From e14537fe378a228ae210c187524d753a01202749 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Mon, 9 Mar 2026 23:15:53 +0530 Subject: [PATCH 01/39] nd42_rest_send: apply rest_send branch changes --- plugins/module_utils/__init__.py | 0 plugins/module_utils/common/__init__.py | 0 plugins/module_utils/common/exceptions.py | 146 ++ .../module_utils/common/pydantic_compat.py | 243 +++ plugins/module_utils/enums.py | 158 ++ plugins/module_utils/nd_v2.py | 317 ++++ plugins/module_utils/rest/__init__.py | 0 .../module_utils/rest/protocols/__init__.py | 0 .../rest/protocols/response_handler.py | 138 ++ .../rest/protocols/response_validation.py | 193 +++ plugins/module_utils/rest/protocols/sender.py | 103 ++ .../module_utils/rest/response_handler_nd.py | 409 +++++ .../rest/response_strategies/__init__.py | 0 .../response_strategies/nd_v1_strategy.py | 246 +++ plugins/module_utils/rest/rest_send.py | 797 +++++++++ plugins/module_utils/rest/results.py | 1019 +++++++++++ plugins/module_utils/rest/sender_nd.py | 322 ++++ tests/sanity/requirements.txt | 9 +- tests/unit/__init__.py | 0 tests/unit/module_utils/__init__.py | 0 tests/unit/module_utils/common_utils.py | 75 + .../fixtures/fixture_data/test_rest_send.json | 244 +++ .../module_utils/fixtures/load_fixture.py | 46 + .../unit/module_utils/mock_ansible_module.py | 95 ++ tests/unit/module_utils/response_generator.py | 60 + tests/unit/module_utils/sender_file.py | 293 ++++ .../module_utils/test_response_handler_nd.py | 1496 +++++++++++++++++ tests/unit/module_utils/test_rest_send.py | 1445 ++++++++++++++++ tests/unit/module_utils/test_sender_nd.py | 906 ++++++++++ 29 files changed, 8757 insertions(+), 3 deletions(-) create mode 100644 plugins/module_utils/__init__.py create mode 100644 plugins/module_utils/common/__init__.py create mode 100644 plugins/module_utils/common/exceptions.py create mode 100644 plugins/module_utils/common/pydantic_compat.py create mode 100644 plugins/module_utils/enums.py create mode 100644 plugins/module_utils/nd_v2.py create mode 100644 plugins/module_utils/rest/__init__.py create mode 100644 plugins/module_utils/rest/protocols/__init__.py create mode 100644 plugins/module_utils/rest/protocols/response_handler.py create mode 100644 plugins/module_utils/rest/protocols/response_validation.py create mode 100644 plugins/module_utils/rest/protocols/sender.py create mode 100644 plugins/module_utils/rest/response_handler_nd.py create mode 100644 plugins/module_utils/rest/response_strategies/__init__.py create mode 100644 plugins/module_utils/rest/response_strategies/nd_v1_strategy.py create mode 100644 plugins/module_utils/rest/rest_send.py create mode 100644 plugins/module_utils/rest/results.py create mode 100644 plugins/module_utils/rest/sender_nd.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/module_utils/__init__.py create mode 100644 tests/unit/module_utils/common_utils.py create mode 100644 tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json create mode 100644 tests/unit/module_utils/fixtures/load_fixture.py create mode 100644 tests/unit/module_utils/mock_ansible_module.py create mode 100644 tests/unit/module_utils/response_generator.py create mode 100644 tests/unit/module_utils/sender_file.py create mode 100644 tests/unit/module_utils/test_response_handler_nd.py create mode 100644 tests/unit/module_utils/test_rest_send.py create mode 100644 tests/unit/module_utils/test_sender_nd.py diff --git a/plugins/module_utils/__init__.py b/plugins/module_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/common/__init__.py b/plugins/module_utils/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/common/exceptions.py b/plugins/module_utils/common/exceptions.py new file mode 100644 index 00000000..16e31ac6 --- /dev/null +++ b/plugins/module_utils/common/exceptions.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# exceptions.py + +Exception classes for the cisco.nd Ansible collection. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, +) + + +class NDErrorData(BaseModel): + """ + # Summary + + Pydantic model for structured error data from NDModule requests. + + This model provides type-safe error information that can be serialized + to a dict for use with Ansible's fail_json. + + ## Attributes + + - msg: Human-readable error message (required) + - status: HTTP status code as integer (optional) + - request_payload: Request payload that was sent (optional) + - response_payload: Response payload from controller (optional) + - raw: Raw response content for non-JSON responses (optional) + + ## Raises + + - None + """ + + model_config = ConfigDict(extra="forbid") + + msg: str + status: Optional[int] = None + request_payload: Optional[dict[str, Any]] = None + response_payload: Optional[dict[str, Any]] = None + raw: Optional[Any] = None + + +class NDModuleError(Exception): + """ + # Summary + + Exception raised by NDModule when a request fails. + + This exception wraps an NDErrorData Pydantic model, providing structured + error information that can be used by callers to build appropriate error + responses (e.g., Ansible fail_json). + + ## Usage Example + + ```python + try: + data = nd.request("/api/v1/endpoint", HttpVerbEnum.POST, payload) + except NDModuleError as e: + print(f"Error: {e.msg}") + print(f"Status: {e.status}") + if e.response_payload: + print(f"Response: {e.response_payload}") + # Use to_dict() for fail_json + module.fail_json(**e.to_dict()) + ``` + + ## Raises + + - None + """ + + # pylint: disable=too-many-arguments + def __init__( + self, + msg: str, + status: Optional[int] = None, + request_payload: Optional[dict[str, Any]] = None, + response_payload: Optional[dict[str, Any]] = None, + raw: Optional[Any] = None, + ) -> None: + self.error_data = NDErrorData( + msg=msg, + status=status, + request_payload=request_payload, + response_payload=response_payload, + raw=raw, + ) + super().__init__(msg) + + @property + def msg(self) -> str: + """Human-readable error message.""" + return self.error_data.msg + + @property + def status(self) -> Optional[int]: + """HTTP status code.""" + return self.error_data.status + + @property + def request_payload(self) -> Optional[dict[str, Any]]: + """Request payload that was sent.""" + return self.error_data.request_payload + + @property + def response_payload(self) -> Optional[dict[str, Any]]: + """Response payload from controller.""" + return self.error_data.response_payload + + @property + def raw(self) -> Optional[Any]: + """Raw response content for non-JSON responses.""" + return self.error_data.raw + + def to_dict(self) -> dict[str, Any]: + """ + # Summary + + Convert exception attributes to a dict for use with fail_json. + + Returns a dict containing only non-None attributes. + + ## Raises + + - None + """ + return self.error_data.model_dump(exclude_none=True) diff --git a/plugins/module_utils/common/pydantic_compat.py b/plugins/module_utils/common/pydantic_compat.py new file mode 100644 index 00000000..e1550a18 --- /dev/null +++ b/plugins/module_utils/common/pydantic_compat.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# pylint: disable=too-few-public-methods +""" +# Summary + +Pydantic compatibility layer. + +This module provides a single location for Pydantic imports with fallback +implementations when Pydantic is not available. This ensures consistent +behavior across all modules and follows the DRY principle. + +## Usage + +### Importing + +Rather than importing directly from pydantic, import from this module: + +```python +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel +``` + +This ensure that Ansible sanity tests will not fail due to missing Pydantic dependencies. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import traceback +from typing import TYPE_CHECKING, Any, Callable, Union + +if TYPE_CHECKING: + # Type checkers always see the real Pydantic types + from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PydanticExperimentalWarning, + StrictBool, + ValidationError, + field_serializer, + field_validator, + model_validator, + validator, + ) + + HAS_PYDANTIC = True # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name +else: + # Runtime: try to import, with fallback + try: + from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PydanticExperimentalWarning, + StrictBool, + ValidationError, + field_serializer, + field_validator, + model_validator, + validator, + ) + except ImportError: + HAS_PYDANTIC = False # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR: Union[str, None] = traceback.format_exc() # pylint: disable=invalid-name + + # Fallback: Minimal BaseModel replacement + class BaseModel: + """Fallback BaseModel when pydantic is not available.""" + + model_config = {"validate_assignment": False, "use_enum_values": False} + + def __init__(self, **kwargs): + """Accept keyword arguments and set them as attributes.""" + for key, value in kwargs.items(): + setattr(self, key, value) + + def model_dump(self, exclude_none: bool = False, exclude_defaults: bool = False) -> dict: # pylint: disable=unused-argument + """Return a dictionary of field names and values. + + Args: + exclude_none: If True, exclude fields with None values + exclude_defaults: Accepted for API compatibility but not implemented in fallback + """ + result = {} + for key, value in self.__dict__.items(): + if exclude_none and value is None: + continue + result[key] = value + return result + + # Fallback: ConfigDict that does nothing + def ConfigDict(**kwargs) -> dict: # pylint: disable=unused-argument,invalid-name + """Pydantic ConfigDict fallback when pydantic is not available.""" + return kwargs + + # Fallback: Field that does nothing + def Field(**kwargs) -> Any: # pylint: disable=unused-argument,invalid-name + """Pydantic Field fallback when pydantic is not available.""" + if "default_factory" in kwargs: + return kwargs["default_factory"]() + return kwargs.get("default") + + # Fallback: field_serializer decorator that does nothing + def field_serializer(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic field_serializer fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: field_validator decorator that does nothing + def field_validator(*args, **kwargs) -> Callable[..., Any]: # pylint: disable=unused-argument,invalid-name + """Pydantic field_validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: AfterValidator that returns the function unchanged + def AfterValidator(func): # pylint: disable=invalid-name + """Pydantic AfterValidator fallback when pydantic is not available.""" + return func + + # Fallback: BeforeValidator that returns the function unchanged + def BeforeValidator(func): # pylint: disable=invalid-name + """Pydantic BeforeValidator fallback when pydantic is not available.""" + return func + + # Fallback: PydanticExperimentalWarning + PydanticExperimentalWarning = Warning + + # Fallback: StrictBool + StrictBool = bool + + # Fallback: ValidationError + class ValidationError(Exception): + """ + Pydantic ValidationError fallback when pydantic is not available. + """ + + def __init__(self, message="A custom error occurred."): + self.message = message + super().__init__(self.message) + + def __str__(self): + return f"ValidationError: {self.message}" + + # Fallback: model_validator decorator that does nothing + def model_validator(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic model_validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: validator decorator that does nothing + def validator(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + else: + HAS_PYDANTIC = True # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name + + +def require_pydantic(module) -> None: + """ + # Summary + + Call `module.fail_json` if pydantic is not installed. + + Intended to be called once at the top of a module's `main()` function, + immediately after `AnsibleModule` is instantiated, to provide a clear + error message when pydantic is a required dependency. + + ## Example + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic + + def main(): + module = AnsibleModule(argument_spec=...) + require_pydantic(module) + ``` + + ## Raises + + None + + ## Notes + + - Does nothing if pydantic is installed. + - Uses Ansible's `missing_required_lib` to produce a standardized error + message that includes installation instructions. + """ + if not HAS_PYDANTIC: + from ansible.module_utils.basic import missing_required_lib # pylint: disable=import-outside-toplevel + + module.fail_json(msg=missing_required_lib("pydantic"), exception=PYDANTIC_IMPORT_ERROR) + + +__all__ = [ + "AfterValidator", + "BaseModel", + "BeforeValidator", + "ConfigDict", + "Field", + "HAS_PYDANTIC", + "PYDANTIC_IMPORT_ERROR", + "PydanticExperimentalWarning", + "StrictBool", + "ValidationError", + "field_serializer", + "field_validator", + "model_validator", + "require_pydantic", + "validator", +] diff --git a/plugins/module_utils/enums.py b/plugins/module_utils/enums.py new file mode 100644 index 00000000..55d1f1ac --- /dev/null +++ b/plugins/module_utils/enums.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# pylint: disable=wrong-import-position +# pylint: disable=missing-module-docstring +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# Summary + +Enum definitions for Nexus Dashboard Ansible modules. + +## Enums + +- HttpVerbEnum: Enum for HTTP verb values used in endpoints. +- OperationType: Enum for operation types used by Results to determine if changes have occurred. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from enum import Enum + + +class BooleanStringEnum(str, Enum): + """ + # Summary + + Enum for boolean string values used in query parameters. + + ## Members + + - TRUE: Represents the string "true". + - FALSE: Represents the string "false". + """ + + TRUE = "true" + FALSE = "false" + + +class HttpVerbEnum(str, Enum): + """ + # Summary + + Enum for HTTP verb values used in endpoints. + + ## Members + + - GET: Represents the HTTP GET method. + - POST: Represents the HTTP POST method. + - PUT: Represents the HTTP PUT method. + - DELETE: Represents the HTTP DELETE method. + - PATCH: Represents the HTTP PATCH method. + """ + + GET = "GET" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + PATCH = "PATCH" + + @classmethod + def values(cls) -> list[str]: + """ + # Summary + + Returns a list of all enum values. + + ## Returns + + - A list of string values representing the enum members. + """ + return sorted([member.value for member in cls]) + + +class OperationType(Enum): + """ + # Summary + + Enumeration for operation types. + + Used by Results to determine if changes have occurred based on the operation type. + + - QUERY: Represents a query operation which does not change state. + - CREATE: Represents a create operation which adds new resources. + - UPDATE: Represents an update operation which modifies existing resources. + - DELETE: Represents a delete operation which removes resources. + + # Usage + + ```python + from plugins.module_utils.enums import OperationType + class MyModule: + def __init__(self): + self.operation_type = OperationType.QUERY + ``` + + The above informs the Results class that the current operation is a query, and thus + no changes should be expected. + + Specifically, Results._determine_if_changed() will return False for QUERY operations, + while it will evaluate CREATE, UPDATE, and DELETE operations in more detail to + determine if any changes have occurred. + """ + + QUERY = "query" + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + + def changes_state(self) -> bool: + """ + # Summary + + Return True if this operation type can change controller state. + + ## Returns + + - `bool`: True if operation can change state, False otherwise + + ## Examples + + ```python + OperationType.QUERY.changes_state() # Returns False + OperationType.CREATE.changes_state() # Returns True + OperationType.DELETE.changes_state() # Returns True + ``` + """ + return self in ( + OperationType.CREATE, + OperationType.UPDATE, + OperationType.DELETE, + ) + + def is_read_only(self) -> bool: + """ + # Summary + + Return True if this operation type is read-only. + + ## Returns + + - `bool`: True if operation is read-only, False otherwise + + ## Examples + + ```python + OperationType.QUERY.is_read_only() # Returns True + OperationType.CREATE.is_read_only() # Returns False + ``` + """ + return self == OperationType.QUERY diff --git a/plugins/module_utils/nd_v2.py b/plugins/module_utils/nd_v2.py new file mode 100644 index 00000000..0a3fe61a --- /dev/null +++ b/plugins/module_utils/nd_v2.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# nd_v2.py + +Simplified NDModule using RestSend infrastructure with exception-based error handling. + +This module provides a streamlined interface for interacting with Nexus Dashboard +controllers. Unlike the original nd.py which uses Ansible's fail_json/exit_json, +this module raises Python exceptions, making it: + +- Easier to unit test +- Reusable with non-Ansible code (e.g., raw Python Requests) +- More Pythonic in error handling + +## Usage Example + +```python +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule, + NDModuleError, + nd_argument_spec, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +def main(): + argument_spec = nd_argument_spec() + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + nd = NDModule(module) + + try: + data = nd.request("/api/v1/some/endpoint", HttpVerbEnum.GET) + module.exit_json(changed=False, data=data) + except NDModuleError as e: + module.fail_json(msg=e.msg, status=e.status, response_payload=e.response_payload) +``` +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import logging +from typing import Any, Optional + +from ansible.module_utils.basic import env_fallback +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.response_handler import ResponseHandlerProtocol +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.sender import SenderProtocol +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend +from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender + + +def nd_argument_spec() -> dict[str, Any]: + """ + Return the common argument spec for ND modules. + + This function provides the standard arguments that all ND modules + should accept for connection and authentication. + """ + return dict( + host=dict(type="str", required=False, aliases=["hostname"], fallback=(env_fallback, ["ND_HOST"])), + port=dict(type="int", required=False, fallback=(env_fallback, ["ND_PORT"])), + username=dict(type="str", fallback=(env_fallback, ["ND_USERNAME", "ANSIBLE_NET_USERNAME"])), + password=dict(type="str", required=False, no_log=True, fallback=(env_fallback, ["ND_PASSWORD", "ANSIBLE_NET_PASSWORD"])), + output_level=dict(type="str", default="normal", choices=["debug", "info", "normal"], fallback=(env_fallback, ["ND_OUTPUT_LEVEL"])), + timeout=dict(type="int", default=30, fallback=(env_fallback, ["ND_TIMEOUT"])), + use_proxy=dict(type="bool", fallback=(env_fallback, ["ND_USE_PROXY"])), + use_ssl=dict(type="bool", fallback=(env_fallback, ["ND_USE_SSL"])), + validate_certs=dict(type="bool", fallback=(env_fallback, ["ND_VALIDATE_CERTS"])), + login_domain=dict(type="str", fallback=(env_fallback, ["ND_LOGIN_DOMAIN"])), + ) + + +class NDModule: + """ + # Summary + + Simplified NDModule using RestSend infrastructure with exception-based error handling. + + This class provides a clean interface for making REST API requests to Nexus Dashboard + controllers. It uses the RestSend/Sender/ResponseHandler infrastructure for + separation of concerns and testability. + + ## Key Differences from nd.py NDModule + + 1. Uses exceptions (NDModuleError) instead of fail_json/exit_json + 2. No Connection class dependency - uses Sender for HTTP operations + 3. Minimal state - only tracks request/response metadata + 4. request() leverages RestSend -> Sender -> ResponseHandler + + ## Usage Example + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule, NDModuleError + from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + nd = NDModule(module) + + try: + # GET request + data = nd.request("/api/v1/endpoint") + + # POST request with payload + result = nd.request("/api/v1/endpoint", HttpVerbEnum.POST, {"key": "value"}) + except NDModuleError as e: + module.fail_json(**e.to_dict()) + ``` + + ## Raises + + - NDModuleError: When a request fails (replaces fail_json) + - ValueError: When RestSend encounters configuration errors + - TypeError: When invalid types are passed to RestSend + """ + + def __init__(self, module) -> None: + """ + Initialize NDModule with an AnsibleModule instance. + + Args: + module: AnsibleModule instance (or compatible mock for testing) + """ + self.class_name = self.__class__.__name__ + self.module = module + self.params: dict[str, Any] = module.params + + self.log = logging.getLogger(f"nd.{self.class_name}") + + # Request/response state (for debugging and error reporting) + self.method: Optional[str] = None + self.path: Optional[str] = None + self.response: Optional[str] = None + self.status: Optional[int] = None + self.url: Optional[str] = None + + # RestSend infrastructure (lazy initialized) + self._rest_send: Optional[RestSend] = None + self._sender: Optional[SenderProtocol] = None + self._response_handler: Optional[ResponseHandlerProtocol] = None + + if self.module._debug: + self.module.warn("Enable debug output because ANSIBLE_DEBUG was set.") + self.params["output_level"] = "debug" + + def _get_rest_send(self) -> RestSend: + """ + # Summary + + Lazy initialization of RestSend and its dependencies. + + ## Returns + + - RestSend: Configured RestSend instance ready for use. + """ + method_name = "_get_rest_send" + params = {} + if self._rest_send is None: + params = { + "check_mode": self.module.check_mode, + "state": self.params.get("state"), + } + self._sender = Sender() + self._sender.ansible_module = self.module + self._response_handler = ResponseHandler() + self._rest_send = RestSend(params) + self._rest_send.sender = self._sender + self._rest_send.response_handler = self._response_handler + + msg = f"{self.class_name}.{method_name}: " + msg += "Initialized RestSend instance with params: " + msg += f"{params}" + self.log.debug(msg) + return self._rest_send + + @property + def rest_send(self) -> RestSend: + """ + # Summary + + Access to the RestSend instance used by this NDModule. + + ## Returns + + - RestSend: The RestSend instance. + + ## Raises + + - `ValueError`: If accessed before `request()` has been called. + + ## Usage + + ```python + nd = NDModule(module) + data = nd.request("/api/v1/endpoint") + + # Access RestSend response/result + response = nd.rest_send.response_current + result = nd.rest_send.result_current + ``` + """ + if self._rest_send is None: + msg = f"{self.class_name}.rest_send: " + msg += "rest_send must be initialized before accessing. " + msg += "Call request() first." + raise ValueError(msg) + return self._rest_send + + def request( + self, + path: str, + verb: HttpVerbEnum = HttpVerbEnum.GET, + data: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """ + # Summary + + Make a REST API request to the Nexus Dashboard controller. + + This method uses the RestSend infrastructure for improved separation + of concerns and testability. + + ## Args + + - path: The fully-formed API endpoint path including query string + (e.g., "/appcenter/cisco/ndfc/api/v1/endpoint?param=value") + - verb: HTTP verb as HttpVerbEnum (default: HttpVerbEnum.GET) + - data: Optional request payload as a dict + + ## Returns + + The response DATA from the controller (parsed JSON body). + + For full response metadata (status, message, etc.), access + `rest_send.response_current` and `rest_send.result_current` + after calling this method. + + ## Raises + + - `NDModuleError`: If the request fails (with status, payload, etc.) + - `ValueError`: If RestSend encounters configuration errors + - `TypeError`: If invalid types are passed + """ + method_name = "request" + # If PATCH with empty data, return early (existing behavior) + if verb == HttpVerbEnum.PATCH and not data: + return {} + + rest_send = self._get_rest_send() + + # Send the request + try: + rest_send.path = path + rest_send.verb = verb # type: ignore[assignment] + msg = f"{self.class_name}.{method_name}: " + msg += "Sending request " + msg += f"verb: {verb}, " + msg += f"path: {path}" + if data: + rest_send.payload = data + msg += f", data: {data}" + self.log.debug(msg) + rest_send.commit() + except (TypeError, ValueError) as error: + raise ValueError(f"Error in request: {error}") from error + + # Get response and result from RestSend + response = rest_send.response_current + result = rest_send.result_current + + # Update state for debugging/error reporting + self.method = verb.value + self.path = path + self.response = response.get("MESSAGE") + self.status = response.get("RETURN_CODE", -1) + self.url = response.get("REQUEST_PATH") + + # Handle errors based on result + if not result.get("success", False): + response_data = response.get("DATA") + + # Get error message from ResponseHandler + error_msg = self._response_handler.error_message if self._response_handler else "Unknown error" + + # Build exception with available context + raw = None + payload = None + + if isinstance(response_data, dict): + if "raw_response" in response_data: + raw = response_data["raw_response"] + else: + payload = response_data + + raise NDModuleError( + msg=error_msg if error_msg else "Unknown error", + status=self.status, + request_payload=data, + response_payload=payload, + raw=raw, + ) + + # Return the response data on success + return response.get("DATA", {}) diff --git a/plugins/module_utils/rest/__init__.py b/plugins/module_utils/rest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/rest/protocols/__init__.py b/plugins/module_utils/rest/protocols/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/rest/protocols/response_handler.py b/plugins/module_utils/rest/protocols/response_handler.py new file mode 100644 index 00000000..487e12cf --- /dev/null +++ b/plugins/module_utils/rest/protocols/response_handler.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# pylint: disable=missing-module-docstring +# pylint: disable=unnecessary-ellipsis +# pylint: disable=wrong-import-position +# Copyright: (c) 2026, Allen Robel (@arobel) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +""" +Protocol definition for ResponseHandler classes. +""" + +try: + from typing import Protocol, runtime_checkable +except ImportError: + try: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + except ImportError: + + class Protocol: # type: ignore[no-redef] + """Stub for Python < 3.8 without typing_extensions.""" + + def runtime_checkable(cls): # type: ignore[no-redef] + return cls + + +from typing import Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +@runtime_checkable +class ResponseHandlerProtocol(Protocol): + """ + # Summary + + Protocol defining the interface for response handlers in RestSend. + + Any class implementing this protocol must provide: + + - `response` property (getter/setter): The controller response dict. + - `result` property (getter): The calculated result based on response and verb. + - `verb` property (getter/setter): The HTTP method (GET, POST, PUT, DELETE, etc.). + - `commit()` method: Parses response and sets result. + + ## Notes + + - Getters for `response`, `result`, and `verb` should raise `ValueError` if + accessed before being set. + + ## Example Implementations + + - `ResponseHandler` in `response_handler_nd.py`: Handles Nexus Dashboard responses. + - Future: `ResponseHandlerApic` for APIC controller responses. + """ + + @property + def response(self) -> dict: + """ + # Summary + + The controller response. + + ## Raises + + - ValueError: If accessed before being set. + """ + ... + + @response.setter + def response(self, value: dict) -> None: + pass + + @property + def result(self) -> dict: + """ + # Summary + + The calculated result based on response and verb. + + ## Raises + + - ValueError: If accessed before commit() is called. + """ + ... + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the request. + + ## Raises + + - ValueError: If accessed before being set. + """ + ... + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + pass + + def commit(self) -> None: + """ + # Summary + + Parse the response and set the result. + + ## Raises + + - ValueError: If response or verb is not set. + """ + ... + + @property + def error_message(self) -> Optional[str]: + """ + # Summary + + Human-readable error message extracted from response. + + ## Returns + + - str: Error message if an error occurred. + - None: If the request was successful or commit() not called. + """ + ... diff --git a/plugins/module_utils/rest/protocols/response_validation.py b/plugins/module_utils/rest/protocols/response_validation.py new file mode 100644 index 00000000..bb627196 --- /dev/null +++ b/plugins/module_utils/rest/protocols/response_validation.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# Summary + +Protocol definition for version-specific response validation strategies. + +## Description + +This module defines the ResponseValidationStrategy protocol which specifies +the interface for handling version-specific differences in ND API responses, +including status code validation and error message extraction. + +When ND API v2 is released with different status codes or response formats, +implementing a new strategy class allows clean separation of v1 and v2 logic. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +try: + from typing import Protocol, runtime_checkable +except ImportError: + try: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + except ImportError: + + class Protocol: # type: ignore[no-redef] + """Stub for Python < 3.8 without typing_extensions.""" + + def runtime_checkable(cls): # type: ignore[no-redef] + return cls + + +from typing import Optional + +# pylint: disable=unnecessary-ellipsis + + +@runtime_checkable +class ResponseValidationStrategy(Protocol): + """ + # Summary + + Protocol for version-specific response validation. + + ## Description + + This protocol defines the interface for handling version-specific + differences in ND API responses, including status code validation + and error message extraction. + + Implementations of this protocol enable injecting version-specific + behavior into ResponseHandler without modifying the handler itself. + + ## Methods + + See property and method definitions below. + + ## Raises + + None - implementations may raise exceptions per their logic + """ + + @property + def success_codes(self) -> set[int]: + """ + # Summary + + Return set of HTTP status codes considered successful. + + ## Returns + + - Set of integers representing success status codes + """ + ... + + @property + def not_found_code(self) -> int: + """ + # Summary + + Return HTTP status code for resource not found. + + ## Returns + + - Integer representing not-found status code (typically 404) + """ + ... + + @property + def error_codes(self) -> set[int]: + """ + # Summary + + Return set of HTTP status codes considered errors. + + ## Returns + + - Set of integers representing error status codes + """ + ... + + def is_success(self, return_code: int) -> bool: + """ + # Summary + + Check if return code indicates success. + + ## Parameters + + - return_code: HTTP status code to check + + ## Returns + + - True if code is in success_codes, 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_error(self, return_code: int) -> bool: + """ + # Summary + + Check if return code indicates error. + + ## Parameters + + - return_code: HTTP status code to check + + ## Returns + + - True if code is in error_codes, False otherwise + + ## Raises + + None + """ + ... + + def extract_error_message(self, response: dict) -> Optional[str]: + """ + # Summary + + Extract error message from response DATA. + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, etc. + + ## Returns + + - Error message string if found, None otherwise + + ## Raises + + None - should return None gracefully if error message cannot be extracted + """ + ... diff --git a/plugins/module_utils/rest/protocols/sender.py b/plugins/module_utils/rest/protocols/sender.py new file mode 100644 index 00000000..5e55047c --- /dev/null +++ b/plugins/module_utils/rest/protocols/sender.py @@ -0,0 +1,103 @@ +# pylint: disable=wrong-import-position +# pylint: disable=missing-module-docstring +# pylint: disable=unnecessary-ellipsis +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +try: + from typing import Protocol, runtime_checkable +except ImportError: + try: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + except ImportError: + + class Protocol: # type: ignore[no-redef] + """Stub for Python < 3.8 without typing_extensions.""" + + def runtime_checkable(cls): # type: ignore[no-redef] + return cls + + +from typing import Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +@runtime_checkable +class SenderProtocol(Protocol): + """ + # Summary + + Protocol defining the sender interface for RestSend. + + Any class implementing this protocol must provide: + + - `path` property (getter/setter): The endpoint path for the REST request. + - `verb` property (getter/setter): The HTTP method (GET, POST, PUT, DELETE, etc.). + - `payload` property (getter/setter): Optional request payload as a dict. + - `response` property (getter): The response from the controller. + - `commit()` method: Sends the request to the controller. + + ## Example Implementations + + - `Sender` in `sender_nd.py`: Uses Ansible HttpApi plugin. + - `Sender` in `sender_file.py`: Reads responses from files (for testing). + """ + + @property + def path(self) -> str: + """Endpoint path for the REST request.""" + ... + + @path.setter + def path(self, value: str) -> None: + """Set the endpoint path for the REST request.""" + ... + + @property + def verb(self) -> HttpVerbEnum: + """HTTP method for the REST request.""" + ... + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + """Set the HTTP method for the REST request.""" + ... + + @property + def payload(self) -> Optional[dict]: + """Optional payload to send to the controller.""" + ... + + @payload.setter + def payload(self, value: dict) -> None: + """Set the optional payload for the REST request.""" + ... + + @property + def response(self) -> dict: + """The response from the controller.""" + ... + + def commit(self) -> None: + """ + Send the request to the controller. + + Raises: + ConnectionError: If there is an error with the connection. + """ + ... diff --git a/plugins/module_utils/rest/response_handler_nd.py b/plugins/module_utils/rest/response_handler_nd.py new file mode 100644 index 00000000..ed5a12fe --- /dev/null +++ b/plugins/module_utils/rest/response_handler_nd.py @@ -0,0 +1,409 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# response_handler_nd.py + +Implements the ResponseHandler interface for handling Nexus Dashboard controller responses. + +## Version Compatibility + +This handler is designed for ND API v1 responses (ND 4.2+). + +### Status Code Assumptions + +Status codes are defined by the injected `ResponseValidationStrategy`, defaulting +to `NdV1Strategy` (ND 4.2+): + +- Success: 200, 201, 202, 204, 207 +- Not Found: 404 (treated as success for GET) +- Error: 405, 409 + +If ND API v2 uses different codes, inject a new strategy via the +`validation_strategy` property rather than modifying this class. + +### Response Format + +Expects ND HttpApi plugin to provide responses with these keys: + +- RETURN_CODE (int): HTTP status code (e.g., 200, 404, 500) +- MESSAGE (str): HTTP reason phrase (e.g., "OK", "Not Found") +- DATA (dict): Parsed JSON body or dict with raw_response if parsing failed +- REQUEST_PATH (str): The request URL path +- METHOD (str): The HTTP method used (GET, POST, PUT, DELETE, PATCH) + +### Supported Error Formats + +The error_message property handles multiple ND API v1 error response formats: + +1. code/message dict: {"code": , "message": } +2. messages array: {"messages": [{"code": , "severity": , "message": }]} +3. errors array: {"errors": [, ...]} +4. raw_response: {"raw_response": } for non-JSON responses + +If ND API v2 changes error response structures, error extraction logic will need updates. + +## Future v2 Considerations + +If ND API v2 changes response format or status codes, implement a new strategy +class (e.g. `NdV2Strategy`) conforming to `ResponseValidationStrategy` and inject +it via `response_handler.validation_strategy = NdV2Strategy()`. + +TODO: Should response be converted to a Pydantic model by this class? +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import logging +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.response_validation import ResponseValidationStrategy +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_strategies.nd_v1_strategy import NdV1Strategy + + +class ResponseHandler: + """ + # Summary + + Implement the response handler interface for injection into RestSend(). + + ## Raises + + - `TypeError` if: + - `response` is not a dict. + - `ValueError` if: + - `response` is missing any fields required by the handler + to calculate the result. + - Required fields: + - `RETURN_CODE` + - `MESSAGE` + - `response` is not set prior to calling `commit()`. + - `verb` is not set prior to calling `commit()`. + + ## Interface specification + + - `response` setter property + - Accepts a dict containing the controller response. + - Raises `TypeError` if: + - `response` is not a dict. + - Raises `ValueError` if: + - `response` is missing any fields required by the handler + to calculate the result, for example `RETURN_CODE` and + `MESSAGE`. + - `result` getter property + - Returns a dict containing the calculated result based on the + controller response and the request verb. + - Raises `ValueError` if `result` is accessed before calling + `commit()`. + - `result` setter property + - Set internally by the handler based on the response and verb. + - `verb` setter property + - Accepts an HttpVerbEnum enum defining the request verb. + - Valid verb: One of "DELETE", "GET", "POST", "PUT". + - e.g. HttpVerbEnum.GET, HttpVerbEnum.POST, etc. + - Raises `ValueError` if verb is not set prior to calling `commit()`. + - `commit()` method + - Parse `response` and set `result`. + - Raise `ValueError` if: + - `response` is not set. + - `verb` is not set. + + ## Usage example + + ```python + # import and instantiate the class + from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import \ + ResponseHandler + response_handler = ResponseHandler() + + try: + # Set the response from the controller + response_handler.response = controller_response + + # Set the request verb + response_handler.verb = HttpVerbEnum.GET + + # Call commit to parse the response + response_handler.commit() + + # Access the result + result = response_handler.result + except (TypeError, ValueError) as error: + handle_error(error) + ``` + + """ + + def __init__(self) -> None: + self.class_name = self.__class__.__name__ + method_name = "__init__" + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self._response: Optional[dict[str, Any]] = None + self._result: Optional[dict[str, Any]] = None + self._strategy: ResponseValidationStrategy = NdV1Strategy() + self._verb: Optional[HttpVerbEnum] = None + + msg = f"ENTERED {self.class_name}.{method_name}" + self.log.debug(msg) + + def _handle_response(self) -> None: + """ + # Summary + + Call the appropriate handler for response based on the value of self.verb + """ + if self.verb == HttpVerbEnum.GET: + self._handle_get_response() + else: + self._handle_post_put_delete_response() + + def _handle_get_response(self) -> None: + """ + # Summary + + Handle GET responses from the controller and set self.result. + + - self.result is a dict containing: + - found: + - False if RETURN_CODE == 404 + - True otherwise (when successful) + - success: + - True if RETURN_CODE in (200, 201, 202, 204, 207, 404) + - False otherwise (error status codes) + """ + result = {} + return_code = self.response.get("RETURN_CODE") + + # 404 Not Found - resource doesn't exist, but request was successful + if self._strategy.is_not_found(return_code): + result["found"] = False + result["success"] = True + # Success codes - resource found + elif self._strategy.is_success(return_code): + 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 = {} + return_code = self.response.get("RETURN_CODE") + + # Check for explicit error in response + if self.response.get("ERROR") is not None: + result["success"] = False + result["changed"] = False + # Check for error in response data (ND error format) + elif self.response.get("DATA", {}).get("error") is not None: + result["success"] = False + result["changed"] = False + # Success codes indicate the operation completed + elif self._strategy.is_success(return_code): + result["success"] = True + result["changed"] = True + # Any other status code is an error + else: + result["success"] = False + result["changed"] = False + + self.result = copy.copy(result) + + def commit(self) -> None: + """ + # Summary + + Parse the response from the controller and set self.result + based on the response. + + ## Raises + + - ``ValueError`` if: + - ``response`` is not set. + - ``verb`` is not set. + """ + method_name = "commit" + msg = f"{self.class_name}.{method_name}: " + msg += f"response {self.response}, verb {self.verb}" + self.log.debug(msg) + self._handle_response() + + @property + def response(self) -> dict[str, Any]: + """ + # Summary + + The controller response. + + ## Raises + + - getter: ``ValueError`` if response is not set. + - setter: ``TypeError`` if ``response`` is not a dict. + - setter: ``ValueError`` if ``response`` is missing required fields + (``RETURN_CODE``, ``MESSAGE``). + """ + if self._response is None: + msg = f"{self.class_name}.response: " + msg += "response must be set before accessing." + raise ValueError(msg) + return self._response + + @response.setter + def response(self, value: dict[str, Any]) -> None: + method_name = "response" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.{method_name} must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + if value.get("MESSAGE", None) is None: + msg = f"{self.class_name}.{method_name}: " + msg += "response must have a MESSAGE key. " + msg += f"Got: {value}." + raise ValueError(msg) + if value.get("RETURN_CODE", None) is None: + msg = f"{self.class_name}.{method_name}: " + msg += "response must have a RETURN_CODE key. " + msg += f"Got: {value}." + raise ValueError(msg) + self._response = value + + @property + def result(self) -> dict[str, Any]: + """ + # Summary + + The result calculated by the handler based on the controller response. + + ## Raises + + - getter: ``ValueError`` if result is not set (commit() not called). + - setter: ``TypeError`` if result is not a dict. + """ + if self._result is None: + msg = f"{self.class_name}.result: " + msg += "result must be set before accessing. Call commit() first." + raise ValueError(msg) + return self._result + + @result.setter + def result(self, value: dict[str, Any]) -> None: + method_name = "result" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.{method_name} must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + self._result = value + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the REST request e.g. HttpVerbEnum.GET, HttpVerbEnum.POST, etc. + + ## Raises + + - ``ValueError`` if value is not set. + """ + if self._verb is None: + raise ValueError(f"{self.class_name}.verb is not set.") + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + self._verb = value + + @property + def error_message(self) -> Optional[str]: + """ + # Summary + + Extract a human-readable error message from the response DATA. + + Delegates to the injected `ResponseValidationStrategy`. Returns None if + result indicates success or if `commit()` has not been called. + + ## Returns + + - str: Human-readable error message if an error occurred. + - None: If the request was successful or `commit()` not called. + + ## Raises + + None + """ + if self._result is not None and not self._result.get("success", True): + return self._strategy.extract_error_message(self._response) + return None + + @property + def validation_strategy(self) -> ResponseValidationStrategy: + """ + # Summary + + The response validation strategy used to check status codes and extract + error messages. + + ## Returns + + - `ResponseValidationStrategy`: The current strategy instance. + + ## Raises + + None + """ + return self._strategy + + @validation_strategy.setter + def validation_strategy(self, value: ResponseValidationStrategy) -> None: + """ + # Summary + + Set the response validation strategy. + + ## Raises + + ### TypeError + + - If `value` does not implement `ResponseValidationStrategy`. + """ + method_name = "validation_strategy" + if not isinstance(value, ResponseValidationStrategy): + msg = f"{self.class_name}.{method_name}: " + msg += f"Expected ResponseValidationStrategy. Got {type(value)}." + raise TypeError(msg) + self._strategy = value diff --git a/plugins/module_utils/rest/response_strategies/__init__.py b/plugins/module_utils/rest/response_strategies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py b/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py new file mode 100644 index 00000000..a591a36d --- /dev/null +++ b/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py @@ -0,0 +1,246 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# Summary + +ND API v1 response validation strategy. + +## Description + +Implements status code validation and error message extraction for ND API v1 +responses (ND 4.2). + +This strategy encapsulates the response handling logic previously hardcoded +in ResponseHandler, enabling version-specific behavior to be injected. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import Any, Optional + + +class NdV1Strategy: + """ + # Summary + + Response validation strategy for ND API v1. + + ## Description + + Implements status code validation and error message extraction + for ND API v1 (ND 4.2+). + + ## Status Codes + + - Success: 200, 201, 202, 204, 207 + - Not Found: 404 (treated as success for GET) + - Error: 405, 409 + + ## 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: DATA.messages[0].{code, severity, message} + 4. errors array: DATA.errors[0] + 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 + + @property + def error_codes(self) -> set[int]: + """ + # Summary + + Return v1 error codes. + + ## Returns + + - Set of integers: {405, 409} + + ## Raises + + None + """ + return {405, 409} + + def is_success(self, return_code: int) -> bool: + """ + # Summary + + Check if return code indicates success (v1). + + ## Parameters + + - return_code: HTTP status code to check + + ## Returns + + - True if code is in success_codes, False otherwise + + ## Raises + + None + """ + return return_code in self.success_codes + + 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_error(self, return_code: int) -> bool: + """ + # Summary + + Check if return code indicates error (v1). + + ## Parameters + + - return_code: HTTP status code to check + + ## Returns + + - True if code is in error_codes, False otherwise + + ## Raises + + None + """ + return return_code in self.error_codes + + 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 + 5. errors array + 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: + first_msg = data_dict["messages"][0] + if all(k in first_msg for k in ("code", "severity", "message")): + msg = f"ND Error {first_msg['code']} ({first_msg['severity']}): {first_msg['message']}" + + # errors array format + if msg is None and "errors" in data_dict and len(data_dict.get("errors", [])) > 0: + msg = f"ND Error: {data_dict['errors'][0]}" + + # Unknown dict format - fallback + if msg is None: + msg = f"ND Error: Request failed with status {return_code}" + # Non-dict response data + else: + msg = f"ND Error: {response_data}" + + return msg diff --git a/plugins/module_utils/rest/rest_send.py b/plugins/module_utils/rest/rest_send.py new file mode 100644 index 00000000..4e903fb0 --- /dev/null +++ b/plugins/module_utils/rest/rest_send.py @@ -0,0 +1,797 @@ +# -*- coding: utf-8 -*- +# pylint: disable=wrong-import-position +# pylint: disable=missing-module-docstring +# Copyright: (c) 2026, Allen Robel (@arobel) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +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` + - `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 `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 + response = rest_send.response + # dict containing the current controller response + response_current = rest_send.response_current + # list of results from the controller for this session + result = rest_send.result + # 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._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` + - self.response raises `TypeError` + - self.result 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) + 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._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 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 response(self) -> list[dict]: + """ + # Summary + + The aggregated list of responses from the controller. + + `commit()` must be called first. + + ## Raises + + - setter: `TypeError` if value is not a `dict` + + """ + 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.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 result(self) -> list[dict]: + """ + # Summary + + The aggregated list of results from the controller. + + `commit()` must be called first. + + ## Raises + + - setter: `TypeError` if: + - value is not a `dict`. + + """ + return copy.deepcopy(self._result) + + @result.setter + def result(self, value: dict): + method_name = "result" + 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._result.append(value) + + @property + def result_current(self) -> dict: + """ + # Summary + + The current result from the controller + + `commit()` must be called first. + + This is a dict containing the current result. + + ## Raises + + - setter: `TypeError` if value is not a `dict` + + """ + return copy.deepcopy(self._result_current) + + @result_current.setter + def result_current(self, value: dict): + method_name = "result_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + self._result_current = value + + @property + def send_interval(self) -> int: + """ + # Summary + + Send interval, in seconds, for retrying responses from the controller. + + ## Raises + + - setter: ``TypeError`` if value is not an `int` + + ## Default + + `5` + """ + return self._send_interval + + @send_interval.setter + def send_interval(self, value: int) -> None: + method_name = "send_interval" + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be an integer. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + # Check explicit boolean first since isinstance(True, int) is True + if isinstance(value, bool): + raise TypeError(msg) + if not isinstance(value, int): + raise TypeError(msg) + self._send_interval = value + + @property + def sender(self) -> SenderProtocol: + """ + # Summary + + A class implementing the SenderProtocol. + + See module_utils/rest/protocols/sender.py for SenderProtocol definition. + + ## Raises + + - getter: ``ValueError`` if sender is not set before accessing. + - setter: ``TypeError`` if value does not implement SenderProtocol. + """ + if self._sender is None: + msg = f"{self.class_name}.sender: " + msg += "sender must be set before accessing." + raise ValueError(msg) + return self._sender + + @sender.setter + def sender(self, value: SenderProtocol): + required_members = ( + "path", + "verb", + "payload", + "response", + "commit", + ) + missing_members = [member for member in required_members if not self._has_member_static(value, member)] + if missing_members: + msg = f"{self.class_name}.sender: " + msg += "value must implement SenderProtocol. " + msg += f"Missing members: {missing_members}. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + if not callable(getattr(value, "commit", None)): + msg = f"{self.class_name}.sender: " + msg += "value.commit must be callable. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + self._sender = value + + @property + def timeout(self) -> int: + """ + # Summary + + Timeout, in seconds, for retrieving responses from the controller. + + ## Raises + + - setter: ``TypeError`` if value is not an ``int`` + + ## Default + + `300` + """ + return self._timeout + + @timeout.setter + def timeout(self, value: int) -> None: + method_name = "timeout" + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be an integer. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + if isinstance(value, bool): + raise TypeError(msg) + if not isinstance(value, int): + raise TypeError(msg) + self._timeout = value + + @property + def unit_test(self) -> bool: + """ + # Summary + + Is RestSend being called from a unit test. + Set this to True in unit tests to speed the test up. + + ## Raises + + - setter: `TypeError` if value is not a `bool` + + ## Default + + `False` + """ + return self._unit_test + + @unit_test.setter + def unit_test(self, value: bool) -> None: + method_name = "unit_test" + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a boolean. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + raise TypeError(msg) + self._unit_test = value + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the REST request e.g. HttpVerbEnum.GET, HttpVerbEnum.POST, etc. + + ## Raises + + - setter: `TypeError` if value is not an instance of HttpVerbEnum + - getter: `ValueError` if verb is not set before accessing. + """ + if self._verb is None: + msg = f"{self.class_name}.verb: " + msg += "verb must be set before accessing." + raise ValueError(msg) + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum): + if not isinstance(value, HttpVerbEnum): + msg = f"{self.class_name}.verb: " + msg += "verb must be an instance of HttpVerbEnum. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + self._verb = value diff --git a/plugins/module_utils/rest/results.py b/plugins/module_utils/rest/results.py new file mode 100644 index 00000000..140ec8c5 --- /dev/null +++ b/plugins/module_utils/rest/results.py @@ -0,0 +1,1019 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# pylint: disable=too-many-instance-attributes,too-many-public-methods,line-too-long,too-many-lines +""" +Exposes public class Results to collect results across Ansible tasks. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import logging +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, + ValidationError, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType + + +class TaskResultData(BaseModel): + """ + # Summary + + Pydantic model for a single task result. + + Represents all data for one task including its response, result, diff, + and metadata. Immutable after creation to prevent accidental modification + of registered tasks. + + ## Raises + + - `ValidationError`: if field validation fails during instantiation + + ## Attributes + + - `sequence_number`: Unique sequence number for this task (required, >= 1) + - `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) + response: dict[str, Any] + result: dict[str, Any] + diff: dict[str, Any] + metadata: dict[str, Any] + changed: bool + failed: bool + + +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) + - `response`: List of all response dicts (default empty list) + - `result`: List of all result dicts (default empty list) + - `metadata`: List of all metadata dicts (default empty list) + """ + + model_config = ConfigDict(extra="forbid") + + changed: bool + failed: bool + diff: list[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) + metadata: list[dict[str, Any]] = Field(default_factory=list) + + +class CurrentTaskData(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 `TaskResultData`. + 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) + """ + + 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 + + +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. `CurrentTaskData` - Mutable staging area for building the current task + 2. `TaskResultData` - Immutable registered task 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_task_result()` + - Converts current task to immutable `TaskResultData` + - 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_task_result()`. + + 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_task_result() 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_task_result() determines changed/failed automatically + self._results.register_task_result() + ... + + @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[TaskResultData] = [] + + # Current task being built (mutable) + self._current: CurrentTaskData = CurrentTaskData() + + # Aggregated state (derived from tasks) + self._changed: set[bool] = set() + self._failed: set[bool] = set() + + # Final result (built on demand) + self._final_result: Optional[FinalResultData] = None + + # Legacy: response_data list for backward compatibility + self._response_data: list[dict[str, Any]] = [] + + msg = f"ENTERED {self.class_name}():" + self.log.debug(msg) + + def add_response_data(self, value: dict[str, Any]) -> None: + """ + # Summary + + Add a dict to the response_data list. + + ## Raises + + - `TypeError`: if value is not a dict + + ## See also + + `@response_data` property + """ + method_name: str = "add_response_data" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"instance.add_response_data must be a dict. Got {value}" + raise TypeError(msg) + self._response_data.append(copy.deepcopy(value)) + + 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_task_result(self) -> None: + """ + # Summary + + Register the current task result. + + Converts `CurrentTaskData` to immutable `TaskResultData`, 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 `TaskResultData` with validation + 7. Register the task and update aggregated changed/failed sets + 8. Reset current task for next registration + """ + method_name: str = "register_task_result" + + 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 TaskResultData with validation + try: + task_data = TaskResultData( + sequence_number=self.task_sequence_number, + 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) + self._changed.add(changed) + self._failed.add(failed) + + # Reset current task for next task + self._current = CurrentTaskData() + + # 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] + response_list = [task.response for task in self._tasks] + result_list = [task.result for task in self._tasks] + metadata_list = [task.metadata 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, + response=response_list, + result=result_list, + metadata=metadata_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. + + ## Raises + + None + + ## Returns + + - A set() of boolean values indicating whether any tasks changed + + ## See also + + - `register_task_result()` method to register tasks and update the changed set. + """ + return self._changed + + @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 diff(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 + + - If the set contains True, at least one task failed. + - If the set contains only False all tasks succeeded. + + ## Raises + + None + + ## See also + + - `register_task_result()` method to register tasks and update the failed set. + """ + return self._failed + + @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 response(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 response_data(self) -> list[dict[str, Any]]: + """ + # Summary + + Return a `list` of `dict`, where each `dict` contains the contents of the DATA key + within the responses that have been added. + + ## Raises + + None + + ## See also + + `add_response_data()` method to add to the response_data list. + """ + return self._response_data + + @property + def result(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 diff --git a/plugins/module_utils/rest/sender_nd.py b/plugins/module_utils/rest/sender_nd.py new file mode 100644 index 00000000..ae333dd0 --- /dev/null +++ b/plugins/module_utils/rest/sender_nd.py @@ -0,0 +1,322 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Sender module conforming to SenderProtocol. + +See plugins/module_utils/protocol_sender.py for the protocol definition. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import inspect +import json +import logging +from typing import Any, Optional + +from ansible.module_utils.basic import AnsibleModule # type: ignore +from ansible.module_utils.connection import Connection # type: ignore +from ansible.module_utils.connection import ConnectionError as AnsibleConnectionError +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class Sender: + """ + # Summary + + An injected dependency for `RestSend` which implements the + `sender` interface. Responses are retrieved using the Ansible HttpApi plugin. + + For the `sender` interface definition, see `plugins/module_utils/protocol_sender.py`. + + ## Raises + + - `ValueError` if: + - `ansible_module` is not set. + - `path` is not set. + - `verb` is not set. + - `TypeError` if: + - `ansible_module` is not an instance of AnsibleModule. + - `payload` is not a `dict`. + - `response` is not a `dict`. + + ## Usage + + `ansible_module` is an instance of `AnsibleModule`. + + ```python + sender = Sender() + try: + sender.ansible_module = ansible_module + rest_send = RestSend() + rest_send.sender = sender + except (TypeError, ValueError) as error: + handle_error(error) + # etc... + # See rest_send.py for RestSend() usage. + ``` + """ + + def __init__( + self, + ansible_module: Optional[AnsibleModule] = None, + verb: Optional[HttpVerbEnum] = None, + path: Optional[str] = None, + payload: Optional[dict[str, Any]] = None, + ) -> None: + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self._ansible_module: Optional[AnsibleModule] = ansible_module + self._connection: Optional[Connection] = None + + self._path: Optional[str] = path + self._payload: Optional[dict[str, Any]] = payload + self._response: Optional[dict[str, Any]] = None + self._verb: Optional[HttpVerbEnum] = verb + + msg = "ENTERED Sender(): " + self.log.debug(msg) + + def _get_caller_name(self) -> str: + """ + # Summary + + Get the name of the method that called the current method. + + ## Raises + + None + + ## Returns + + - `str`: The name of the calling method + """ + return inspect.stack()[2][3] + + def commit(self) -> None: + """ + # Summary + + Send the request to the controller + + ## Raises + + - `ValueError` if there is an error with the connection to the controller. + + ## Properties read + + - `verb`: HTTP verb e.g. GET, POST, PATCH, PUT, DELETE + - `path`: HTTP path e.g. /api/v1/some_endpoint + - `payload`: Optional HTTP payload + + ## Properties written + + - `response`: raw response from the controller + """ + method_name = "commit" + caller = self._get_caller_name() + + if self._connection is None: + self._connection = Connection(self.ansible_module._socket_path) # pylint: disable=protected-access + self._connection.set_params(self.ansible_module.params) + + msg = f"{self.class_name}.{method_name}: " + msg += f"caller: {caller}. " + msg += "Calling Connection().send_request: " + msg += f"verb {self.verb.value}, path {self.path}" + try: + if self.payload is None: + self.log.debug(msg) + response = self._connection.send_request(self.verb.value, self.path) + else: + msg += ", payload: " + msg += f"{json.dumps(self.payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + response = self._connection.send_request( + self.verb.value, + self.path, + json.dumps(self.payload), + ) + # Normalize response: if JSON parsing failed, DATA will be None + # and raw content will be in the "raw" key. Convert to consistent format. + response = self._normalize_response(response) + self.response = response + except AnsibleConnectionError as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"ConnectionError occurred: {error}" + self.log.error(msg) + raise ValueError(msg) from error + except Exception as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"Unexpected error occurred: {error}" + self.log.error(msg) + raise ValueError(msg) from error + + def _normalize_response(self, response: dict) -> dict: + """ + # Summary + + Normalize the HttpApi response to ensure consistent format. + + If the HttpApi plugin failed to parse the response as JSON, the + `DATA` key will be None and the raw response content will be in + the `raw` key. This method converts such responses to a consistent + format where `DATA` contains a dict with the raw content. + + ## Parameters + + - `response`: The response dict from the HttpApi plugin. + + ## Returns + + The normalized response dict. + """ + if response.get("DATA") is None and response.get("raw") is not None: + response["DATA"] = {"raw_response": response.get("raw")} + # If MESSAGE is just the HTTP reason phrase, enhance it + if response.get("MESSAGE") in ("OK", None): + response["MESSAGE"] = "Response could not be parsed as JSON" + return response + + @property + def ansible_module(self) -> AnsibleModule: + """ + # Summary + + The AnsibleModule instance to use for this sender. + + ## Raises + + - `ValueError` if ansible_module is not set. + """ + if self._ansible_module is None: + msg = f"{self.class_name}.ansible_module: " + msg += "ansible_module must be set before accessing ansible_module." + raise ValueError(msg) + return self._ansible_module + + @ansible_module.setter + def ansible_module(self, value: AnsibleModule): + self._ansible_module = value + + @property + def path(self) -> str: + """ + # Summary + + Endpoint path for the REST request. + + ## Raises + + - getter: `ValueError` if `path` is not set before accessing. + + ## Example + + ``/appcenter/cisco/ndfc/api/v1/...etc...`` + """ + if self._path is None: + msg = f"{self.class_name}.path: " + msg += "path must be set before accessing path." + raise ValueError(msg) + return self._path + + @path.setter + def path(self, value: str): + self._path = value + + @property + def payload(self) -> Optional[dict[str, Any]]: + """ + # Summary + + Return the payload to send to the controller + + ## Raises + - `TypeError` if value is not a `dict`. + """ + return self._payload + + @payload.setter + def payload(self, value: dict): + method_name = "payload" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + raise TypeError(msg) + self._payload = value + + @property + def response(self) -> dict: + """ + # Summary + + The response from the controller. + + - getter: Return a deepcopy of `response` + - setter: Set `response` + + ## Raises + + - getter: `ValueError` if response is not set. + - setter: `TypeError` if value is not a `dict`. + """ + if self._response is None: + msg = f"{self.class_name}.response: " + msg += "response must be set before accessing response." + raise ValueError(msg) + return copy.deepcopy(self._response) + + @response.setter + def response(self, value: dict): + method_name = "response" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + raise TypeError(msg) + self._response = value + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the REST request. + + ## Raises + + - getter: `ValueError` if verb is not set. + - setter: `TypeError` if value is not a `HttpVerbEnum`. + """ + if self._verb is None: + msg = f"{self.class_name}.verb: " + msg += "verb must be set before accessing verb." + raise ValueError(msg) + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum): + method_name = "verb" + if value not in HttpVerbEnum.values(): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be one of {HttpVerbEnum.values()}. " + msg += f"Got {value}." + raise TypeError(msg) + self._verb = value diff --git a/tests/sanity/requirements.txt b/tests/sanity/requirements.txt index 8ea87eb9..f19a09fb 100644 --- a/tests/sanity/requirements.txt +++ b/tests/sanity/requirements.txt @@ -1,4 +1,7 @@ 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 +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+ diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/module_utils/__init__.py b/tests/unit/module_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/module_utils/common_utils.py b/tests/unit/module_utils/common_utils.py new file mode 100644 index 00000000..bc64b0d6 --- /dev/null +++ b/tests/unit/module_utils/common_utils.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Common utilities used by unit tests. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +from contextlib import contextmanager + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.log import Log +from ansible_collections.cisco.nd.tests.unit.module_utils.fixtures.load_fixture import load_fixture +from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator +from ansible_collections.cisco.nd.tests.unit.module_utils.sender_file import Sender as SenderFile + +params = { + "state": "merged", + "config": {"switches": [{"ip_address": "172.22.150.105"}]}, + "check_mode": False, +} + + +# See the following for explanation of why fixtures are explicitely named +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# @pytest.fixture(name="controller_version") +# def controller_version_fixture(): +# """ +# return ControllerVersion instance. +# """ +# return ControllerVersion() +@pytest.fixture(name="sender_file") +def sender_file_fixture(): + """ + return Send() imported from sender_file.py + """ + + def responses(): + yield {} + + instance = SenderFile() + instance.gen = ResponseGenerator(responses()) + return instance + + +@pytest.fixture(name="log") +def log_fixture(): + """ + return Log instance + """ + return Log() + + +@contextmanager +def does_not_raise(): + """ + A context manager that does not raise an exception. + """ + yield + + +def responses_sender_file(key: str) -> dict[str, str]: + """ + Return data in responses_SenderFile.json + """ + response_file = "responses_SenderFile" + response = load_fixture(response_file).get(key) + print(f"responses_sender_file: {key} : {response}") + return response diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json b/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json new file mode 100644 index 00000000..88aa460a --- /dev/null +++ b/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json @@ -0,0 +1,244 @@ +{ + "TEST_NOTES": [ + "Fixture data for test_rest_send.py tests", + "Provides mock controller responses for REST operations" + ], + "test_rest_send_00100a": { + "TEST_NOTES": ["Successful GET request response"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/endpoint", + "MESSAGE": "OK", + "DATA": { + "status": "success", + "result": "test data" + } + }, + "test_rest_send_00110a": { + "TEST_NOTES": ["Successful POST request response"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/create", + "MESSAGE": "Created", + "DATA": { + "id": "12345", + "status": "created" + } + }, + "test_rest_send_00120a": { + "TEST_NOTES": ["Successful PUT request response"], + "RETURN_CODE": 200, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/test/update/12345", + "MESSAGE": "Updated", + "DATA": { + "id": "12345", + "status": "updated" + } + }, + "test_rest_send_00130a": { + "TEST_NOTES": ["Successful DELETE request response"], + "RETURN_CODE": 200, + "METHOD": "DELETE", + "REQUEST_PATH": "/api/v1/test/delete/12345", + "MESSAGE": "Deleted", + "DATA": { + "id": "12345", + "status": "deleted" + } + }, + "test_rest_send_00200a": { + "TEST_NOTES": ["Failed request - 404 Not Found"], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/notfound", + "MESSAGE": "Not Found", + "DATA": { + "error": "Resource not found" + } + }, + "test_rest_send_00210a": { + "TEST_NOTES": ["Failed request - 400 Bad Request"], + "RETURN_CODE": 400, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/badrequest", + "MESSAGE": "Bad Request", + "DATA": { + "error": "Invalid payload" + } + }, + "test_rest_send_00220a": { + "TEST_NOTES": ["Failed request - 500 Internal Server Error"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/servererror", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Server error occurred" + } + }, + "test_rest_send_00300a": { + "TEST_NOTES": ["First response in retry sequence - failure"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Temporary error" + } + }, + "test_rest_send_00300b": { + "TEST_NOTES": ["Second response in retry sequence - success"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "OK", + "DATA": { + "status": "success", + "result": "data after retry" + } + }, + "test_rest_send_00400a": { + "TEST_NOTES": ["GET request successful response"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/endpoint", + "MESSAGE": "OK", + "DATA": { + "status": "success" + } + }, + "test_rest_send_00410a": { + "TEST_NOTES": ["POST request successful response"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/create", + "MESSAGE": "OK", + "DATA": { + "status": "created" + } + }, + "test_rest_send_00420a": { + "TEST_NOTES": ["PUT request successful response"], + "RETURN_CODE": 200, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/test/update/12345", + "MESSAGE": "OK", + "DATA": { + "status": "updated" + } + }, + "test_rest_send_00430a": { + "TEST_NOTES": ["DELETE request successful response"], + "RETURN_CODE": 200, + "METHOD": "DELETE", + "REQUEST_PATH": "/api/v1/test/delete/12345", + "MESSAGE": "OK", + "DATA": { + "status": "deleted" + } + }, + "test_rest_send_00500a": { + "TEST_NOTES": ["404 Not Found response"], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/notfound", + "MESSAGE": "Not Found", + "DATA": { + "error": "Resource not found" + } + }, + "test_rest_send_00510a": { + "TEST_NOTES": ["400 Bad Request response"], + "RETURN_CODE": 400, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/badrequest", + "MESSAGE": "Bad Request", + "DATA": { + "error": "Invalid request data" + } + }, + "test_rest_send_00520a": { + "TEST_NOTES": ["500 Internal Server Error response"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/servererror", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Server error occurred" + } + }, + "test_rest_send_00600a": { + "TEST_NOTES": ["First response - 500 error for retry test"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Temporary error" + } + }, + "test_rest_send_00600b": { + "TEST_NOTES": ["Second response - success after retry"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "OK", + "DATA": { + "status": "success" + } + }, + "test_rest_send_00600c": { + "TEST_NOTES": ["Multiple sequential requests - third"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/multi/create", + "MESSAGE": "Created", + "DATA": { + "id": 3, + "name": "third", + "status": "created" + } + }, + "test_rest_send_00700a": { + "TEST_NOTES": ["First sequential GET"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/multi/1", + "MESSAGE": "OK", + "DATA": { + "id": 1 + } + }, + "test_rest_send_00700b": { + "TEST_NOTES": ["Second sequential GET"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/multi/2", + "MESSAGE": "OK", + "DATA": { + "id": 2 + } + }, + "test_rest_send_00700c": { + "TEST_NOTES": ["Third sequential POST"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/multi/create", + "MESSAGE": "OK", + "DATA": { + "id": 3, + "status": "created" + } + }, + "test_rest_send_00900a": { + "TEST_NOTES": ["Response for deepcopy test"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/endpoint", + "MESSAGE": "OK", + "DATA": { + "status": "success" + } + } +} diff --git a/tests/unit/module_utils/fixtures/load_fixture.py b/tests/unit/module_utils/fixtures/load_fixture.py new file mode 100644 index 00000000..ec5a84d3 --- /dev/null +++ b/tests/unit/module_utils/fixtures/load_fixture.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Function to load test inputs from JSON files. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import json +import os +import sys + +fixture_path = os.path.join(os.path.dirname(__file__), "fixture_data") + + +def load_fixture(filename): + """ + load test inputs from json files + """ + path = os.path.join(fixture_path, f"{filename}.json") + + try: + with open(path, encoding="utf-8") as file_handle: + data = file_handle.read() + except IOError as exception: + msg = f"Exception opening test input file {filename}.json : " + msg += f"Exception detail: {exception}" + print(msg) + sys.exit(1) + + try: + fixture = json.loads(data) + except json.JSONDecodeError as exception: + msg = "Exception reading JSON contents in " + msg += f"test input file {filename}.json : " + msg += f"Exception detail: {exception}" + print(msg) + sys.exit(1) + + return fixture diff --git a/tests/unit/module_utils/mock_ansible_module.py b/tests/unit/module_utils/mock_ansible_module.py new file mode 100644 index 00000000..d58397df --- /dev/null +++ b/tests/unit/module_utils/mock_ansible_module.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Mock AnsibleModule for unit testing. + +This module provides a mock implementation of Ansible's AnsibleModule +to avoid circular import issues between sender_file.py and common_utils.py. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + + +# Define base exception class +class AnsibleFailJson(Exception): + """ + Exception raised by MockAnsibleModule.fail_json() + """ + + +# Try to import AnsibleFailJson from ansible.netcommon if available +# This allows compatibility with tests that expect the netcommon version +try: + from ansible_collections.ansible.netcommon.tests.unit.modules.utils import AnsibleFailJson as _NetcommonFailJson + + # Use the netcommon version if available + AnsibleFailJson = _NetcommonFailJson # type: ignore[misc] +except ImportError: + # Use the local version defined above + pass + + +class MockAnsibleModule: + """ + # Summary + + Mock the AnsibleModule class for unit testing. + + ## Attributes + + - check_mode: Whether the module is running in check mode + - params: Module parameters dictionary + - argument_spec: Module argument specification + - supports_check_mode: Whether the module supports check mode + + ## Methods + + - fail_json: Raises AnsibleFailJson exception with the provided message + """ + + check_mode = False + + params = {"config": {"switches": [{"ip_address": "172.22.150.105"}]}} + argument_spec = { + "config": {"required": True, "type": "dict"}, + "state": {"default": "merged", "choices": ["merged", "deleted", "query"]}, + "check_mode": False, + } + supports_check_mode = True + + @staticmethod + def fail_json(msg, **kwargs) -> AnsibleFailJson: + """ + # Summary + + Mock the fail_json method. + + ## Parameters + + - msg: Error message + - kwargs: Additional keyword arguments (ignored) + + ## Raises + + - AnsibleFailJson: Always raised with the provided message + """ + raise AnsibleFailJson(msg) + + def public_method_for_pylint(self): + """ + # Summary + + Add one public method to appease pylint. + + ## Raises + + None + """ diff --git a/tests/unit/module_utils/response_generator.py b/tests/unit/module_utils/response_generator.py new file mode 100644 index 00000000..e96aad70 --- /dev/null +++ b/tests/unit/module_utils/response_generator.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Response generator for unit tests. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + + +class ResponseGenerator: + """ + Given a coroutine which yields dictionaries, return the yielded items + with each call to the next property + + For usage in the context of dcnm_image_policy unit tests, see: + test: test_image_policy_create_bulk_00037 + file: tests/unit/modules/dcnm/dcnm_image_policy/test_image_policy_create_bulk.py + + Simplified usage example below. + + def responses(): + yield {"key1": "value1"} + yield {"key2": "value2"} + + gen = ResponseGenerator(responses()) + + print(gen.next) # {"key1": "value1"} + print(gen.next) # {"key2": "value2"} + """ + + def __init__(self, gen): + self.gen = gen + + @property + def next(self): + """ + Return the next item in the generator + """ + return next(self.gen) + + @property + def implements(self): + """ + ### Summary + Used by Sender() classes to verify Sender().gen is a + response generator which implements the response_generator + interfacee. + """ + return "response_generator" + + def public_method_for_pylint(self): + """ + Add one public method to appease pylint + """ diff --git a/tests/unit/module_utils/sender_file.py b/tests/unit/module_utils/sender_file.py new file mode 100644 index 00000000..7060e8c0 --- /dev/null +++ b/tests/unit/module_utils/sender_file.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Sender module conforming to SenderProtocol for file-based mock responses. + +See plugins/module_utils/protocol_sender.py for the protocol definition. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import inspect +import logging +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.mock_ansible_module import MockAnsibleModule +from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator + + +class Sender: + """ + # Summary + + An injected dependency for `RestSend` which implements the + `sender` interface. Responses are read from JSON files. + + ## Raises + + - `ValueError` if: + - `gen` is not set. + - `TypeError` if: + - `gen` is not an instance of ResponseGenerator() + + ## Usage + + - `gen` is an instance of `ResponseGenerator()` which yields simulated responses. + In the example below, `responses()` is a generator that yields dictionaries. + However, in practice, it would yield responses read from JSON files. + - `responses()` is a coroutine that yields controller responses. + In the example below, it yields to dictionaries. However, in + practice, it would yield responses read from JSON files. + + ```python + def responses(): + yield {"key1": "value1"} + yield {"key2": "value2"} + + sender = Sender() + sender.gen = ResponseGenerator(responses()) + + try: + rest_send = RestSend() + rest_send.sender = sender + except (TypeError, ValueError) as error: + handle_error(error) + # etc... + # See rest_send.py for RestSend() usage. + ``` + """ + + def __init__(self) -> None: + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self._ansible_module: Optional[MockAnsibleModule] = None + self._gen: Optional[ResponseGenerator] = None + self._path: Optional[str] = None + self._payload: Optional[dict[str, Any]] = None + self._response: Optional[dict[str, Any]] = None + self._verb: Optional[HttpVerbEnum] = None + + self._raise_method: Optional[str] = None + self._raise_exception: Optional[BaseException] = None + + msg = "ENTERED Sender(): " + self.log.debug(msg) + + def commit(self) -> None: + """ + # Summary + + - Simulate a commit to a controller (does nothing). + - Allows to simulate exceptions for testing error handling in RestSend by setting the `raise_exception` and `raise_method` properties. + + ## Raises + + - `ValueError` if `gen` is not set. + - `self.raise_exception` if set and + `self.raise_method` == "commit" + """ + method_name = "commit" + + if self.raise_method == method_name and self.raise_exception is not None: + msg = f"{self.class_name}.{method_name}: " + msg += f"Simulated {type(self.raise_exception).__name__}." + raise self.raise_exception + + caller = inspect.stack()[1][3] + msg = f"{self.class_name}.{method_name}: " + msg += f"caller {caller}" + self.log.debug(msg) + + @property + def ansible_module(self) -> Optional[MockAnsibleModule]: + """ + # Summary + + Mock ansible_module + """ + return self._ansible_module + + @ansible_module.setter + def ansible_module(self, value: Optional[MockAnsibleModule]): + self._ansible_module = value + + @property + def gen(self) -> ResponseGenerator: + """ + # Summary + + The `ResponseGenerator()` instance which yields simulated responses. + + ## Raises + + - `ValueError` if `gen` is not set. + - `TypeError` if value is not a class implementing the `response_generator` interface. + """ + if self._gen is None: + msg = f"{self.class_name}.gen: gen must be set to a class implementing the response_generator interface." + raise ValueError(msg) + return self._gen + + @gen.setter + def gen(self, value: ResponseGenerator) -> None: + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "Expected a class implementing the " + msg += "response_generator interface. " + msg += f"Got {value}." + try: + implements = value.implements + except AttributeError as error: + raise TypeError(msg) from error + if implements != "response_generator": + raise TypeError(msg) + self._gen = value + + @property + def path(self) -> str: + """ + # Summary + + Dummy path. + + ## Raises + + - getter: `ValueError` if `path` is not set before accessing. + + ## Example + + ``/appcenter/cisco/ndfc/api/v1/...etc...`` + """ + if self._path is None: + msg = f"{self.class_name}.path: path must be set before accessing." + raise ValueError(msg) + return self._path + + @path.setter + def path(self, value: str): + self._path = value + + @property + def payload(self) -> Optional[dict[str, Any]]: + """ + # Summary + + Dummy payload. + + ## Raises + + None + """ + return self._payload + + @payload.setter + def payload(self, value: Optional[dict[str, Any]]): + self._payload = value + + @property + def raise_exception(self) -> Optional[BaseException]: + """ + # Summary + + The exception to raise when calling the method specified in `raise_method`. + + ## Raises + + - `TypeError` if value is not a subclass of `BaseException`. + + ## Usage + + ```python + instance = Sender() + instance.raise_method = "commit" + instance.raise_exception = ValueError + instance.commit() # will raise a simulated ValueError + ``` + + ## Notes + + - No error checking is done on the input to this property. + """ + if self._raise_exception is not None and not issubclass(type(self._raise_exception), BaseException): + msg = f"{self.class_name}.raise_exception: " + msg += "raise_exception must be a subclass of BaseException. " + msg += f"Got {self._raise_exception} of type {type(self._raise_exception).__name__}." + raise TypeError(msg) + return self._raise_exception + + @raise_exception.setter + def raise_exception(self, value: Optional[BaseException]): + if value is not None and not issubclass(type(value), BaseException): + msg = f"{self.class_name}.raise_exception: " + msg += "raise_exception must be a subclass of BaseException. " + msg += f"Got {value} of type {type(value).__name__}." + raise TypeError(msg) + self._raise_exception = value + + @property + def raise_method(self) -> Optional[str]: + """ + ## Summary + + The method in which to raise exception `raise_exception`. + + ## Raises + + None + + ## Usage + + See `raise_exception`. + """ + return self._raise_method + + @raise_method.setter + def raise_method(self, value: Optional[str]) -> None: + self._raise_method = value + + @property + def response(self) -> dict[str, Any]: + """ + # Summary + + The simulated response from a file. + + Returns a deepcopy to prevent mutation of the response object. + + ## Raises + + None + """ + return copy.deepcopy(self.gen.next) + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Dummy Verb. + + ## Raises + + - `ValueError` if verb is not set. + """ + if self._verb is None: + msg = f"{self.class_name}.verb: verb must be set before accessing." + raise ValueError(msg) + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + self._verb = value diff --git a/tests/unit/module_utils/test_response_handler_nd.py b/tests/unit/module_utils/test_response_handler_nd.py new file mode 100644 index 00000000..f3250dbc --- /dev/null +++ b/tests/unit/module_utils/test_response_handler_nd.py @@ -0,0 +1,1496 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for response_handler_nd.py + +Tests the ResponseHandler class for handling ND controller responses. +""" + +# pylint: disable=unused-import +# pylint: disable=redefined-outer-name +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=invalid-name +# pylint: disable=line-too-long +# pylint: disable=too-many-lines + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_strategies.nd_v1_strategy import NdV1Strategy +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + +# ============================================================================= +# Test: ResponseHandler initialization +# ============================================================================= + + +def test_response_handler_nd_00010(): + """ + # Summary + + Verify ResponseHandler initialization with default values. + + ## Test + + - Instance can be created + - _response defaults to None + - _result defaults to None + - _verb defaults to None + - _strategy defaults to NdV1Strategy instance + + ## Classes and Methods + + - ResponseHandler.__init__() + """ + with does_not_raise(): + instance = ResponseHandler() + assert instance._response is None + assert instance._result is None + assert instance._verb is None + assert isinstance(instance._strategy, NdV1Strategy) + + +def test_response_handler_nd_00015(): + """ + # Summary + + Verify validation_strategy getter returns the default NdV1Strategy and + setter accepts a valid strategy. + + ## Test + + - Default strategy is NdV1Strategy + - Setting a new NdV1Strategy instance is accepted + - Getter returns the newly set strategy + + ## Classes and Methods + + - ResponseHandler.validation_strategy (getter/setter) + """ + instance = ResponseHandler() + assert isinstance(instance.validation_strategy, NdV1Strategy) + + new_strategy = NdV1Strategy() + with does_not_raise(): + instance.validation_strategy = new_strategy + assert instance.validation_strategy is new_strategy + + +def test_response_handler_nd_00020(): + """ + # Summary + + Verify validation_strategy setter raises TypeError for invalid type. + + ## Test + + - Setting validation_strategy to a non-strategy object raises TypeError + + ## Classes and Methods + + - ResponseHandler.validation_strategy (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.validation_strategy:.*Expected ResponseValidationStrategy" + with pytest.raises(TypeError, match=match): + instance.validation_strategy = "not a strategy" # type: ignore[assignment] + + +# ============================================================================= +# Test: ResponseHandler.response property +# ============================================================================= + + +def test_response_handler_nd_00100(): + """ + # Summary + + Verify response getter raises ValueError when not set. + + ## Test + + - Accessing response before setting raises ValueError + + ## Classes and Methods + + - ResponseHandler.response (getter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.response + + +def test_response_handler_nd_00110(): + """ + # Summary + + Verify response setter/getter with valid dict. + + ## Test + + - response can be set with a valid dict containing RETURN_CODE and MESSAGE + - response getter returns the set value + + ## Classes and Methods + + - ResponseHandler.response (setter/getter) + """ + instance = ResponseHandler() + response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"key": "value"}} + with does_not_raise(): + instance.response = response + result = instance.response + assert result["RETURN_CODE"] == 200 + assert result["MESSAGE"] == "OK" + + +def test_response_handler_nd_00120(): + """ + # Summary + + Verify response setter raises TypeError for non-dict. + + ## Test + + - Setting response to a non-dict raises TypeError + + ## Classes and Methods + + - ResponseHandler.response (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.response = "not a dict" # type: ignore[assignment] + + +def test_response_handler_nd_00130(): + """ + # Summary + + Verify response setter raises ValueError when MESSAGE key is missing. + + ## Test + + - Setting response without MESSAGE raises ValueError + + ## Classes and Methods + + - ResponseHandler.response (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response:.*must have a MESSAGE key" + with pytest.raises(ValueError, match=match): + instance.response = {"RETURN_CODE": 200} + + +def test_response_handler_nd_00140(): + """ + # Summary + + Verify response setter raises ValueError when RETURN_CODE key is missing. + + ## Test + + - Setting response without RETURN_CODE raises ValueError + + ## Classes and Methods + + - ResponseHandler.response (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response:.*must have a RETURN_CODE key" + with pytest.raises(ValueError, match=match): + instance.response = {"MESSAGE": "OK"} + + +# ============================================================================= +# Test: ResponseHandler.verb property +# ============================================================================= + + +def test_response_handler_nd_00200(): + """ + # Summary + + Verify verb getter raises ValueError when not set. + + ## Test + + - Accessing verb before setting raises ValueError + + ## Classes and Methods + + - ResponseHandler.verb (getter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.verb is not set" + with pytest.raises(ValueError, match=match): + result = instance.verb + + +def test_response_handler_nd_00210(): + """ + # Summary + + Verify verb setter/getter with valid HttpVerbEnum. + + ## Test + + - verb can be set and retrieved with HttpVerbEnum values + + ## Classes and Methods + + - ResponseHandler.verb (setter/getter) + """ + instance = ResponseHandler() + with does_not_raise(): + instance.verb = HttpVerbEnum.GET + result = instance.verb + assert result == HttpVerbEnum.GET + + with does_not_raise(): + instance.verb = HttpVerbEnum.POST + result = instance.verb + assert result == HttpVerbEnum.POST + + +# ============================================================================= +# Test: ResponseHandler.result property +# ============================================================================= + + +def test_response_handler_nd_00300(): + """ + # Summary + + Verify result getter raises ValueError when commit() not called. + + ## Test + + - Accessing result before calling commit() raises ValueError + + ## Classes and Methods + + - ResponseHandler.result (getter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.result:.*must be set before accessing.*commit" + with pytest.raises(ValueError, match=match): + result = instance.result + + +def test_response_handler_nd_00310(): + """ + # Summary + + Verify result setter raises TypeError for non-dict. + + ## Test + + - Setting result to non-dict raises TypeError + + ## Classes and Methods + + - ResponseHandler.result (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.result.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.result = "not a dict" # type: ignore[assignment] + + +# ============================================================================= +# Test: ResponseHandler.commit() validation +# ============================================================================= + + +def test_response_handler_nd_00400(): + """ + # Summary + + Verify commit() raises ValueError when response is not set. + + ## Test + + - Calling commit() without setting response raises ValueError + + ## Classes and Methods + + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.verb = HttpVerbEnum.GET + match = r"ResponseHandler\.response:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_response_handler_nd_00410(): + """ + # Summary + + Verify commit() raises ValueError when verb is not set. + + ## Test + + - Calling commit() without setting verb raises ValueError + + ## Classes and Methods + + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + match = r"ResponseHandler\.verb is not set" + with pytest.raises(ValueError, match=match): + instance.commit() + + +# ============================================================================= +# Test: ResponseHandler._handle_get_response() +# ============================================================================= + + +def test_response_handler_nd_00500(): + """ + # Summary + + Verify GET response with 200 OK. + + ## Test + + - GET with RETURN_CODE 200 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00510(): + """ + # Summary + + Verify GET response with 201 Created. + + ## Test + + - GET with RETURN_CODE 201 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 201, "MESSAGE": "Created"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00520(): + """ + # Summary + + Verify GET response with 202 Accepted. + + ## Test + + - GET with RETURN_CODE 202 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 202, "MESSAGE": "Accepted"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00530(): + """ + # Summary + + Verify GET response with 204 No Content. + + ## Test + + - GET with RETURN_CODE 204 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 204, "MESSAGE": "No Content"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00535(): + """ + # Summary + + Verify GET response with 207 Multi-Status. + + ## Test + + - GET with RETURN_CODE 207 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 207, "MESSAGE": "Multi-Status"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00540(): + """ + # Summary + + Verify GET response with 404 Not Found. + + ## Test + + - GET with RETURN_CODE 404 sets found=False, success=True + - 404 is treated as "not found but not an error" + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 404, "MESSAGE": "Not Found"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is True + + +def test_response_handler_nd_00550(): + """ + # Summary + + Verify GET response with 500 Internal Server Error. + + ## Test + + - GET with RETURN_CODE 500 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 500, "MESSAGE": "Internal Server Error"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00560(): + """ + # Summary + + Verify GET response with 400 Bad Request. + + ## Test + + - GET with RETURN_CODE 400 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 400, "MESSAGE": "Bad Request"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00570(): + """ + # Summary + + Verify GET response with 401 Unauthorized. + + ## Test + + - GET with RETURN_CODE 401 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 401, "MESSAGE": "Unauthorized"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00575(): + """ + # Summary + + Verify GET response with 405 Method Not Allowed. + + ## Test + + - GET with RETURN_CODE 405 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 405, "MESSAGE": "Method Not Allowed"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00580(): + """ + # Summary + + Verify GET response with 409 Conflict. + + ## Test + + - GET with RETURN_CODE 409 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 409, "MESSAGE": "Conflict"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +# ============================================================================= +# Test: ResponseHandler._handle_post_put_delete_response() +# ============================================================================= + + +def test_response_handler_nd_00600(): + """ + # Summary + + Verify POST response with 200 OK (no errors). + + ## Test + + - POST with RETURN_CODE 200 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"status": "created"}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00610(): + """ + # Summary + + Verify PUT response with 200 OK. + + ## Test + + - PUT with RETURN_CODE 200 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"status": "updated"}} + instance.verb = HttpVerbEnum.PUT + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00620(): + """ + # Summary + + Verify DELETE response with 200 OK. + + ## Test + + - DELETE with RETURN_CODE 200 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.DELETE + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00630(): + """ + # Summary + + Verify POST response with 201 Created. + + ## Test + + - POST with RETURN_CODE 201 sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 201, "MESSAGE": "Created", "DATA": {}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00640(): + """ + # Summary + + Verify POST response with 202 Accepted. + + ## Test + + - POST with RETURN_CODE 202 sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 202, "MESSAGE": "Accepted", "DATA": {}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00650(): + """ + # Summary + + Verify DELETE response with 204 No Content. + + ## Test + + - DELETE with RETURN_CODE 204 sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 204, "MESSAGE": "No Content", "DATA": {}} + instance.verb = HttpVerbEnum.DELETE + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00655(): + """ + # Summary + + Verify POST response with 207 Multi-Status. + + ## Test + + - POST with RETURN_CODE 207 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 207, "MESSAGE": "Multi-Status", "DATA": {"status": "partial"}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00660(): + """ + # Summary + + Verify POST response with explicit ERROR key. + + ## Test + + - Response containing ERROR key sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "ERROR": "Something went wrong", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00670(): + """ + # Summary + + Verify POST response with DATA.error (ND error format). + + ## Test + + - Response with DATA containing error key sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"error": "ND error occurred"}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00680(): + """ + # Summary + + Verify POST response with 500 error status code. + + ## Test + + - POST with RETURN_CODE 500 sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00690(): + """ + # Summary + + Verify POST response with 400 Bad Request. + + ## Test + + - POST with RETURN_CODE 400 and no explicit errors sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00695(): + """ + # Summary + + Verify POST response with 405 Method Not Allowed. + + ## Test + + - POST with RETURN_CODE 405 sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 405, + "MESSAGE": "Method Not Allowed", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00705(): + """ + # Summary + + Verify POST response with 409 Conflict. + + ## Test + + - POST with RETURN_CODE 409 sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 409, + "MESSAGE": "Conflict", + "DATA": {"reason": "resource exists"}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +# ============================================================================= +# Test: ResponseHandler.error_message property +# ============================================================================= + + +def test_response_handler_nd_00700(): + """ + # Summary + + Verify error_message returns None on successful response. + + ## Test + + - error_message is None when result indicates success + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is None + + +def test_response_handler_nd_00710(): + """ + # Summary + + Verify error_message returns None when commit() not called. + + ## Test + + - error_message is None when _result is None (commit not called) + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + assert instance.error_message is None + + +def test_response_handler_nd_00720(): + """ + # Summary + + Verify error_message for raw_response format (non-JSON response). + + ## Test + + - When DATA contains raw_response key, error_message indicates non-JSON response + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": {"raw_response": "Error"}, + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "could not be parsed as JSON" in instance.error_message + + +def test_response_handler_nd_00730(): + """ + # Summary + + Verify error_message for code/message format. + + ## Test + + - When DATA contains code and message keys, error_message includes both + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"code": "INVALID_INPUT", "message": "Field X is required"}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "INVALID_INPUT" in instance.error_message + assert "Field X is required" in instance.error_message + + +def test_response_handler_nd_00740(): + """ + # Summary + + Verify error_message for messages array format. + + ## Test + + - When DATA contains messages array with code/severity/message, + error_message includes all three fields + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": { + "messages": [ + { + "code": "ERR_001", + "severity": "ERROR", + "message": "Validation failed", + } + ] + }, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "ERR_001" in instance.error_message + assert "ERROR" in instance.error_message + assert "Validation failed" in instance.error_message + + +def test_response_handler_nd_00750(): + """ + # Summary + + Verify error_message for errors array format. + + ## Test + + - When DATA contains errors array, error_message includes the first error + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"errors": ["First error message", "Second error message"]}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "First error message" in instance.error_message + + +def test_response_handler_nd_00760(): + """ + # Summary + + Verify error_message when DATA is None (connection failure). + + ## Test + + - When DATA is None, error_message includes REQUEST_PATH and MESSAGE + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Connection refused", + "REQUEST_PATH": "/api/v1/some/endpoint", + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "Connection failed" in instance.error_message + assert "/api/v1/some/endpoint" in instance.error_message + assert "Connection refused" in instance.error_message + + +def test_response_handler_nd_00770(): + """ + # Summary + + Verify error_message with non-dict DATA. + + ## Test + + - When DATA is a non-dict value, error_message includes stringified DATA + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": "Unexpected string error", + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "Unexpected string error" in instance.error_message + + +def test_response_handler_nd_00780(): + """ + # Summary + + Verify error_message fallback for unknown dict format. + + ## Test + + - When DATA is a dict with no recognized error format, + error_message falls back to including RETURN_CODE + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 503, + "MESSAGE": "Service Unavailable", + "DATA": {"some_unknown_key": "some_value"}, + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "503" in instance.error_message + + +def test_response_handler_nd_00790(): + """ + # Summary + + Verify error_message returns None when result success is True. + + ## Test + + - Even with error-like DATA, if result is success, error_message is None + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"errors": ["Some error"]}, + } + instance.verb = HttpVerbEnum.GET + instance.commit() + # For GET with 200, success is True regardless of DATA content + assert instance.result["success"] is True + assert instance.error_message is None + + +def test_response_handler_nd_00800(): + """ + # Summary + + Verify error_message for connection failure with no REQUEST_PATH. + + ## Test + + - When DATA is None and REQUEST_PATH is missing, error_message uses "unknown" + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Connection timed out", + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "unknown" in instance.error_message + assert "Connection timed out" in instance.error_message + + +def test_response_handler_nd_00810(): + """ + # Summary + + Verify error_message for messages array with empty array. + + ## Test + + - When DATA contains an empty messages array, messages format is skipped + and fallback is used + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"messages": []}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "400" in instance.error_message + + +def test_response_handler_nd_00820(): + """ + # Summary + + Verify error_message for errors array with empty array. + + ## Test + + - When DATA contains an empty errors array, errors format is skipped + and fallback is used + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"errors": []}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "400" in instance.error_message + + +# ============================================================================= +# Test: ResponseHandler._handle_response() routing +# ============================================================================= + + +def test_response_handler_nd_00900(): + """ + # Summary + + Verify _handle_response routes GET to _handle_get_response. + + ## Test + + - GET verb produces result with "found" key (not "changed") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_get_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert "found" in instance.result + assert "changed" not in instance.result + + +def test_response_handler_nd_00910(): + """ + # Summary + + Verify _handle_response routes POST to _handle_post_put_delete_response. + + ## Test + + - POST verb produces result with "changed" key (not "found") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_post_put_delete_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.POST + instance.commit() + assert "changed" in instance.result + assert "found" not in instance.result + + +def test_response_handler_nd_00920(): + """ + # Summary + + Verify _handle_response routes PUT to _handle_post_put_delete_response. + + ## Test + + - PUT verb produces result with "changed" key (not "found") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_post_put_delete_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.PUT + instance.commit() + assert "changed" in instance.result + assert "found" not in instance.result + + +def test_response_handler_nd_00930(): + """ + # Summary + + Verify _handle_response routes DELETE to _handle_post_put_delete_response. + + ## Test + + - DELETE verb produces result with "changed" key (not "found") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_post_put_delete_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.DELETE + instance.commit() + assert "changed" in instance.result + assert "found" not in instance.result + + +# ============================================================================= +# Test: ResponseHandler with code/message + messages array in same response +# ============================================================================= + + +def test_response_handler_nd_01000(): + """ + # Summary + + Verify error_message prefers code/message format over messages array. + + ## Test + + - When DATA contains both code/message and messages array, + code/message takes priority + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": { + "code": "PRIMARY_ERROR", + "message": "Primary error message", + "messages": [ + { + "code": "SECONDARY", + "severity": "WARNING", + "message": "Secondary message", + } + ], + }, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "PRIMARY_ERROR" in instance.error_message + assert "Primary error message" in instance.error_message + + +# ============================================================================= +# Test: ResponseHandler commit() can be called multiple times +# ============================================================================= + + +def test_response_handler_nd_01100(): + """ + # Summary + + Verify commit() can be called with different responses. + + ## Test + + - First commit with 200 success + - Second commit with 500 error + - result reflects the most recent commit + + ## Classes and Methods + + - ResponseHandler.commit() + """ + instance = ResponseHandler() + + # First commit - success + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.result["success"] is True + assert instance.result["found"] is True + + # Second commit - failure + instance.response = {"RETURN_CODE": 500, "MESSAGE": "Internal Server Error"} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.result["success"] is False + assert instance.result["found"] is False diff --git a/tests/unit/module_utils/test_rest_send.py b/tests/unit/module_utils/test_rest_send.py new file mode 100644 index 00000000..ab1c499c --- /dev/null +++ b/tests/unit/module_utils/test_rest_send.py @@ -0,0 +1,1445 @@ +# -*- 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.response) == 1 + assert len(instance.result) == 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.response) == 1 + assert len(instance.result) == 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.response) == 2 + assert len(instance.result) == 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.response) == 3 + assert len(instance.result) == 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.response + result_copy = instance.result + 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() diff --git a/tests/unit/module_utils/test_sender_nd.py b/tests/unit/module_utils/test_sender_nd.py new file mode 100644 index 00000000..5edd102f --- /dev/null +++ b/tests/unit/module_utils/test_sender_nd.py @@ -0,0 +1,906 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for sender_nd.py + +Tests the Sender class for sending REST requests via the Ansible HttpApi plugin. +""" + +# pylint: disable=unused-import +# pylint: disable=redefined-outer-name +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=invalid-name +# pylint: disable=line-too-long +# pylint: disable=too-many-lines + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +from unittest.mock import MagicMock, patch + +import pytest +from ansible.module_utils.connection import ConnectionError as AnsibleConnectionError +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + +# ============================================================================= +# Test: Sender initialization +# ============================================================================= + + +def test_sender_nd_00010(): + """ + # Summary + + Verify Sender initialization with default values. + + ## Test + + - Instance can be created with no arguments + - All attributes default to None + + ## Classes and Methods + + - Sender.__init__() + """ + with does_not_raise(): + instance = Sender() + assert instance._ansible_module is None + assert instance._connection is None + assert instance._path is None + assert instance._payload is None + assert instance._response is None + assert instance._verb is None + + +def test_sender_nd_00020(): + """ + # Summary + + Verify Sender initialization with all parameters. + + ## Test + + - Instance can be created with all optional constructor arguments + + ## Classes and Methods + + - Sender.__init__() + """ + mock_module = MagicMock() + with does_not_raise(): + instance = Sender( + ansible_module=mock_module, + verb=HttpVerbEnum.GET, + path="/api/v1/test", + payload={"key": "value"}, + ) + assert instance._ansible_module is mock_module + assert instance._path == "/api/v1/test" + assert instance._payload == {"key": "value"} + assert instance._verb == HttpVerbEnum.GET + + +# ============================================================================= +# Test: Sender.ansible_module property +# ============================================================================= + + +def test_sender_nd_00100(): + """ + # Summary + + Verify ansible_module getter raises ValueError when not set. + + ## Test + + - Accessing ansible_module before setting raises ValueError + + ## Classes and Methods + + - Sender.ansible_module (getter) + """ + instance = Sender() + match = r"Sender\.ansible_module:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.ansible_module + + +def test_sender_nd_00110(): + """ + # Summary + + Verify ansible_module setter/getter. + + ## Test + + - ansible_module can be set and retrieved + + ## Classes and Methods + + - Sender.ansible_module (setter/getter) + """ + instance = Sender() + mock_module = MagicMock() + with does_not_raise(): + instance.ansible_module = mock_module + result = instance.ansible_module + assert result is mock_module + + +# ============================================================================= +# Test: Sender.path property +# ============================================================================= + + +def test_sender_nd_00200(): + """ + # Summary + + Verify path getter raises ValueError when not set. + + ## Test + + - Accessing path before setting raises ValueError + + ## Classes and Methods + + - Sender.path (getter) + """ + instance = Sender() + match = r"Sender\.path:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.path + + +def test_sender_nd_00210(): + """ + # Summary + + Verify path setter/getter. + + ## Test + + - path can be set and retrieved + + ## Classes and Methods + + - Sender.path (setter/getter) + """ + instance = Sender() + with does_not_raise(): + instance.path = "/api/v1/test/endpoint" + result = instance.path + assert result == "/api/v1/test/endpoint" + + +# ============================================================================= +# Test: Sender.verb property +# ============================================================================= + + +def test_sender_nd_00300(): + """ + # Summary + + Verify verb getter raises ValueError when not set. + + ## Test + + - Accessing verb before setting raises ValueError + + ## Classes and Methods + + - Sender.verb (getter) + """ + instance = Sender() + match = r"Sender\.verb:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.verb + + +def test_sender_nd_00310(): + """ + # Summary + + Verify verb setter/getter with valid HttpVerbEnum. + + ## Test + + - verb can be set and retrieved with all HttpVerbEnum values + + ## Classes and Methods + + - Sender.verb (setter/getter) + """ + instance = Sender() + for verb in (HttpVerbEnum.GET, HttpVerbEnum.POST, HttpVerbEnum.PUT, HttpVerbEnum.DELETE): + with does_not_raise(): + instance.verb = verb + result = instance.verb + assert result == verb + + +def test_sender_nd_00320(): + """ + # Summary + + Verify verb setter raises TypeError for invalid value. + + ## Test + + - Setting verb to a value not in HttpVerbEnum.values() raises TypeError + + ## Classes and Methods + + - Sender.verb (setter) + """ + instance = Sender() + match = r"Sender\.verb:.*must be one of" + with pytest.raises(TypeError, match=match): + instance.verb = "INVALID" # type: ignore[assignment] + + +# ============================================================================= +# Test: Sender.payload property +# ============================================================================= + + +def test_sender_nd_00400(): + """ + # Summary + + Verify payload defaults to None. + + ## Test + + - payload is None by default + + ## Classes and Methods + + - Sender.payload (getter) + """ + instance = Sender() + with does_not_raise(): + result = instance.payload + assert result is None + + +def test_sender_nd_00410(): + """ + # Summary + + Verify payload setter/getter with valid dict. + + ## Test + + - payload can be set and retrieved + + ## Classes and Methods + + - Sender.payload (setter/getter) + """ + instance = Sender() + with does_not_raise(): + instance.payload = {"name": "test", "config": {"key": "value"}} + result = instance.payload + assert result == {"name": "test", "config": {"key": "value"}} + + +def test_sender_nd_00420(): + """ + # Summary + + Verify payload setter raises TypeError for non-dict. + + ## Test + + - Setting payload to a non-dict raises TypeError + + ## Classes and Methods + + - Sender.payload (setter) + """ + instance = Sender() + match = r"Sender\.payload:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.payload = "not a dict" # type: ignore[assignment] + + +def test_sender_nd_00430(): + """ + # Summary + + Verify payload setter raises TypeError for list. + + ## Test + + - Setting payload to a list raises TypeError + + ## Classes and Methods + + - Sender.payload (setter) + """ + instance = Sender() + match = r"Sender\.payload:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.payload = [1, 2, 3] # type: ignore[assignment] + + +# ============================================================================= +# Test: Sender.response property +# ============================================================================= + + +def test_sender_nd_00500(): + """ + # Summary + + Verify response getter raises ValueError when not set. + + ## Test + + - Accessing response before commit raises ValueError + + ## Classes and Methods + + - Sender.response (getter) + """ + instance = Sender() + match = r"Sender\.response:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.response + + +def test_sender_nd_00510(): + """ + # Summary + + Verify response getter returns deepcopy. + + ## Test + + - response getter returns a deepcopy of the internal response + + ## Classes and Methods + + - Sender.response (getter) + """ + instance = Sender() + instance._response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"key": "value"}} + result = instance.response + # Modify the copy + result["MODIFIED"] = True + # Verify original is unchanged + assert "MODIFIED" not in instance._response + + +def test_sender_nd_00520(): + """ + # Summary + + Verify response setter raises TypeError for non-dict. + + ## Test + + - Setting response to a non-dict raises TypeError + + ## Classes and Methods + + - Sender.response (setter) + """ + instance = Sender() + match = r"Sender\.response:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.response = "not a dict" # type: ignore[assignment] + + +def test_sender_nd_00530(): + """ + # Summary + + Verify response setter accepts valid dict. + + ## Test + + - response can be set with a valid dict + + ## Classes and Methods + + - Sender.response (setter/getter) + """ + instance = Sender() + response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + with does_not_raise(): + instance.response = response + result = instance.response + assert result["RETURN_CODE"] == 200 + assert result["MESSAGE"] == "OK" + + +# ============================================================================= +# Test: Sender._normalize_response() +# ============================================================================= + + +def test_sender_nd_00600(): + """ + # Summary + + Verify _normalize_response with normal JSON response. + + ## Test + + - Response with valid DATA passes through unchanged + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "success"}, + } + result = instance._normalize_response(response) + assert result["DATA"] == {"status": "success"} + assert result["MESSAGE"] == "OK" + + +def test_sender_nd_00610(): + """ + # Summary + + Verify _normalize_response when DATA is None and raw is present. + + ## Test + + - When DATA is None and raw is present, DATA is populated with raw_response + - MESSAGE is set to indicate JSON parsing failure + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": None, + "raw": "Not JSON", + } + result = instance._normalize_response(response) + assert result["DATA"] == {"raw_response": "Not JSON"} + assert result["MESSAGE"] == "Response could not be parsed as JSON" + + +def test_sender_nd_00620(): + """ + # Summary + + Verify _normalize_response when DATA is None, raw is present, + and MESSAGE is None. + + ## Test + + - When MESSAGE is None, it is set to indicate JSON parsing failure + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 200, + "MESSAGE": None, + "DATA": None, + "raw": "raw content", + } + result = instance._normalize_response(response) + assert result["DATA"] == {"raw_response": "raw content"} + assert result["MESSAGE"] == "Response could not be parsed as JSON" + + +def test_sender_nd_00630(): + """ + # Summary + + Verify _normalize_response when DATA is None and raw is also None. + + ## Test + + - When both DATA and raw are None, response is not modified + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": None, + } + result = instance._normalize_response(response) + assert result["DATA"] is None + assert result["MESSAGE"] == "Internal Server Error" + + +def test_sender_nd_00640(): + """ + # Summary + + Verify _normalize_response preserves non-OK MESSAGE when raw is present. + + ## Test + + - When DATA is None and raw is present, MESSAGE is only overwritten + if it was "OK" or None + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": None, + "raw": "raw error content", + } + result = instance._normalize_response(response) + assert result["DATA"] == {"raw_response": "raw error content"} + # MESSAGE is NOT overwritten because it's not "OK" or None + assert result["MESSAGE"] == "Internal Server Error" + + +# ============================================================================= +# Test: Sender.commit() with mocked Connection +# ============================================================================= + + +def test_sender_nd_00700(): + """ + # Summary + + Verify commit() with successful GET request (no payload). + + ## Test + + - commit() calls Connection.send_request with verb and path + - response is populated from the Connection response + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "success"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + assert instance.response["DATA"]["status"] == "success" + mock_connection.send_request.assert_called_once_with("GET", "/api/v1/test") + + +def test_sender_nd_00710(): + """ + # Summary + + Verify commit() with POST request including payload. + + ## Test + + - commit() calls Connection.send_request with verb, path, and JSON payload + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "created"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test/create" + instance.verb = HttpVerbEnum.POST + instance.payload = {"name": "test"} + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + assert instance.response["DATA"]["status"] == "created" + mock_connection.send_request.assert_called_once_with( + "POST", + "/api/v1/test/create", + '{"name": "test"}', + ) + + +def test_sender_nd_00720(): + """ + # Summary + + Verify commit() raises ValueError on connection failure. + + ## Test + + - When Connection.send_request raises AnsibleConnectionError, + commit() re-raises as ValueError + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.side_effect = AnsibleConnectionError("Connection refused") + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + match = r"Sender\.commit:.*ConnectionError occurred" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_sender_nd_00730(): + """ + # Summary + + Verify commit() raises ValueError on unexpected exception. + + ## Test + + - When Connection.send_request raises an unexpected Exception, + commit() wraps it in ValueError + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.side_effect = RuntimeError("Unexpected error") + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + match = r"Sender\.commit:.*Unexpected error occurred" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_sender_nd_00740(): + """ + # Summary + + Verify commit() reuses existing connection on second call. + + ## Test + + - First commit creates a new Connection + - Second commit reuses the existing connection + - Connection constructor is called only once + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {}, + } + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ) as mock_conn_class: + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + instance.commit() + instance.commit() + + # Connection constructor should only be called once + mock_conn_class.assert_called_once() + # send_request should be called twice + assert mock_connection.send_request.call_count == 2 + + +def test_sender_nd_00750(): + """ + # Summary + + Verify commit() normalizes non-JSON responses. + + ## Test + + - When Connection returns DATA=None with raw content, + commit() normalizes the response + + ## Classes and Methods + + - Sender.commit() + - Sender._normalize_response() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": None, + "raw": "Non-JSON response", + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["DATA"] == {"raw_response": "Non-JSON response"} + assert instance.response["MESSAGE"] == "Response could not be parsed as JSON" + + +def test_sender_nd_00760(): + """ + # Summary + + Verify commit() with PUT request including payload. + + ## Test + + - commit() calls Connection.send_request with PUT verb, path, and JSON payload + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "updated"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test/update/12345" + instance.verb = HttpVerbEnum.PUT + instance.payload = {"status": "active"} + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + mock_connection.send_request.assert_called_once_with( + "PUT", + "/api/v1/test/update/12345", + '{"status": "active"}', + ) + + +def test_sender_nd_00770(): + """ + # Summary + + Verify commit() with DELETE request (no payload). + + ## Test + + - commit() calls Connection.send_request with DELETE verb and path + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "deleted"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test/delete/12345" + instance.verb = HttpVerbEnum.DELETE + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + mock_connection.send_request.assert_called_once_with("DELETE", "/api/v1/test/delete/12345") From 565a069da9b3107d1c1e2893c7e95ca6ac73cf91 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Mon, 9 Mar 2026 23:15:59 +0530 Subject: [PATCH 02/39] nd42_smart_endpoints: apply smart endpoints branch changes --- plugins/module_utils/endpoints/__init__.py | 0 plugins/module_utils/endpoints/base_path.py | 71 ++ plugins/module_utils/endpoints/mixins.py | 89 ++ .../module_utils/endpoints/query_params.py | 325 +++++++ plugins/module_utils/endpoints/v1/__init__.py | 0 .../endpoints/v1/base_paths_infra.py | 139 +++ .../endpoints/v1/base_paths_manage.py | 115 +++ .../module_utils/endpoints/v1/infra_aaa.py | 219 +++++ .../endpoints/v1/infra_clusterhealth.py | 241 +++++ .../module_utils/endpoints/v1/infra_login.py | 91 ++ tests/config.yml | 3 + .../module_utils/endpoints/test_base_path.py | 444 +++++++++ .../endpoints/test_base_paths_infra.py | 390 ++++++++ .../endpoints/test_base_paths_manage.py | 309 +++++++ .../endpoints/test_endpoint_mixins.py | 560 ++++++++++++ .../test_endpoints_api_v1_infra_aaa.py | 437 +++++++++ ...st_endpoints_api_v1_infra_clusterhealth.py | 479 ++++++++++ .../test_endpoints_api_v1_infra_login.py | 68 ++ .../endpoints/test_query_params.py | 845 ++++++++++++++++++ 19 files changed, 4825 insertions(+) create mode 100644 plugins/module_utils/endpoints/__init__.py create mode 100644 plugins/module_utils/endpoints/base_path.py create mode 100644 plugins/module_utils/endpoints/mixins.py create mode 100644 plugins/module_utils/endpoints/query_params.py create mode 100644 plugins/module_utils/endpoints/v1/__init__.py create mode 100644 plugins/module_utils/endpoints/v1/base_paths_infra.py create mode 100644 plugins/module_utils/endpoints/v1/base_paths_manage.py create mode 100644 plugins/module_utils/endpoints/v1/infra_aaa.py create mode 100644 plugins/module_utils/endpoints/v1/infra_clusterhealth.py create mode 100644 plugins/module_utils/endpoints/v1/infra_login.py create mode 100644 tests/config.yml create mode 100644 tests/unit/module_utils/endpoints/test_base_path.py create mode 100644 tests/unit/module_utils/endpoints/test_base_paths_infra.py create mode 100644 tests/unit/module_utils/endpoints/test_base_paths_manage.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoint_mixins.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_clusterhealth.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_login.py create mode 100644 tests/unit/module_utils/endpoints/test_query_params.py diff --git a/plugins/module_utils/endpoints/__init__.py b/plugins/module_utils/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/endpoints/base_path.py b/plugins/module_utils/endpoints/base_path.py new file mode 100644 index 00000000..9359a03b --- /dev/null +++ b/plugins/module_utils/endpoints/base_path.py @@ -0,0 +1,71 @@ +# 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 API endpoints. + +This module provides a single location to manage all API base paths using +a type-safe Enum pattern, allowing easy modification when API paths change +and preventing invalid path usage through compile-time checking. + +## Usage + +```python +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ApiPath + +# Recommended: Use enum for type safety +base_url = ApiPath.INFRA.value + +# Type-safe function parameters +def build_endpoint(api_base: ApiPath, path: str) -> str: + return f"{api_base.value}/{path}" + +# IDE autocomplete works +endpoint = build_endpoint(ApiPath.INFRA, "aaa/localUsers") +``` + +## Backward Compatibility + +Legacy constants (ND_INFRA_API, etc.) are maintained for backward compatibility +but are deprecated. New code should use the ApiPath enum. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Final + + +class ApiPath(str, Enum): + """ + # Summary + + Base API path constants for ND REST API. + + ## Description + + String-based enum providing type-safe API base paths shared across + all endpoint versions (v1, v2, etc.). + + ## Raises + + None + """ + + ANALYZE = "/api/v1/analyze" + INFRA = "/api/v1/infra" + MANAGE = "/api/v1/manage" + ONEMANAGE = "/api/v1/onemanage" + + +ND_ANALYZE_API: Final = ApiPath.ANALYZE.value +ND_INFRA_API: Final = ApiPath.INFRA.value +ND_MANAGE_API: Final = ApiPath.MANAGE.value +ND_ONEMANAGE_API: Final = ApiPath.ONEMANAGE.value diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py new file mode 100644 index 00000000..78c0994a --- /dev/null +++ b/plugins/module_utils/endpoints/mixins.py @@ -0,0 +1,89 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Reusable mixin classes for endpoint models. + +This module provides mixin classes that can be composed to add common +fields to endpoint models without duplication. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import BooleanStringEnum +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + Field, +) + + +class ClusterNameMixin(BaseModel): + """Mixin for endpoints that require cluster_name parameter.""" + + cluster_name: Optional[str] = Field(default=None, min_length=1, description="Cluster name") + + +class FabricNameMixin(BaseModel): + """Mixin for endpoints that require fabric_name parameter.""" + + fabric_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="Fabric name") + + +class ForceShowRunMixin(BaseModel): + """Mixin for endpoints that require force_show_run parameter.""" + + force_show_run: BooleanStringEnum = Field(default=BooleanStringEnum.FALSE, description="Force show running config") + + +class HealthCategoryMixin(BaseModel): + """Mixin for endpoints that require health_category parameter.""" + + health_category: Optional[str] = Field(default=None, min_length=1, description="Health category") + + +class InclAllMsdSwitchesMixin(BaseModel): + """Mixin for endpoints that require incl_all_msd_switches parameter.""" + + incl_all_msd_switches: BooleanStringEnum = Field(default=BooleanStringEnum.FALSE, description="Include all MSD switches") + + +class LinkUuidMixin(BaseModel): + """Mixin for endpoints that require link_uuid parameter.""" + + link_uuid: Optional[str] = Field(default=None, min_length=1, description="Link UUID") + + +class LoginIdMixin(BaseModel): + """Mixin for endpoints that require login_id parameter.""" + + login_id: Optional[str] = Field(default=None, min_length=1, description="Login ID") + + +class NetworkNameMixin(BaseModel): + """Mixin for endpoints that require network_name parameter.""" + + network_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="Network name") + + +class NodeNameMixin(BaseModel): + """Mixin for endpoints that require node_name parameter.""" + + node_name: Optional[str] = Field(default=None, min_length=1, description="Node name") + + +class SwitchSerialNumberMixin(BaseModel): + """Mixin for endpoints that require switch_sn parameter.""" + + switch_sn: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") + + +class VrfNameMixin(BaseModel): + """Mixin for endpoints that require vrf_name parameter.""" + + vrf_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="VRF name") diff --git a/plugins/module_utils/endpoints/query_params.py b/plugins/module_utils/endpoints/query_params.py new file mode 100644 index 00000000..355d4bbb --- /dev/null +++ b/plugins/module_utils/endpoints/query_params.py @@ -0,0 +1,325 @@ +# 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 + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Optional, Union +from urllib.parse import quote + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + Field, + field_validator, +) + + +class QueryParams(ABC): + """ + # Summary + + Abstract Base Class for Query Parameters + + ## Description + + Base class for all query parameter types. Subclasses implement + `to_query_string()` to convert their parameters to URL query string format. + + ## 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 + """ + + @abstractmethod + 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" + ``` + """ + + 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 + """ + return len(self.to_query_string()) == 0 + + +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 all(v is None for v in self.model_dump().values()) + + +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[Union[EndpointQueryParams, LuceneQueryParams]] = [] + + def add(self, params: Union[EndpointQueryParams, LuceneQueryParams]) -> "CompositeQueryParams": + """ + # Summary + + Add a query parameter group to the composite. + + ## Parameters + + - params: EndpointQueryParams or LuceneQueryParams instance + + ## Returns + + - Self (for method chaining) + + ## Example + + ```python + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + ``` + """ + self._param_groups.append(params) + return self + + def to_query_string(self, url_encode: bool = True) -> str: + """ + # Summary + + Build complete query string from all parameter groups. + + ## Parameters + + - url_encode: If True, URL-encode parameter values (default: True) + + ## Returns + + - Complete query string (without leading '?') + - Empty string if no parameters are set + """ + parts = [] + for param_group in self._param_groups: + if not param_group.is_empty(): + # LuceneQueryParams supports url_encode parameter, EndpointQueryParams doesn't + if isinstance(param_group, LuceneQueryParams): + parts.append(param_group.to_query_string(url_encode=url_encode)) + else: + parts.append(param_group.to_query_string()) + return "&".join(parts) + + def is_empty(self) -> bool: + """Check if any parameters are set across all groups.""" + return all(param_group.is_empty() for param_group in self._param_groups) + + def clear(self) -> None: + """Remove all parameter groups.""" + self._param_groups.clear() diff --git a/plugins/module_utils/endpoints/v1/__init__.py b/plugins/module_utils/endpoints/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/endpoints/v1/base_paths_infra.py b/plugins/module_utils/endpoints/v1/base_paths_infra.py new file mode 100644 index 00000000..3b7db8eb --- /dev/null +++ b/plugins/module_utils/endpoints/v1/base_paths_infra.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Centralized base paths for ND Infra API endpoints. + +/api/v1/infra + +This module provides a single location to manage all API Infra base paths, +allowing easy modification when API paths change. All endpoint classes +should use these path builders for consistency. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Final + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ( + ApiPath, +) + + +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.base_paths_infra import BasePath + + # Get a complete base path for ND Infra + path = BasePath.nd_infra("aaa", "localUsers") + # Returns: /api/v1/infra/aaa/localUsers + + # Leverage a convenience method + path = BasePath.nd_infra_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 = ApiPath.INFRA.value + + @classmethod + def nd_infra(cls, *segments: str) -> str: + """ + # Summary + + Build ND infra API path. + + ## Parameters + + - segments: Path segments to append after /api/v1/infra + + ## Returns + + - Complete ND infra API path + + ## Example + + ```python + path = BasePath.nd_infra("aaa", "localUsers") + # Returns: /api/v1/infra/aaa/localUsers + ``` + """ + if not segments: + return cls.API + return f"{cls.API}/{'/'.join(segments)}" + + @classmethod + def nd_infra_aaa(cls, *segments: str) -> str: + """ + # Summary + + Build ND infra AAA API path. + + ## Parameters + + - segments: Path segments to append after aaa (e.g., "localUsers") + + ## Returns + + - Complete ND infra AAA path + + ## Example + + ```python + path = BasePath.nd_infra_aaa("localUsers") + # Returns: /api/v1/infra/aaa/localUsers + ``` + """ + return cls.nd_infra("aaa", *segments) + + @classmethod + def nd_infra_clusterhealth(cls, *segments: str) -> str: + """ + # Summary + + Build ND infra clusterhealth API path. + + ## Parameters + + - segments: Path segments to append after clusterhealth (e.g., "config", "status") + + ## Returns + + - Complete ND infra clusterhealth path + + ## Example + + ```python + path = BasePath.nd_infra_clusterhealth("config") + # Returns: /api/v1/infra/clusterhealth/config + ``` + """ + return cls.nd_infra("clusterhealth", *segments) diff --git a/plugins/module_utils/endpoints/v1/base_paths_manage.py b/plugins/module_utils/endpoints/v1/base_paths_manage.py new file mode 100644 index 00000000..bb34fc59 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/base_paths_manage.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Centralized base paths for ND Manage API endpoints. + +/api/v1/manage + +This module provides a single location to manage all API Manage base paths, +allowing easy modification when API paths change. All endpoint classes +should use these path builders for consistency. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Final + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ( + ApiPath, +) + + +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.base_paths_manage import BasePath + + # Get a complete base path for ND Manage + path = BasePath.nd_manage("inventory", "switches") + # Returns: /api/v1/manage/inventory/switches + + # Leverage a convenience method + path = BasePath.nd_manage_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 = ApiPath.MANAGE.value + + @classmethod + def nd_manage(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.nd_manage("inventory", "switches") + # Returns: /api/v1/manage/inventory/switches + ``` + """ + if not segments: + return cls.API + return f"{cls.API}/{'/'.join(segments)}" + + @classmethod + def nd_manage_inventory(cls, *segments: str) -> str: + """ + # Summary + + Build ND manage inventory API path. + + ## Parameters + + - segments: Path segments to append after inventory (e.g., "switches") + + ## Returns + + - Complete ND manage inventory path + + ## Example + + ```python + path = BasePath.nd_manage_inventory("switches") + # Returns: /api/v1/manage/inventory/switches + ``` + """ + return cls.nd_manage("inventory", *segments) diff --git a/plugins/module_utils/endpoints/v1/infra_aaa.py b/plugins/module_utils/endpoints/v1/infra_aaa.py new file mode 100644 index 00000000..c47fab32 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra_aaa.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Infra AAA endpoint models. + +This module contains endpoint definitions for AAA-related operations +in the ND Infra API. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + LoginIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_infra import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) + +# Common config for basic validation +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class _EpInfraAaaLocalUsersBase(LoginIdMixin, BaseModel): + """ + Base class for ND Infra AAA Local Users endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/infra/aaa/localUsers endpoint. + """ + + model_config = COMMON_CONFIG + + # Version metadata + 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 + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Returns + + - Complete endpoint path string, optionally including login_id + """ + if self.login_id is not None: + return BasePath.nd_infra_aaa("localUsers", self.login_id) + return BasePath.nd_infra_aaa("localUsers") + + +class EpInfraAaaLocalUsersGet(_EpInfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users GET Endpoint + + ## Description + + Endpoint to retrieve local users from the ND Infra AAA service. + Optionally retrieve a specific local user by login_id. + + ## Path + + - /api/v1/infra/aaa/localUsers + - /api/v1/infra/aaa/localUsers/{login_id} + + ## Verb + + - GET + + ## Usage + + ```python + # Get all local users + request = EpApiV1InfraAaaLocalUsersGet() + path = request.path + verb = request.verb + + # Get specific local user + request = EpApiV1InfraAaaLocalUsersGet() + request.login_id = "admin" + path = request.path + verb = request.verb + ``` + """ + + class_name: Literal["EpInfraAaaLocalUsersGet"] = Field(default="EpInfraAaaLocalUsersGet", description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class EpInfraAaaLocalUsersPost(_EpInfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users POST Endpoint + + ## Description + + Endpoint to create a local user in the ND Infra AAA service. + + ## Path + + - /api/v1/infra/aaa/localUsers + + ## Verb + + - POST + + ## Usage + + ```python + request = EpApiV1InfraAaaLocalUsersPost() + path = request.path + verb = request.verb + ``` + """ + + class_name: Literal["EpInfraAaaLocalUsersPost"] = Field(default="EpInfraAaaLocalUsersPost", description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +class EpInfraAaaLocalUsersPut(_EpInfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users PUT Endpoint + + ## Description + + Endpoint to update a local user in the ND Infra AAA service. + + ## Path + + - /api/v1/infra/aaa/localUsers/{login_id} + + ## Verb + + - PUT + + ## Usage + + ```python + request = EpApiV1InfraAaaLocalUsersPut() + request.login_id = "admin" + path = request.path + verb = request.verb + ``` + """ + + class_name: Literal["EpInfraAaaLocalUsersPut"] = Field(default="EpInfraAaaLocalUsersPut", description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.PUT + + +class EpInfraAaaLocalUsersDelete(_EpInfraAaaLocalUsersBase): + """ + # Summary + + ND Infra AAA Local Users DELETE Endpoint + + ## Description + + Endpoint to delete a local user from the ND Infra AAA service. + + ## Path + + - /api/v1/infra/aaa/localUsers/{login_id} + + ## Verb + + - DELETE + + ## Usage + + ```python + request = EpApiV1InfraAaaLocalUsersDelete() + request.login_id = "admin" + path = request.path + verb = request.verb + ``` + """ + + class_name: Literal["EpInfraAaaLocalUsersDelete"] = Field(default="EpInfraAaaLocalUsersDelete", 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_clusterhealth.py b/plugins/module_utils/endpoints/v1/infra_clusterhealth.py new file mode 100644 index 00000000..858502da --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra_clusterhealth.py @@ -0,0 +1,241 @@ +# 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 + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_infra import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) + +# Common config for basic validation +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class ClusterHealthConfigEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for cluster health config endpoint. + + ## Parameters + + - cluster_name: Cluster name (optional) + + ## Usage + + ```python + params = ClusterHealthConfigEndpointParams(cluster_name="my-cluster") + query_string = params.to_query_string() + # Returns: "clusterName=my-cluster" + ``` + """ + + cluster_name: Optional[str] = Field(default=None, min_length=1, description="Cluster name") + + +class ClusterHealthStatusEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for cluster health status endpoint. + + ## Parameters + + - cluster_name: Cluster name (optional) + - health_category: Health category (optional) + - node_name: Node name (optional) + + ## 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" + ``` + """ + + cluster_name: Optional[str] = Field(default=None, min_length=1, description="Cluster name") + health_category: Optional[str] = Field(default=None, min_length=1, description="Health category") + node_name: Optional[str] = Field(default=None, min_length=1, description="Node name") + + +class EpInfraClusterhealthConfigGet(BaseModel): + """ + # 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 + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + 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") + + class_name: Literal["EpInfraClusterhealthConfigGet"] = Field(default="EpInfraClusterhealthConfigGet", 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.nd_infra_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 + + +class EpInfraClusterhealthStatusGet(BaseModel): + """ + # 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 + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + 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") + + class_name: Literal["EpInfraClusterhealthStatusGet"] = Field(default="EpInfraClusterhealthStatusGet", 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.nd_infra_clusterhealth("status") + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET diff --git a/plugins/module_utils/endpoints/v1/infra_login.py b/plugins/module_utils/endpoints/v1/infra_login.py new file mode 100644 index 00000000..9363d6eb --- /dev/null +++ b/plugins/module_utils/endpoints/v1/infra_login.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Infra Login endpoint model. + +This module contains the endpoint definition for the ND Infra login operation. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_infra import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) + + +class EpInfraLoginPost(BaseModel): + """ + # 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 + """ + + model_config = ConfigDict(validate_assignment=True) + + 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") + class_name: Literal["EpInfraLoginPost"] = Field(default="EpInfraLoginPost", 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.nd_infra("login") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST diff --git a/tests/config.yml b/tests/config.yml new file mode 100644 index 00000000..7cf024ab --- /dev/null +++ b/tests/config.yml @@ -0,0 +1,3 @@ +modules: + # Limit Python version to control node Python versions + python_requires: controller diff --git a/tests/unit/module_utils/endpoints/test_base_path.py b/tests/unit/module_utils/endpoints/test_base_path.py new file mode 100644 index 00000000..3929d964 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_base_path.py @@ -0,0 +1,444 @@ +# 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_path.py + +Tests the root API path constants defined in base_path.py +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ( + LOGIN, + ND_ANALYZE_API, + ND_INFRA_API, + ND_MANAGE_API, + ND_MSO_API, + ND_ONEMANAGE_API, + NDFC_API, + ApiPath, +) +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: Root API Path Constants +# ============================================================================= + + +def test_base_path_00010(): + """ + # Summary + + Verify ND_ANALYZE_API constant value + + ## Test + + - ND_ANALYZE_API equals "/api/v1/analyze" + + ## Classes and Methods + + - base_path.ND_ANALYZE_API + """ + with does_not_raise(): + result = ND_ANALYZE_API + assert result == "/api/v1/analyze" + + +def test_base_path_00020(): + """ + # Summary + + Verify ND_INFRA_API constant value + + ## Test + + - ND_INFRA_API equals "/api/v1/infra" + + ## Classes and Methods + + - base_path.ND_INFRA_API + """ + with does_not_raise(): + result = ND_INFRA_API + assert result == "/api/v1/infra" + + +def test_base_path_00030(): + """ + # Summary + + Verify ND_MANAGE_API constant value + + ## Test + + - ND_MANAGE_API equals "/api/v1/manage" + + ## Classes and Methods + + - base_path.ND_MANAGE_API + """ + with does_not_raise(): + result = ND_MANAGE_API + assert result == "/api/v1/manage" + + +def test_base_path_00040(): + """ + # Summary + + Verify ND_ONEMANAGE_API constant value + + ## Test + + - ND_ONEMANAGE_API equals "/api/v1/onemanage" + + ## Classes and Methods + + - base_path.ND_ONEMANAGE_API + """ + with does_not_raise(): + result = ND_ONEMANAGE_API + assert result == "/api/v1/onemanage" + + +def test_base_path_00050(): + """ + # Summary + + Verify ND_MSO_API constant value + + ## Test + + - ND_MSO_API equals "/mso" + + ## Classes and Methods + + - base_path.ND_MSO_API + """ + with does_not_raise(): + result = ND_MSO_API + assert result == "/mso" + + +def test_base_path_00060(): + """ + # Summary + + Verify NDFC_API constant value + + ## Test + + - NDFC_API equals "/appcenter/cisco/ndfc/api" + + ## Classes and Methods + + - base_path.NDFC_API + """ + with does_not_raise(): + result = NDFC_API + assert result == "/appcenter/cisco/ndfc/api" + + +def test_base_path_00070(): + """ + # Summary + + Verify LOGIN constant value + + ## Test + + - LOGIN equals "/login" + + ## Classes and Methods + + - base_path.LOGIN + """ + with does_not_raise(): + result = LOGIN + assert result == "/login" + + +# ============================================================================= +# Test: Constant Immutability (Final types) +# ============================================================================= + + +def test_base_path_00100(): + """ + # Summary + + Verify constants are strings + + ## Test + + - All constants are string types + - This ensures they can be used in path building + + ## Classes and Methods + + - base_path.ND_ANALYZE_API + - base_path.ND_INFRA_API + - base_path.ND_MANAGE_API + - base_path.ND_ONEMANAGE_API + - base_path.ND_MSO_API + - base_path.NDFC_API + - base_path.LOGIN + """ + with does_not_raise(): + assert isinstance(ND_ANALYZE_API, str) + assert isinstance(ND_INFRA_API, str) + assert isinstance(ND_MANAGE_API, str) + assert isinstance(ND_ONEMANAGE_API, str) + assert isinstance(ND_MSO_API, str) + assert isinstance(NDFC_API, str) + assert isinstance(LOGIN, str) + + +def test_base_path_00110(): + """ + # Summary + + Verify all API paths start with forward slash + + ## Test + + - All API path constants start with "/" + - This ensures proper path concatenation + + ## Classes and Methods + + - base_path.ND_ANALYZE_API + - base_path.ND_INFRA_API + - base_path.ND_MANAGE_API + - base_path.ND_ONEMANAGE_API + - base_path.ND_MSO_API + - base_path.NDFC_API + - base_path.LOGIN + """ + with does_not_raise(): + assert ND_ANALYZE_API.startswith("/") + assert ND_INFRA_API.startswith("/") + assert ND_MANAGE_API.startswith("/") + assert ND_ONEMANAGE_API.startswith("/") + assert ND_MSO_API.startswith("/") + assert NDFC_API.startswith("/") + assert LOGIN.startswith("/") + + +def test_base_path_00120(): + """ + # Summary + + Verify no API paths end with trailing slash + + ## Test + + - No API path constants end with "/" + - This prevents double slashes when building paths + + ## Classes and Methods + + - base_path.ND_ANALYZE_API + - base_path.ND_INFRA_API + - base_path.ND_MANAGE_API + - base_path.ND_ONEMANAGE_API + - base_path.ND_MSO_API + - base_path.NDFC_API + - base_path.LOGIN + """ + with does_not_raise(): + assert not ND_ANALYZE_API.endswith("/") + assert not ND_INFRA_API.endswith("/") + assert not ND_MANAGE_API.endswith("/") + assert not ND_ONEMANAGE_API.endswith("/") + assert not ND_MSO_API.endswith("/") + assert not NDFC_API.endswith("/") + assert not LOGIN.endswith("/") + + +# ============================================================================= +# Test: ND API Path Structure +# ============================================================================= + + +def test_base_path_00200(): + """ + # Summary + + Verify ND API paths follow /api/v1/ pattern + + ## Test + + - ND_ANALYZE_API follows the pattern + - ND_INFRA_API follows the pattern + - ND_MANAGE_API follows the pattern + - ND_ONEMANAGE_API follows the pattern + + ## Classes and Methods + + - base_path.ND_ANALYZE_API + - base_path.ND_INFRA_API + - base_path.ND_MANAGE_API + - base_path.ND_ONEMANAGE_API + """ + with does_not_raise(): + assert ND_ANALYZE_API.startswith("/api/v1/") + assert ND_INFRA_API.startswith("/api/v1/") + assert ND_MANAGE_API.startswith("/api/v1/") + assert ND_ONEMANAGE_API.startswith("/api/v1/") + + +def test_base_path_00210(): + """ + # Summary + + Verify non-ND API paths have different structure + + ## Test + + - ND_MSO_API does not follow /api/v1/ pattern + - NDFC_API does not follow /api/v1/ pattern + - LOGIN does not follow /api/v1/ pattern + + ## Classes and Methods + + - base_path.ND_MSO_API + - base_path.NDFC_API + - base_path.LOGIN + """ + with does_not_raise(): + assert not ND_MSO_API.startswith("/api/v1/") + assert not NDFC_API.startswith("/api/v1/") + assert not LOGIN.startswith("/api/v1/") + + +# ============================================================================= +# Test: Path Uniqueness +# ============================================================================= + + +def test_base_path_00300(): + """ + # Summary + + Verify all API path constants are unique + + ## Test + + - Each constant has a different value + - No duplicate paths exist + + ## Classes and Methods + + - base_path.ND_ANALYZE_API + - base_path.ND_INFRA_API + - base_path.ND_MANAGE_API + - base_path.ND_ONEMANAGE_API + - base_path.ND_MSO_API + - base_path.NDFC_API + - base_path.LOGIN + """ + with does_not_raise(): + paths = [ + ND_ANALYZE_API, + ND_INFRA_API, + ND_MANAGE_API, + ND_ONEMANAGE_API, + ND_MSO_API, + NDFC_API, + LOGIN, + ] + # Convert to set and check length matches + assert len(paths) == len(set(paths)), "Duplicate paths found" + + +# ============================================================================= +# Test: ApiPath Enum +# ============================================================================= + + +def test_base_path_00400(): + """ + # Summary + + Verify ApiPath enum provides all expected members + + ## Test + + - All 7 API paths available as enum members + - Enum members have correct string values + - Enum is iterable + + ## Classes and Methods + + - base_path.ApiPath + """ + with does_not_raise(): + paths = list(ApiPath) + + assert len(paths) == 7 + assert ApiPath.ANALYZE in paths + assert ApiPath.INFRA in paths + assert ApiPath.MANAGE in paths + assert ApiPath.ONEMANAGE in paths + assert ApiPath.MSO in paths + assert ApiPath.NDFC in paths + assert ApiPath.LOGIN in paths + + +def test_base_path_00410(): + """ + # Summary + + Verify ApiPath enum values match backward compat constants + + ## Test + + - ApiPath.ANALYZE.value equals ND_ANALYZE_API + - ApiPath.INFRA.value equals ND_INFRA_API + - ApiPath.MANAGE.value equals ND_MANAGE_API + - All enum values match corresponding constants + + ## Classes and Methods + + - base_path.ApiPath + """ + with does_not_raise(): + assert ApiPath.ANALYZE.value == ND_ANALYZE_API + assert ApiPath.INFRA.value == ND_INFRA_API + assert ApiPath.MANAGE.value == ND_MANAGE_API + assert ApiPath.ONEMANAGE.value == ND_ONEMANAGE_API + assert ApiPath.MSO.value == ND_MSO_API + assert ApiPath.NDFC.value == NDFC_API + assert ApiPath.LOGIN.value == LOGIN + + +def test_base_path_00420(): + """ + # Summary + + Verify ApiPath enum members are strings + + ## Test + + - ApiPath enum extends str + - Enum members can be used directly in string operations + - String conversion works correctly + + ## Classes and Methods + + - base_path.ApiPath + """ + with does_not_raise(): + assert isinstance(ApiPath.INFRA, str) + assert isinstance(ApiPath.MANAGE, str) + assert ApiPath.INFRA == "/api/v1/infra" + assert ApiPath.MANAGE == "/api/v1/manage" diff --git a/tests/unit/module_utils/endpoints/test_base_paths_infra.py b/tests/unit/module_utils/endpoints/test_base_paths_infra.py new file mode 100644 index 00000000..d089cfe6 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_base_paths_infra.py @@ -0,0 +1,390 @@ +# 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.base_path import ( + ND_INFRA_API, + ApiPath, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_infra 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 ND_INFRA_API and ApiPath.INFRA + + ## Test + + - BasePath.API equals "/api/v1/infra" + - BasePath.API uses ApiPath.INFRA.value + - Backward compat constant still works + + ## Classes and Methods + + - BasePath.API + - ApiPath.INFRA + """ + with does_not_raise(): + result = BasePath.API + assert result == ND_INFRA_API + assert result == ApiPath.INFRA.value + assert result == "/api/v1/infra" + + +# ============================================================================= +# Test: nd_infra() method +# ============================================================================= + + +def test_base_paths_infra_00100(): + """ + # Summary + + Verify nd_infra() with no segments returns API root + + ## Test + + - nd_infra() returns "/api/v1/infra" + + ## Classes and Methods + + - BasePath.nd_infra() + """ + with does_not_raise(): + result = BasePath.nd_infra() + assert result == "/api/v1/infra" + + +def test_base_paths_infra_00110(): + """ + # Summary + + Verify nd_infra() with single segment + + ## Test + + - nd_infra("aaa") returns "/api/v1/infra/aaa" + + ## Classes and Methods + + - BasePath.nd_infra() + """ + with does_not_raise(): + result = BasePath.nd_infra("aaa") + assert result == "/api/v1/infra/aaa" + + +def test_base_paths_infra_00120(): + """ + # Summary + + Verify nd_infra() with multiple segments + + ## Test + + - nd_infra("aaa", "localUsers") returns "/api/v1/infra/aaa/localUsers" + + ## Classes and Methods + + - BasePath.nd_infra() + """ + with does_not_raise(): + result = BasePath.nd_infra("aaa", "localUsers") + assert result == "/api/v1/infra/aaa/localUsers" + + +def test_base_paths_infra_00130(): + """ + # Summary + + Verify nd_infra() with three segments + + ## Test + + - nd_infra("aaa", "localUsers", "user1") returns correct path + + ## Classes and Methods + + - BasePath.nd_infra() + """ + with does_not_raise(): + result = BasePath.nd_infra("aaa", "localUsers", "user1") + assert result == "/api/v1/infra/aaa/localUsers/user1" + + +# ============================================================================= +# Test: nd_infra_aaa() method +# ============================================================================= + + +def test_base_paths_infra_00200(): + """ + # Summary + + Verify nd_infra_aaa() with no segments + + ## Test + + - nd_infra_aaa() returns "/api/v1/infra/aaa" + + ## Classes and Methods + + - BasePath.nd_infra_aaa() + """ + with does_not_raise(): + result = BasePath.nd_infra_aaa() + assert result == "/api/v1/infra/aaa" + + +def test_base_paths_infra_00210(): + """ + # Summary + + Verify nd_infra_aaa() with single segment + + ## Test + + - nd_infra_aaa("localUsers") returns "/api/v1/infra/aaa/localUsers" + + ## Classes and Methods + + - BasePath.nd_infra_aaa() + """ + with does_not_raise(): + result = BasePath.nd_infra_aaa("localUsers") + assert result == "/api/v1/infra/aaa/localUsers" + + +def test_base_paths_infra_00220(): + """ + # Summary + + Verify nd_infra_aaa() with multiple segments + + ## Test + + - nd_infra_aaa("localUsers", "user1") returns correct path + + ## Classes and Methods + + - BasePath.nd_infra_aaa() + """ + with does_not_raise(): + result = BasePath.nd_infra_aaa("localUsers", "user1") + assert result == "/api/v1/infra/aaa/localUsers/user1" + + +# ============================================================================= +# Test: nd_infra_clusterhealth() method +# ============================================================================= + + +def test_base_paths_infra_00300(): + """ + # Summary + + Verify nd_infra_clusterhealth() with no segments + + ## Test + + - nd_infra_clusterhealth() returns "/api/v1/infra/clusterhealth" + + ## Classes and Methods + + - BasePath.nd_infra_clusterhealth() + """ + with does_not_raise(): + result = BasePath.nd_infra_clusterhealth() + assert result == "/api/v1/infra/clusterhealth" + + +def test_base_paths_infra_00310(): + """ + # Summary + + Verify nd_infra_clusterhealth() with "config" segment + + ## Test + + - nd_infra_clusterhealth("config") returns "/api/v1/infra/clusterhealth/config" + + ## Classes and Methods + + - BasePath.nd_infra_clusterhealth() + """ + with does_not_raise(): + result = BasePath.nd_infra_clusterhealth("config") + assert result == "/api/v1/infra/clusterhealth/config" + + +def test_base_paths_infra_00320(): + """ + # Summary + + Verify nd_infra_clusterhealth() with "status" segment + + ## Test + + - nd_infra_clusterhealth("status") returns "/api/v1/infra/clusterhealth/status" + + ## Classes and Methods + + - BasePath.nd_infra_clusterhealth() + """ + with does_not_raise(): + result = BasePath.nd_infra_clusterhealth("status") + assert result == "/api/v1/infra/clusterhealth/status" + + +def test_base_paths_infra_00330(): + """ + # Summary + + Verify nd_infra_clusterhealth() with multiple segments + + ## Test + + - nd_infra_clusterhealth("config", "cluster1") returns correct path + + ## Classes and Methods + + - BasePath.nd_infra_clusterhealth() + """ + with does_not_raise(): + result = BasePath.nd_infra_clusterhealth("config", "cluster1") + assert result == "/api/v1/infra/clusterhealth/config/cluster1" + + +# ============================================================================= +# Test: Method composition +# ============================================================================= + + +def test_base_paths_infra_00400(): + """ + # Summary + + Verify nd_infra_aaa() uses nd_infra() internally + + ## Test + + - nd_infra_aaa("localUsers") equals nd_infra("aaa", "localUsers") + + ## Classes and Methods + + - BasePath.nd_infra() + - BasePath.nd_infra_aaa() + """ + with does_not_raise(): + result1 = BasePath.nd_infra_aaa("localUsers") + result2 = BasePath.nd_infra("aaa", "localUsers") + assert result1 == result2 + + +def test_base_paths_infra_00410(): + """ + # Summary + + Verify nd_infra_clusterhealth() uses nd_infra() internally + + ## Test + + - nd_infra_clusterhealth("config") equals nd_infra("clusterhealth", "config") + + ## Classes and Methods + + - BasePath.nd_infra() + - BasePath.nd_infra_clusterhealth() + """ + with does_not_raise(): + result1 = BasePath.nd_infra_clusterhealth("config") + result2 = BasePath.nd_infra("clusterhealth", "config") + assert result1 == result2 + + +# ============================================================================= +# Test: Edge cases +# ============================================================================= + + +def test_base_paths_infra_00500(): + """ + # Summary + + Verify empty string segment is handled + + ## Test + + - nd_infra("aaa", "", "localUsers") creates path with empty segment + - This creates double slashes (expected behavior) + + ## Classes and Methods + + - BasePath.nd_infra() + """ + with does_not_raise(): + result = BasePath.nd_infra("aaa", "", "localUsers") + assert result == "/api/v1/infra/aaa//localUsers" + + +def test_base_paths_infra_00510(): + """ + # Summary + + Verify segments with special characters + + ## Test + + - nd_infra_aaa("user-name_123") handles hyphens and underscores + + ## Classes and Methods + + - BasePath.nd_infra_aaa() + """ + with does_not_raise(): + result = BasePath.nd_infra_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.nd_infra() + """ + with does_not_raise(): + result = BasePath.nd_infra("my path") + assert result == "/api/v1/infra/my path" diff --git a/tests/unit/module_utils/endpoints/test_base_paths_manage.py b/tests/unit/module_utils/endpoints/test_base_paths_manage.py new file mode 100644 index 00000000..9561714b --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_base_paths_manage.py @@ -0,0 +1,309 @@ +# 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.base_path import ( + ND_MANAGE_API, + ApiPath, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage 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 ND_MANAGE_API and ApiPath.MANAGE + + ## Test + + - BasePath.API equals "/api/v1/manage" + - BasePath.API uses ApiPath.MANAGE.value + - Backward compat constant still works + + ## Classes and Methods + + - BasePath.API + - ApiPath.MANAGE + """ + with does_not_raise(): + result = BasePath.API + assert result == ND_MANAGE_API + assert result == ApiPath.MANAGE.value + assert result == "/api/v1/manage" + + +# ============================================================================= +# Test: nd_manage() method +# ============================================================================= + + +def test_base_paths_manage_00100(): + """ + # Summary + + Verify nd_manage() with no segments returns API root + + ## Test + + - nd_manage() returns "/api/v1/manage" + + ## Classes and Methods + + - BasePath.nd_manage() + """ + with does_not_raise(): + result = BasePath.nd_manage() + assert result == "/api/v1/manage" + + +def test_base_paths_manage_00110(): + """ + # Summary + + Verify nd_manage() with single segment + + ## Test + + - nd_manage("inventory") returns "/api/v1/manage/inventory" + + ## Classes and Methods + + - BasePath.nd_manage() + """ + with does_not_raise(): + result = BasePath.nd_manage("inventory") + assert result == "/api/v1/manage/inventory" + + +def test_base_paths_manage_00120(): + """ + # Summary + + Verify nd_manage() with multiple segments + + ## Test + + - nd_manage("inventory", "switches") returns "/api/v1/manage/inventory/switches" + + ## Classes and Methods + + - BasePath.nd_manage() + """ + with does_not_raise(): + result = BasePath.nd_manage("inventory", "switches") + assert result == "/api/v1/manage/inventory/switches" + + +def test_base_paths_manage_00130(): + """ + # Summary + + Verify nd_manage() with three segments + + ## Test + + - nd_manage("inventory", "switches", "fabric1") returns correct path + + ## Classes and Methods + + - BasePath.nd_manage() + """ + with does_not_raise(): + result = BasePath.nd_manage("inventory", "switches", "fabric1") + assert result == "/api/v1/manage/inventory/switches/fabric1" + + +# ============================================================================= +# Test: nd_manage_inventory() method +# ============================================================================= + + +def test_base_paths_manage_00200(): + """ + # Summary + + Verify nd_manage_inventory() with no segments + + ## Test + + - nd_manage_inventory() returns "/api/v1/manage/inventory" + + ## Classes and Methods + + - BasePath.nd_manage_inventory() + """ + with does_not_raise(): + result = BasePath.nd_manage_inventory() + assert result == "/api/v1/manage/inventory" + + +def test_base_paths_manage_00210(): + """ + # Summary + + Verify nd_manage_inventory() with single segment + + ## Test + + - nd_manage_inventory("switches") returns "/api/v1/manage/inventory/switches" + + ## Classes and Methods + + - BasePath.nd_manage_inventory() + """ + with does_not_raise(): + result = BasePath.nd_manage_inventory("switches") + assert result == "/api/v1/manage/inventory/switches" + + +def test_base_paths_manage_00220(): + """ + # Summary + + Verify nd_manage_inventory() with multiple segments + + ## Test + + - nd_manage_inventory("switches", "fabric1") returns correct path + + ## Classes and Methods + + - BasePath.nd_manage_inventory() + """ + with does_not_raise(): + result = BasePath.nd_manage_inventory("switches", "fabric1") + assert result == "/api/v1/manage/inventory/switches/fabric1" + + +# ============================================================================= +# Test: Method composition +# ============================================================================= + + +def test_base_paths_manage_00300(): + """ + # Summary + + Verify nd_manage_inventory() uses nd_manage() internally + + ## Test + + - nd_manage_inventory("switches") equals nd_manage("inventory", "switches") + + ## Classes and Methods + + - BasePath.nd_manage() + - BasePath.nd_manage_inventory() + """ + with does_not_raise(): + result1 = BasePath.nd_manage_inventory("switches") + result2 = BasePath.nd_manage("inventory", "switches") + assert result1 == result2 + + +def test_base_paths_manage_00310(): + """ + # Summary + + Verify method composition with multiple segments + + ## Test + + - nd_manage_inventory("switches", "summary") equals nd_manage("inventory", "switches", "summary") + + ## Classes and Methods + + - BasePath.nd_manage() + - BasePath.nd_manage_inventory() + """ + with does_not_raise(): + result1 = BasePath.nd_manage_inventory("switches", "summary") + result2 = BasePath.nd_manage("inventory", "switches", "summary") + assert result1 == result2 + + +# ============================================================================= +# Test: Edge cases +# ============================================================================= + + +def test_base_paths_manage_00400(): + """ + # Summary + + Verify empty string segment is handled + + ## Test + + - nd_manage("inventory", "", "switches") creates path with empty segment + - This creates double slashes (expected behavior) + + ## Classes and Methods + + - BasePath.nd_manage() + """ + with does_not_raise(): + result = BasePath.nd_manage("inventory", "", "switches") + assert result == "/api/v1/manage/inventory//switches" + + +def test_base_paths_manage_00410(): + """ + # Summary + + Verify segments with special characters + + ## Test + + - nd_manage_inventory("fabric-name_123") handles hyphens and underscores + + ## Classes and Methods + + - BasePath.nd_manage_inventory() + """ + with does_not_raise(): + result = BasePath.nd_manage_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.nd_manage() + """ + with does_not_raise(): + result = BasePath.nd_manage("my path") + assert result == "/api/v1/manage/my path" diff --git a/tests/unit/module_utils/endpoints/test_endpoint_mixins.py b/tests/unit/module_utils/endpoints/test_endpoint_mixins.py new file mode 100644 index 00000000..c31674fb --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoint_mixins.py @@ -0,0 +1,560 @@ +# 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 +""" + +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 ( + ClusterNameMixin, + FabricNameMixin, + ForceShowRunMixin, + HealthCategoryMixin, + InclAllMsdSwitchesMixin, + LinkUuidMixin, + LoginIdMixin, + NetworkNameMixin, + NodeNameMixin, + SwitchSerialNumberMixin, + VrfNameMixin, +) +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: ForceShowRunMixin +# ============================================================================= + + +def test_endpoint_mixins_00010(): + """ + # Summary + + Verify ForceShowRunMixin default value + + ## Test + + - force_show_run defaults to BooleanStringEnum.FALSE + + ## Classes and Methods + + - ForceShowRunMixin.force_show_run + """ + with does_not_raise(): + instance = ForceShowRunMixin() + assert instance.force_show_run == BooleanStringEnum.FALSE + assert instance.force_show_run.value == "false" + + +def test_endpoint_mixins_00020(): + """ + # Summary + + Verify ForceShowRunMixin can be set + + ## Test + + - force_show_run can be set to TRUE + + ## Classes and Methods + + - ForceShowRunMixin.force_show_run + """ + with does_not_raise(): + instance = ForceShowRunMixin(force_show_run=BooleanStringEnum.TRUE) + assert instance.force_show_run == BooleanStringEnum.TRUE + assert instance.force_show_run.value == "true" + + +# ============================================================================= +# Test: InclAllMsdSwitchesMixin +# ============================================================================= + + +def test_endpoint_mixins_00100(): + """ + # Summary + + Verify InclAllMsdSwitchesMixin default value + + ## Test + + - incl_all_msd_switches defaults to BooleanStringEnum.FALSE + + ## Classes and Methods + + - InclAllMsdSwitchesMixin.incl_all_msd_switches + """ + with does_not_raise(): + instance = InclAllMsdSwitchesMixin() + assert instance.incl_all_msd_switches == BooleanStringEnum.FALSE + assert instance.incl_all_msd_switches.value == "false" + + +def test_endpoint_mixins_00110(): + """ + # Summary + + Verify InclAllMsdSwitchesMixin can be set + + ## Test + + - incl_all_msd_switches can be set to TRUE + + ## Classes and Methods + + - InclAllMsdSwitchesMixin.incl_all_msd_switches + """ + with does_not_raise(): + instance = InclAllMsdSwitchesMixin(incl_all_msd_switches=BooleanStringEnum.TRUE) + assert instance.incl_all_msd_switches == BooleanStringEnum.TRUE + assert instance.incl_all_msd_switches.value == "true" + + +# ============================================================================= +# Test: FabricNameMixin +# ============================================================================= + + +def test_endpoint_mixins_00200(): + """ + # Summary + + Verify FabricNameMixin default value is None + + ## Test + + - fabric_name defaults to None + + ## Classes and Methods + + - FabricNameMixin.fabric_name + """ + with does_not_raise(): + instance = FabricNameMixin() + assert instance.fabric_name is None + + +def test_endpoint_mixins_00210(): + """ + # Summary + + Verify FabricNameMixin can be set + + ## Test + + - fabric_name can be set to a string value + + ## Classes and Methods + + - FabricNameMixin.fabric_name + """ + with does_not_raise(): + instance = FabricNameMixin(fabric_name="MyFabric") + assert instance.fabric_name == "MyFabric" + + +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: SwitchSerialNumberMixin +# ============================================================================= + + +def test_endpoint_mixins_00300(): + """ + # Summary + + Verify SwitchSerialNumberMixin default value is None + + ## Test + + - switch_sn defaults to None + + ## Classes and Methods + + - SwitchSerialNumberMixin.switch_sn + """ + with does_not_raise(): + instance = SwitchSerialNumberMixin() + assert instance.switch_sn is None + + +def test_endpoint_mixins_00310(): + """ + # Summary + + Verify SwitchSerialNumberMixin can be set + + ## Test + + - switch_sn can be set to a string value + + ## Classes and Methods + + - SwitchSerialNumberMixin.switch_sn + """ + with does_not_raise(): + instance = SwitchSerialNumberMixin(switch_sn="FDO12345678") + assert instance.switch_sn == "FDO12345678" + + +# ============================================================================= +# Test: NetworkNameMixin +# ============================================================================= + + +def test_endpoint_mixins_00400(): + """ + # Summary + + Verify NetworkNameMixin default value is None + + ## Test + + - network_name defaults to None + + ## Classes and Methods + + - NetworkNameMixin.network_name + """ + with does_not_raise(): + instance = NetworkNameMixin() + assert instance.network_name is None + + +def test_endpoint_mixins_00410(): + """ + # Summary + + Verify NetworkNameMixin can be set + + ## Test + + - network_name can be set to a string value + + ## Classes and Methods + + - NetworkNameMixin.network_name + """ + with does_not_raise(): + instance = NetworkNameMixin(network_name="MyNetwork") + assert instance.network_name == "MyNetwork" + + +# ============================================================================= +# Test: VrfNameMixin +# ============================================================================= + + +def test_endpoint_mixins_00500(): + """ + # Summary + + Verify VrfNameMixin default value is None + + ## Test + + - vrf_name defaults to None + + ## Classes and Methods + + - VrfNameMixin.vrf_name + """ + with does_not_raise(): + instance = VrfNameMixin() + assert instance.vrf_name is None + + +def test_endpoint_mixins_00510(): + """ + # Summary + + Verify VrfNameMixin can be set + + ## Test + + - vrf_name can be set to a string value + + ## Classes and Methods + + - VrfNameMixin.vrf_name + """ + with does_not_raise(): + instance = VrfNameMixin(vrf_name="MyVRF") + assert instance.vrf_name == "MyVRF" + + +# ============================================================================= +# Test: LinkUuidMixin +# ============================================================================= + + +def test_endpoint_mixins_00600(): + """ + # Summary + + Verify LinkUuidMixin default value is None + + ## Test + + - link_uuid defaults to None + + ## Classes and Methods + + - LinkUuidMixin.link_uuid + """ + with does_not_raise(): + instance = LinkUuidMixin() + assert instance.link_uuid is None + + +def test_endpoint_mixins_00610(): + """ + # Summary + + Verify LinkUuidMixin can be set + + ## Test + + - link_uuid can be set to a UUID string + + ## Classes and Methods + + - LinkUuidMixin.link_uuid + """ + with does_not_raise(): + instance = LinkUuidMixin(link_uuid="123e4567-e89b-12d3-a456-426614174000") + assert instance.link_uuid == "123e4567-e89b-12d3-a456-426614174000" + + +# ============================================================================= +# Test: LoginIdMixin +# ============================================================================= + + +def test_endpoint_mixins_00700(): + """ + # Summary + + Verify LoginIdMixin default value is None + + ## Test + + - login_id defaults to None + + ## Classes and Methods + + - LoginIdMixin.login_id + """ + with does_not_raise(): + instance = LoginIdMixin() + assert instance.login_id is None + + +def test_endpoint_mixins_00710(): + """ + # Summary + + Verify LoginIdMixin can be set + + ## Test + + - login_id can be set to a string value + + ## Classes and Methods + + - LoginIdMixin.login_id + """ + with does_not_raise(): + instance = LoginIdMixin(login_id="admin") + assert instance.login_id == "admin" + + +# ============================================================================= +# Test: ClusterNameMixin +# ============================================================================= + + +def test_endpoint_mixins_00800(): + """ + # Summary + + Verify ClusterNameMixin default value is None + + ## Test + + - cluster_name defaults to None + + ## Classes and Methods + + - ClusterNameMixin.cluster_name + """ + with does_not_raise(): + instance = ClusterNameMixin() + assert instance.cluster_name is None + + +def test_endpoint_mixins_00810(): + """ + # Summary + + Verify ClusterNameMixin can be set + + ## Test + + - cluster_name can be set to a string value + + ## Classes and Methods + + - ClusterNameMixin.cluster_name + """ + with does_not_raise(): + instance = ClusterNameMixin(cluster_name="my-cluster") + assert instance.cluster_name == "my-cluster" + + +# ============================================================================= +# Test: HealthCategoryMixin +# ============================================================================= + + +def test_endpoint_mixins_00900(): + """ + # Summary + + Verify HealthCategoryMixin default value is None + + ## Test + + - health_category defaults to None + + ## Classes and Methods + + - HealthCategoryMixin.health_category + """ + with does_not_raise(): + instance = HealthCategoryMixin() + assert instance.health_category is None + + +def test_endpoint_mixins_00910(): + """ + # Summary + + Verify HealthCategoryMixin can be set + + ## Test + + - health_category can be set to a string value + + ## Classes and Methods + + - HealthCategoryMixin.health_category + """ + with does_not_raise(): + instance = HealthCategoryMixin(health_category="cpu") + assert instance.health_category == "cpu" + + +# ============================================================================= +# Test: NodeNameMixin +# ============================================================================= + + +def test_endpoint_mixins_01000(): + """ + # Summary + + Verify NodeNameMixin default value is None + + ## Test + + - node_name defaults to None + + ## Classes and Methods + + - NodeNameMixin.node_name + """ + with does_not_raise(): + instance = NodeNameMixin() + assert instance.node_name is None + + +def test_endpoint_mixins_01010(): + """ + # Summary + + Verify NodeNameMixin can be set + + ## Test + + - node_name can be set to a string value + + ## Classes and Methods + + - NodeNameMixin.node_name + """ + with does_not_raise(): + instance = NodeNameMixin(node_name="node1") + assert instance.node_name == "node1" + + +# ============================================================================= +# 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.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa.py new file mode 100644 index 00000000..8c6621e6 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa.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 ep_api_v1_infra_aaa.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 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 00000000..04033917 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_clusterhealth.py @@ -0,0 +1,479 @@ +# 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 import ( + ClusterHealthConfigEndpointParams, + ClusterHealthStatusEndpointParams, + EpInfraClusterhealthConfigGet, + 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 result == "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 result == "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 + assert result == "/api/v1/infra/clusterhealth/status?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 + assert result == "/api/v1/infra/clusterhealth/status?clusterName=cluster1&nodeName=node1" + + +# ============================================================================= +# Test: Pydantic validation +# ============================================================================= + + +def test_endpoints_clusterhealth_00400(): + """ + # Summary + + Verify Pydantic validation for empty string + + ## Test + + - Empty string is rejected for cluster_name (min_length=1) + + ## Classes and Methods + + - ClusterHealthConfigEndpointParams.__init__() + """ + with pytest.raises(ValueError): + ClusterHealthConfigEndpointParams(cluster_name="") + + +def test_endpoints_clusterhealth_00410(): + """ + # Summary + + Verify parameters can be modified after instantiation + + ## Test + + - endpoint_params can be changed after object creation + + ## Classes and Methods + + - EpInfraClusterhealthConfigGet.endpoint_params + """ + with does_not_raise(): + instance = EpInfraClusterhealthConfigGet() + assert instance.path == "/api/v1/infra/clusterhealth/config" + + instance.endpoint_params.cluster_name = "new-cluster" + assert instance.path == "/api/v1/infra/clusterhealth/config?clusterName=new-cluster" + + +def test_endpoints_clusterhealth_00420(): + """ + # Summary + + Verify snake_case to camelCase conversion + + ## Test + + - cluster_name converts to clusterName in query string + - health_category converts to healthCategory + - node_name converts to nodeName + + ## Classes and Methods + + - ClusterHealthStatusEndpointParams.to_query_string() + """ + with does_not_raise(): + params = ClusterHealthStatusEndpointParams(cluster_name="test", health_category="cpu", node_name="node1") + result = params.to_query_string() + # Verify camelCase conversion + assert "clusterName=" in result + assert "healthCategory=" in result + assert "nodeName=" in result + # Verify no snake_case + assert "cluster_name" not in result + assert "health_category" not in result + assert "node_name" not in result diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_login.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_login.py new file mode 100644 index 00000000..83caaba8 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_login.py @@ -0,0 +1,68 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for infra_login.py + +Tests the ND Infra Login endpoint class +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra_login import ( + EpInfraLoginPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + + +def test_endpoints_api_v1_infra_login_00010(): + """ + # Summary + + Verify EpInfraLoginPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpInfraLoginPost.__init__() + - EpInfraLoginPost.class_name + - EpInfraLoginPost.verb + """ + with does_not_raise(): + instance = EpInfraLoginPost() + assert instance.class_name == "EpInfraLoginPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_infra_login_00020(): + """ + # Summary + + Verify EpInfraLoginPost path + + ## Test + + - path returns /api/v1/infra/login + + ## Classes and Methods + + - EpInfraLoginPost.path + """ + with does_not_raise(): + instance = EpInfraLoginPost() + result = instance.path + assert result == "/api/v1/infra/login" diff --git a/tests/unit/module_utils/endpoints/test_query_params.py b/tests/unit/module_utils/endpoints/test_query_params.py new file mode 100644 index 00000000..03500336 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_query_params.py @@ -0,0 +1,845 @@ +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for query_params.py + +Tests the query parameter composition classes +""" + +# pylint: disable=protected-access + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + CompositeQueryParams, + EndpointQueryParams, + LuceneQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import BooleanStringEnum +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Helper test class for EndpointQueryParams +# ============================================================================= + + +class SampleEndpointParams(EndpointQueryParams): + """Sample implementation of EndpointQueryParams for testing.""" + + force_show_run: BooleanStringEnum | None = Field(default=None) + fabric_name: str | None = Field(default=None) + switch_count: int | None = Field(default=None) + + +# ============================================================================= +# Test: EndpointQueryParams +# ============================================================================= + + +def test_query_params_00010(): + """ + # Summary + + Verify EndpointQueryParams default implementation + + ## Test + + - to_query_string() returns empty string when no params set + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + """ + with does_not_raise(): + params = SampleEndpointParams() + result = params.to_query_string() + # Only non-None, non-default values are included + assert result == "" + + +def test_query_params_00020(): + """ + # Summary + + Verify EndpointQueryParams snake_case to camelCase conversion + + ## Test + + - force_show_run converts to forceShowRun + - fabric_name converts to fabricName + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + - EndpointQueryParams._to_camel_case() + """ + with does_not_raise(): + params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE, fabric_name="Fabric1") + result = params.to_query_string() + assert "forceShowRun=" in result + assert "fabricName=" in result + # Verify no snake_case + assert "force_show_run" not in result + assert "fabric_name" not in result + + +def test_query_params_00030(): + """ + # Summary + + Verify EndpointQueryParams handles Enum values + + ## Test + + - BooleanStringEnum.TRUE converts to "true" + - BooleanStringEnum.FALSE converts to "false" + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + """ + with does_not_raise(): + params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE) + result = params.to_query_string() + assert "forceShowRun=true" in result + + +def test_query_params_00040(): + """ + # Summary + + Verify EndpointQueryParams handles integer values + + ## Test + + - Integer values are converted to strings + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + """ + with does_not_raise(): + params = SampleEndpointParams(switch_count=42) + result = params.to_query_string() + assert result == "switchCount=42" + + +def test_query_params_00050(): + """ + # Summary + + Verify EndpointQueryParams handles string values + + ## Test + + - String values are included as-is + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + """ + with does_not_raise(): + params = SampleEndpointParams(fabric_name="MyFabric") + result = params.to_query_string() + assert result == "fabricName=MyFabric" + + +def test_query_params_00060(): + """ + # Summary + + Verify EndpointQueryParams handles multiple params + + ## Test + + - Multiple parameters are joined with '&' + + ## Classes and Methods + + - EndpointQueryParams.to_query_string() + """ + with does_not_raise(): + params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE, fabric_name="Fabric1", switch_count=10) + result = params.to_query_string() + assert "forceShowRun=true" in result + assert "fabricName=Fabric1" in result + assert "switchCount=10" in result + assert result.count("&") == 2 + + +def test_query_params_00070(): + """ + # Summary + + Verify EndpointQueryParams is_empty() method + + ## Test + + - is_empty() returns True when no params set + - is_empty() returns False when params are set + + ## Classes and Methods + + - EndpointQueryParams.is_empty() + """ + with does_not_raise(): + params = SampleEndpointParams() + assert params.is_empty() is True + + params.fabric_name = "Fabric1" + assert params.is_empty() is False + + +def test_query_params_00080(): + """ + # Summary + + Verify EndpointQueryParams _to_camel_case() static method + + ## Test + + - Correctly converts various snake_case strings to camelCase + + ## Classes and Methods + + - EndpointQueryParams._to_camel_case() + """ + with does_not_raise(): + assert EndpointQueryParams._to_camel_case("simple") == "simple" + assert EndpointQueryParams._to_camel_case("snake_case") == "snakeCase" + assert EndpointQueryParams._to_camel_case("long_snake_case_name") == "longSnakeCaseName" + assert EndpointQueryParams._to_camel_case("single") == "single" + + +# ============================================================================= +# Test: LuceneQueryParams +# ============================================================================= + + +def test_query_params_00100(): + """ + # Summary + + Verify LuceneQueryParams default values + + ## Test + + - All parameters default to None + + ## Classes and Methods + + - LuceneQueryParams.__init__() + """ + with does_not_raise(): + params = LuceneQueryParams() + assert params.filter is None + assert params.max is None + assert params.offset is None + assert params.sort is None + assert params.fields is None + + +def test_query_params_00110(): + """ + # Summary + + Verify LuceneQueryParams filter parameter + + ## Test + + - filter can be set to a string value + - to_query_string() includes filter parameter + + ## Classes and Methods + + - LuceneQueryParams.__init__() + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(filter="name:MyFabric") + result = params.to_query_string() + assert "filter=" in result + assert "name" in result + assert "MyFabric" in result + + +def test_query_params_00120(): + """ + # Summary + + Verify LuceneQueryParams max parameter + + ## Test + + - max can be set to an integer value + - to_query_string() includes max parameter + + ## Classes and Methods + + - LuceneQueryParams.__init__() + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(max=100) + result = params.to_query_string() + assert result == "max=100" + + +def test_query_params_00130(): + """ + # Summary + + Verify LuceneQueryParams offset parameter + + ## Test + + - offset can be set to an integer value + - to_query_string() includes offset parameter + + ## Classes and Methods + + - LuceneQueryParams.__init__() + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(offset=20) + result = params.to_query_string() + assert result == "offset=20" + + +def test_query_params_00140(): + """ + # Summary + + Verify LuceneQueryParams sort parameter + + ## Test + + - sort can be set to a valid string + - to_query_string() includes sort parameter + + ## Classes and Methods + + - LuceneQueryParams.__init__() + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(sort="name:asc") + result = params.to_query_string() + assert "sort=" in result + assert "name" in result + + +def test_query_params_00150(): + """ + # Summary + + Verify LuceneQueryParams fields parameter + + ## Test + + - fields can be set to a comma-separated string + - to_query_string() includes fields parameter + + ## Classes and Methods + + - LuceneQueryParams.__init__() + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(fields="name,id,status") + result = params.to_query_string() + assert "fields=" in result + + +def test_query_params_00160(): + """ + # Summary + + Verify LuceneQueryParams URL encoding + + ## Test + + - Special characters in filter are URL-encoded by default + + ## Classes and Methods + + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(filter="name:Fabric* AND status:active") + result = params.to_query_string(url_encode=True) + # Check for URL-encoded characters + assert "filter=" in result + # Space should be encoded + assert "%20" in result or "+" in result + + +def test_query_params_00170(): + """ + # Summary + + Verify LuceneQueryParams URL encoding can be disabled + + ## Test + + - url_encode=False preserves special characters + + ## Classes and Methods + + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(filter="name:Fabric* AND status:active") + result = params.to_query_string(url_encode=False) + assert result == "filter=name:Fabric* AND status:active" + + +def test_query_params_00180(): + """ + # Summary + + Verify LuceneQueryParams is_empty() method + + ## Test + + - is_empty() returns True when no params set + - is_empty() returns False when params are set + + ## Classes and Methods + + - LuceneQueryParams.is_empty() + """ + with does_not_raise(): + params = LuceneQueryParams() + assert params.is_empty() is True + + params.max = 100 + assert params.is_empty() is False + + +def test_query_params_00190(): + """ + # Summary + + Verify LuceneQueryParams multiple parameters + + ## Test + + - Multiple parameters are joined with '&' + - Parameters appear in expected order + + ## Classes and Methods + + - LuceneQueryParams.to_query_string() + """ + with does_not_raise(): + params = LuceneQueryParams(filter="name:*", max=50, offset=10, sort="name:asc") + result = params.to_query_string(url_encode=False) + assert "filter=name:*" in result + assert "max=50" in result + assert "offset=10" in result + assert "sort=name:asc" in result + + +# ============================================================================= +# Test: LuceneQueryParams validation +# ============================================================================= + + +def test_query_params_00200(): + """ + # Summary + + Verify LuceneQueryParams validates max range + + ## Test + + - max must be >= 1 + - max must be <= 10000 + + ## Classes and Methods + + - LuceneQueryParams.__init__() + """ + # Valid values + with does_not_raise(): + LuceneQueryParams(max=1) + LuceneQueryParams(max=10000) + LuceneQueryParams(max=500) + + # Invalid values + with pytest.raises(ValueError): + LuceneQueryParams(max=0) + + with pytest.raises(ValueError): + LuceneQueryParams(max=10001) + + +def test_query_params_00210(): + """ + # Summary + + Verify LuceneQueryParams validates offset range + + ## Test + + - offset must be >= 0 + + ## Classes and Methods + + - LuceneQueryParams.__init__() + """ + # Valid values + with does_not_raise(): + LuceneQueryParams(offset=0) + LuceneQueryParams(offset=100) + + # Invalid values + with pytest.raises(ValueError): + LuceneQueryParams(offset=-1) + + +def test_query_params_00220(): + """ + # Summary + + Verify LuceneQueryParams validates sort format + + ## Test + + - sort direction must be 'asc' or 'desc' + - Invalid directions are rejected + + ## Classes and Methods + + - LuceneQueryParams.validate_sort() + """ + # Valid values + with does_not_raise(): + LuceneQueryParams(sort="name:asc") + LuceneQueryParams(sort="name:desc") + LuceneQueryParams(sort="name:ASC") + LuceneQueryParams(sort="name:DESC") + + # Invalid direction + with pytest.raises(ValueError, match="Sort direction must be"): + LuceneQueryParams(sort="name:invalid") + + +def test_query_params_00230(): + """ + # Summary + + Verify LuceneQueryParams allows sort without direction + + ## Test + + - sort can be set without ':' separator + - Validation only applies when ':' is present + + ## Classes and Methods + + - LuceneQueryParams.validate_sort() + """ + with does_not_raise(): + params = LuceneQueryParams(sort="name") + result = params.to_query_string(url_encode=False) + assert result == "sort=name" + + +# ============================================================================= +# Test: CompositeQueryParams +# ============================================================================= + + +def test_query_params_00300(): + """ + # Summary + + Verify CompositeQueryParams basic instantiation + + ## Test + + - Instance can be created + - Starts with empty parameter groups + + ## Classes and Methods + + - CompositeQueryParams.__init__() + """ + with does_not_raise(): + composite = CompositeQueryParams() + assert composite.is_empty() is True + + +def test_query_params_00310(): + """ + # Summary + + Verify CompositeQueryParams add() method + + ## Test + + - Can add EndpointQueryParams + - Returns self for method chaining + + ## Classes and Methods + + - CompositeQueryParams.add() + """ + with does_not_raise(): + composite = CompositeQueryParams() + endpoint_params = SampleEndpointParams(fabric_name="Fabric1") + result = composite.add(endpoint_params) + assert result is composite + assert composite.is_empty() is False + + +def test_query_params_00320(): + """ + # Summary + + Verify CompositeQueryParams add() with LuceneQueryParams + + ## Test + + - Can add LuceneQueryParams + - Parameters are combined correctly + + ## Classes and Methods + + - CompositeQueryParams.add() + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + composite = CompositeQueryParams() + lucene_params = LuceneQueryParams(max=100) + composite.add(lucene_params) + result = composite.to_query_string() + assert result == "max=100" + + +def test_query_params_00330(): + """ + # Summary + + Verify CompositeQueryParams method chaining + + ## Test + + - Multiple add() calls can be chained + - All parameters are included in final query string + + ## Classes and Methods + + - CompositeQueryParams.add() + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + endpoint_params = SampleEndpointParams(fabric_name="Fabric1") + lucene_params = LuceneQueryParams(max=50) + + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + + result = composite.to_query_string() + assert "fabricName=Fabric1" in result + assert "max=50" in result + + +def test_query_params_00340(): + """ + # Summary + + Verify CompositeQueryParams parameter ordering + + ## Test + + - Parameters appear in order they were added + - EndpointQueryParams before LuceneQueryParams + + ## Classes and Methods + + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + endpoint_params = SampleEndpointParams(fabric_name="Fabric1") + lucene_params = LuceneQueryParams(max=50) + + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + + result = composite.to_query_string() + + # fabricName should appear before max + fabric_pos = result.index("fabricName") + max_pos = result.index("max") + assert fabric_pos < max_pos + + +def test_query_params_00350(): + """ + # Summary + + Verify CompositeQueryParams is_empty() method + + ## Test + + - is_empty() returns True when all groups are empty + - is_empty() returns False when any group has params + + ## Classes and Methods + + - CompositeQueryParams.is_empty() + """ + with does_not_raise(): + composite = CompositeQueryParams() + assert composite.is_empty() is True + + # Add empty parameter group + empty_params = SampleEndpointParams() + composite.add(empty_params) + assert composite.is_empty() is True + + # Add non-empty parameter group + endpoint_params = SampleEndpointParams(fabric_name="Fabric1") + composite.add(endpoint_params) + assert composite.is_empty() is False + + +def test_query_params_00360(): + """ + # Summary + + Verify CompositeQueryParams clear() method + + ## Test + + - clear() removes all parameter groups + - is_empty() returns True after clear() + + ## Classes and Methods + + - CompositeQueryParams.clear() + - CompositeQueryParams.is_empty() + """ + with does_not_raise(): + composite = CompositeQueryParams() + endpoint_params = SampleEndpointParams(fabric_name="Fabric1") + composite.add(endpoint_params) + + assert composite.is_empty() is False + + composite.clear() + assert composite.is_empty() is True + + +def test_query_params_00370(): + """ + # Summary + + Verify CompositeQueryParams URL encoding propagation + + ## Test + + - url_encode parameter is passed to LuceneQueryParams + - EndpointQueryParams not affected (no url_encode parameter) + + ## Classes and Methods + + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + endpoint_params = SampleEndpointParams(fabric_name="My Fabric") + lucene_params = LuceneQueryParams(filter="name:Test Value") + + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + + # With URL encoding + result_encoded = composite.to_query_string(url_encode=True) + assert "filter=" in result_encoded + + # Without URL encoding + result_plain = composite.to_query_string(url_encode=False) + assert "filter=name:Test Value" in result_plain + + +def test_query_params_00380(): + """ + # Summary + + Verify CompositeQueryParams with empty groups + + ## Test + + - Empty parameter groups are skipped in query string + - Only non-empty groups contribute to query string + + ## Classes and Methods + + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + empty_endpoint = SampleEndpointParams() + non_empty_lucene = LuceneQueryParams(max=100) + + composite = CompositeQueryParams() + composite.add(empty_endpoint).add(non_empty_lucene) + + result = composite.to_query_string() + + # Should only contain the Lucene params + assert result == "max=100" + + +# ============================================================================= +# Test: Integration scenarios +# ============================================================================= + + +def test_query_params_00400(): + """ + # Summary + + Verify complex query string composition + + ## Test + + - Combine multiple EndpointQueryParams with LuceneQueryParams + - All parameters are correctly formatted and encoded + + ## Classes and Methods + + - CompositeQueryParams.add() + - CompositeQueryParams.to_query_string() + """ + with does_not_raise(): + endpoint_params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE, fabric_name="Production", switch_count=5) + + lucene_params = LuceneQueryParams(filter="status:active AND role:leaf", max=100, offset=0, sort="name:asc") + + composite = CompositeQueryParams() + composite.add(endpoint_params).add(lucene_params) + + result = composite.to_query_string(url_encode=False) + + # Verify all parameters present + assert "forceShowRun=true" in result + assert "fabricName=Production" in result + assert "switchCount=5" in result + assert "filter=status:active AND role:leaf" in result + assert "max=100" in result + assert "offset=0" in result + assert "sort=name:asc" in result From 1169c00c479bfd912ab9b5a86488ca36f7f95ec0 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Mon, 9 Mar 2026 23:25:07 +0530 Subject: [PATCH 03/39] Changes ported from old PR for VPC pair \ https://github.com/sivakasi-cisco/ansible-nd/pull/1/ --- plugins/module_utils/ep/__init__.py | 25 + plugins/module_utils/ep/v1/__init__.py | 30 + .../module_utils/ep/v1/ep_manage_vpc_pair.py | 32 + .../module_utils/manage/vpc_pair/__init__.py | 107 + .../manage/vpc_pair/base_paths.py | 304 ++ .../manage/vpc_pair/endpoint_mixins.py | 106 + plugins/module_utils/manage/vpc_pair/enums.py | 252 ++ .../vpc_pair/model_playbook_vpc_pair.py | 931 ++++++ .../manage/vpc_pair/vpc_pair_endpoints.py | 441 +++ .../manage/vpc_pair/vpc_pair_resources.py | 399 +++ plugins/modules/nd_vpc_pair.py | 2778 +++++++++++++++++ .../tests/nd/nd_vpc_pair_delete.yaml | 261 ++ .../tests/nd/nd_vpc_pair_gather.yaml | 289 ++ .../tests/nd/nd_vpc_pair_merge.yaml | 658 ++++ .../tests/nd/nd_vpc_pair_override.yaml | 218 ++ .../tests/nd/nd_vpc_pair_replace.yaml | 145 + 16 files changed, 6976 insertions(+) create mode 100644 plugins/module_utils/ep/__init__.py create mode 100644 plugins/module_utils/ep/v1/__init__.py create mode 100644 plugins/module_utils/ep/v1/ep_manage_vpc_pair.py create mode 100644 plugins/module_utils/manage/vpc_pair/__init__.py create mode 100644 plugins/module_utils/manage/vpc_pair/base_paths.py create mode 100644 plugins/module_utils/manage/vpc_pair/endpoint_mixins.py create mode 100644 plugins/module_utils/manage/vpc_pair/enums.py create mode 100644 plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py create mode 100644 plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py create mode 100644 plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py create mode 100644 plugins/modules/nd_vpc_pair.py create mode 100644 tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml create mode 100644 tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml create mode 100644 tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml create mode 100644 tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml create mode 100644 tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml diff --git a/plugins/module_utils/ep/__init__.py b/plugins/module_utils/ep/__init__.py new file mode 100644 index 00000000..139784f5 --- /dev/null +++ b/plugins/module_utils/ep/__init__.py @@ -0,0 +1,25 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ansible_collections.cisco.nd.plugins.module_utils.ep.v1 import ( + EpVpcPairConsistencyGet, + EpVpcPairGet, + EpVpcPairOverviewGet, + EpVpcPairPut, + EpVpcPairRecommendationGet, + EpVpcPairSupportGet, + EpVpcPairsListGet, + VpcPairBasePath, +) + +__all__ = [ + "VpcPairBasePath", + "EpVpcPairGet", + "EpVpcPairPut", + "EpVpcPairSupportGet", + "EpVpcPairOverviewGet", + "EpVpcPairRecommendationGet", + "EpVpcPairConsistencyGet", + "EpVpcPairsListGet", +] diff --git a/plugins/module_utils/ep/v1/__init__.py b/plugins/module_utils/ep/v1/__init__.py new file mode 100644 index 00000000..b950c7cd --- /dev/null +++ b/plugins/module_utils/ep/v1/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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.ep.v1.ep_manage_vpc_pair import ( + EpVpcPairConsistencyGet, + EpVpcPairGet, + EpVpcPairOverviewGet, + EpVpcPairPut, + EpVpcPairRecommendationGet, + EpVpcPairSupportGet, + EpVpcPairsListGet, + VpcPairBasePath, +) + +__all__ = [ + "VpcPairBasePath", + "EpVpcPairGet", + "EpVpcPairPut", + "EpVpcPairSupportGet", + "EpVpcPairOverviewGet", + "EpVpcPairRecommendationGet", + "EpVpcPairConsistencyGet", + "EpVpcPairsListGet", +] diff --git a/plugins/module_utils/ep/v1/ep_manage_vpc_pair.py b/plugins/module_utils/ep/v1/ep_manage_vpc_pair.py new file mode 100644 index 00000000..64006224 --- /dev/null +++ b/plugins/module_utils/ep/v1/ep_manage_vpc_pair.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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.manage.vpc_pair.base_paths import ( + VpcPairBasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_endpoints import ( + EpVpcPairConsistencyGet, + EpVpcPairGet, + EpVpcPairOverviewGet, + EpVpcPairPut, + EpVpcPairRecommendationGet, + EpVpcPairSupportGet, + EpVpcPairsListGet, +) + +__all__ = [ + "VpcPairBasePath", + "EpVpcPairGet", + "EpVpcPairPut", + "EpVpcPairSupportGet", + "EpVpcPairOverviewGet", + "EpVpcPairRecommendationGet", + "EpVpcPairConsistencyGet", + "EpVpcPairsListGet", +] diff --git a/plugins/module_utils/manage/vpc_pair/__init__.py b/plugins/module_utils/manage/vpc_pair/__init__.py new file mode 100644 index 00000000..d1507272 --- /dev/null +++ b/plugins/module_utils/manage/vpc_pair/__init__.py @@ -0,0 +1,107 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." +__author__ = "Neil John" + +""" +VPC pair management utilities for Cisco ND Ansible collection. + +This package provides Pydantic-based schemas and endpoint models for +managing VPC pairs in Nexus Dashboard. + +Components: +- enums: Enumeration types for constrained values +- endpoint_mixins: Reusable field mixins for composition +- base_paths: Centralized API path builders +- vpc_pair_endpoints: Endpoint models for each API operation +- vpc_pair_schemas: Request/response data schemas + +Usage: + from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair import ( + EpVpcPairGet, + EpVpcPairPut, + VpcPairDetailsDefault, + VpcPairingRequest, + VpcActionEnum, + ) +""" + +# Export commonly used components for easier imports +__all__ = [ + # Endpoints + "EpVpcPairGet", + "EpVpcPairPut", + "EpVpcPairSupportGet", + "EpVpcPairOverviewGet", + "EpVpcPairRecommendationGet", + "EpVpcPairConsistencyGet", + "EpVpcPairsListGet", + # Schemas + "VpcPairDetailsDefault", + "VpcPairDetailsCustom", + "VpcPairingRequest", + "VpcUnpairingRequest", + "VpcPairBase", + "VpcPairConsistency", + "VpcPairRecommendation", + # Enums + "VerbEnum", + "VpcActionEnum", + "VpcPairTypeEnum", + "KeepAliveVrfEnum", + "PoModeEnum", + "PortChannelDuplexEnum", + "VpcRoleEnum", + "MaintenanceModeEnum", + "ComponentTypeOverviewEnum", + "ComponentTypeSupportEnum", + "VpcPairViewEnum", + # Field names + "VpcFieldNames", + # Base paths + "VpcPairBasePath", +] + +# Try to import and expose components (graceful fallback if pydantic not available) +try: + from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_endpoints import ( + EpVpcPairGet, + EpVpcPairPut, + EpVpcPairSupportGet, + EpVpcPairOverviewGet, + EpVpcPairRecommendationGet, + EpVpcPairConsistencyGet, + EpVpcPairsListGet, + ) + from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.model_playbook_vpc_pair import ( + VpcPairDetailsDefault, + VpcPairDetailsCustom, + VpcPairingRequest, + VpcUnpairingRequest, + VpcPairBase, + VpcPairConsistency, + VpcPairRecommendation, + ) + from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.enums import ( + VerbEnum, + VpcActionEnum, + VpcPairTypeEnum, + KeepAliveVrfEnum, + PoModeEnum, + PortChannelDuplexEnum, + VpcRoleEnum, + MaintenanceModeEnum, + ComponentTypeOverviewEnum, + ComponentTypeSupportEnum, + VpcPairViewEnum, + VpcFieldNames, + ) + from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.base_paths import VpcPairBasePath + +except ImportError as e: + # Pydantic not available - components will not be exposed + # This allows the package to be imported without pydantic for basic functionality + import sys + print(f"Warning: Could not import VPC pair components: {e}", file=sys.stderr) + pass diff --git a/plugins/module_utils/manage/vpc_pair/base_paths.py b/plugins/module_utils/manage/vpc_pair/base_paths.py new file mode 100644 index 00000000..e9293974 --- /dev/null +++ b/plugins/module_utils/manage/vpc_pair/base_paths.py @@ -0,0 +1,304 @@ +# Copyright (c) 2026 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Centralized base paths for VPC pair API endpoints. + +This module provides a single location to manage all VPC pair API base paths, +allowing easy modification when API paths change. All endpoint classes +should use these path builders for consistency. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Sivakami Sivaraman" + +from typing import Final + + +class VpcPairBasePath: + """ + # Summary + + Centralized VPC Pair API Base Paths + + ## Description + + Provides centralized base path definitions for all ND Manage VPC Pair + API endpoints. This allows API path changes to be managed in a single + location. + + ## Usage + + ```python + # Get VPC pair details path + path = VpcPairBasePath.vpc_pair("Fabric1", "FDO23040Q85") + # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPair + + # Get VPC pairs list path + path = VpcPairBasePath.vpc_pairs_list("Fabric1") + # Returns: /api/v1/manage/fabrics/Fabric1/vpcPairs + ``` + + ## 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 changes base API paths, only this class needs updating + """ + + # Root API paths + MANAGE_API: Final = "/api/v1/manage" + + @classmethod + def manage(cls, *segments: str) -> str: + """ + # Summary + + Build path from Manage API root. + + ## Parameters + + - segments: Path segments to append + + ## Returns + + - Complete path string + + ## Example + + ```python + path = VpcPairBasePath.manage("fabrics", "Fabric1") + # Returns: /api/v1/manage/fabrics/Fabric1 + ``` + """ + if not segments: + return cls.MANAGE_API + return f"{cls.MANAGE_API}/{'/'.join(segments)}" + + @classmethod + def fabrics(cls, fabric_name: str, *segments: str) -> str: + """ + # Summary + + Build fabrics API path. + + ## Parameters + + - fabric_name: Name of the fabric + - segments: Additional path segments to append + + ## Returns + + - Complete fabrics path + + ## Raises + + - ValueError: If fabric_name is None, empty, or not a string + + ## Example + + ```python + path = VpcPairBasePath.fabrics("Fabric1", "switches") + # Returns: /api/v1/manage/fabrics/Fabric1/switches + ``` + """ + # Validate fabric_name + if not fabric_name or not isinstance(fabric_name, str) or not fabric_name.strip(): + raise ValueError( + f"VpcPairBasePath.fabrics(): fabric_name must be a non-empty string. " + f"Got: {fabric_name!r} (type: {type(fabric_name).__name__})" + ) + + if not segments: + return cls.manage("fabrics", fabric_name) + return cls.manage("fabrics", fabric_name, *segments) + + @classmethod + def switches(cls, fabric_name: str, switch_id: str, *segments: str) -> str: + """ + # Summary + + Build switches API path. + + ## Parameters + + - fabric_name: Name of the fabric + - switch_id: Serial number of the switch + - segments: Additional path segments to append + + ## Returns + + - Complete switches path + + ## Example + + ```python + path = VpcPairBasePath.switches("Fabric1", "FDO23040Q85") + # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85 + ``` + """ + if not segments: + return cls.fabrics(fabric_name, "switches", switch_id) + return cls.fabrics(fabric_name, "switches", switch_id, *segments) + + @classmethod + def vpc_pair(cls, fabric_name: str, switch_id: str) -> str: + """ + # Summary + + Build VPC pair details API path. + + ## Parameters + + - fabric_name: Name of the fabric + - switch_id: Serial number of the switch + + ## Returns + + - Complete VPC pair path + + ## Example + + ```python + path = VpcPairBasePath.vpc_pair("Fabric1", "FDO23040Q85") + # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPair + ``` + """ + return cls.switches(fabric_name, switch_id, "vpcPair") + + @classmethod + def vpc_pair_support(cls, fabric_name: str, switch_id: str) -> str: + """ + # Summary + + Build VPC pair support check API path. + + ## Parameters + + - fabric_name: Name of the fabric + - switch_id: Serial number of the switch + + ## Returns + + - Complete VPC pair support path + + ## Example + + ```python + path = VpcPairBasePath.vpc_pair_support("Fabric1", "FDO23040Q85") + # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPairSupport + ``` + """ + return cls.switches(fabric_name, switch_id, "vpcPairSupport") + + @classmethod + def vpc_pair_overview(cls, fabric_name: str, switch_id: str) -> str: + """ + # Summary + + Build VPC pair overview API path. + + ## Parameters + + - fabric_name: Name of the fabric + - switch_id: Serial number of the switch + + ## Returns + + - Complete VPC pair overview path + + ## Example + + ```python + path = VpcPairBasePath.vpc_pair_overview("Fabric1", "FDO23040Q85") + # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPairOverview + ``` + """ + return cls.switches(fabric_name, switch_id, "vpcPairOverview") + + @classmethod + def vpc_pair_recommendation(cls, fabric_name: str, switch_id: str) -> str: + """ + # Summary + + Build VPC pair recommendation API path. + + ## Parameters + + - fabric_name: Name of the fabric + - switch_id: Serial number of the switch + + ## Returns + + - Complete VPC pair recommendation path + + ## Example + + ```python + path = VpcPairBasePath.vpc_pair_recommendation("Fabric1", "FDO23040Q85") + # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPairRecommendation + ``` + """ + return cls.switches(fabric_name, switch_id, "vpcPairRecommendation") + + @classmethod + def vpc_pair_consistency(cls, fabric_name: str, switch_id: str) -> str: + """ + # Summary + + Build VPC pair consistency API path. + + ## Parameters + + - fabric_name: Name of the fabric + - switch_id: Serial number of the switch + + ## Returns + + - Complete VPC pair consistency path + + ## Example + + ```python + path = VpcPairBasePath.vpc_pair_consistency("Fabric1", "FDO23040Q85") + # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPairConsistency + ``` + """ + return cls.switches(fabric_name, switch_id, "vpcPairConsistency") + + @classmethod + def vpc_pairs_list(cls, fabric_name: str) -> str: + """ + # Summary + + Build VPC pairs list API path. + + ## Parameters + + - fabric_name: Name of the fabric + + ## Returns + + - Complete VPC pairs list path + + ## Example + + ```python + path = VpcPairBasePath.vpc_pairs_list("Fabric1") + # Returns: /api/v1/manage/fabrics/Fabric1/vpcPairs + ``` + """ + return cls.fabrics(fabric_name, "vpcPairs") diff --git a/plugins/module_utils/manage/vpc_pair/endpoint_mixins.py b/plugins/module_utils/manage/vpc_pair/endpoint_mixins.py new file mode 100644 index 00000000..92bb9b91 --- /dev/null +++ b/plugins/module_utils/manage/vpc_pair/endpoint_mixins.py @@ -0,0 +1,106 @@ +# Copyright (c) 2026 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Reusable mixin classes for VPC pair endpoint models. + +This module provides mixin classes that can be composed to add common +fields to endpoint models without duplication. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Sivakami Sivaraman" + +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from pydantic import BaseModel, Field +else: + try: + from pydantic import BaseModel, Field + except ImportError: + # Fallback for environments without pydantic + class BaseModel: + pass + + def Field(*args, **kwargs): + return None + + +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 SwitchIdMixin(BaseModel): + """Mixin for endpoints that require switch_id parameter.""" + + switch_id: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") + + +class PeerSwitchIdMixin(BaseModel): + """Mixin for endpoints that require peer_switch_id parameter.""" + + peer_switch_id: Optional[str] = Field(default=None, min_length=1, description="Peer switch serial number") + + +class UseVirtualPeerLinkMixin(BaseModel): + """Mixin for endpoints that require use_virtual_peer_link parameter.""" + + use_virtual_peer_link: Optional[bool] = Field(default=False, description="Indicates whether a virtual peer link is present") + + +class FromClusterMixin(BaseModel): + """Mixin for endpoints that support fromCluster query parameter.""" + + from_cluster: Optional[str] = Field(default=None, description="Optional cluster name") + + +class TicketIdMixin(BaseModel): + """Mixin for endpoints that support ticketId query parameter.""" + + ticket_id: Optional[str] = Field(default=None, description="Change ticket ID") + + +class ComponentTypeMixin(BaseModel): + """Mixin for endpoints that require componentType query parameter.""" + + component_type: Optional[str] = Field(default=None, description="Component type for filtering response") + + +class FilterMixin(BaseModel): + """Mixin for endpoints that support filter query parameter.""" + + filter: Optional[str] = Field(default=None, description="Filter expression for results") + + +class PaginationMixin(BaseModel): + """Mixin for endpoints that support pagination parameters.""" + + max: Optional[int] = Field(default=None, ge=1, description="Maximum number of results") + offset: Optional[int] = Field(default=None, ge=0, description="Offset for pagination") + + +class SortMixin(BaseModel): + """Mixin for endpoints that support sort parameter.""" + + sort: Optional[str] = Field(default=None, description="Sort field and direction (e.g., 'name:asc')") + + +class ViewMixin(BaseModel): + """Mixin for endpoints that support view parameter.""" + + view: Optional[str] = Field(default=None, description="Optional view type for filtering results") diff --git a/plugins/module_utils/manage/vpc_pair/enums.py b/plugins/module_utils/manage/vpc_pair/enums.py new file mode 100644 index 00000000..4f547cc0 --- /dev/null +++ b/plugins/module_utils/manage/vpc_pair/enums.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2026 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Enums for VPC pair management. + +This module provides enumeration types used throughout the VPC pair +management implementation. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Sivakami Sivaraman" + +from enum import Enum + +# Import HttpVerbEnum from top-level enums module (RestSend infrastructure) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +# Backward compatibility alias - Use HttpVerbEnum directly in new code +VerbEnum = HttpVerbEnum + + +# ============================================================================ +# VPC ACTION ENUMS +# ============================================================================ + + +class VpcActionEnum(str, Enum): + """ + VPC pair action types for discriminator pattern. + + Used in API payloads to distinguish between pair/unpair operations. + Values must match OpenAPI discriminator mapping exactly: + - "pair" (lowercase) for pairing operations + - "unPair" (camelCase) for unpairing operations + """ + + PAIR = "pair" # Create or update VPC pair (lowercase per OpenAPI spec) + UNPAIR = "unPair" # Delete VPC pair (camelCase per OpenAPI spec) + + +# ============================================================================ +# TEMPLATE AND CONFIGURATION ENUMS +# ============================================================================ + + +class VpcPairTypeEnum(str, Enum): + """ + VPC pair template types. + + Discriminator for vpc_pair_details field. + """ + + DEFAULT = "default" # Use default VPC pair template + CUSTOM = "custom" # Use custom VPC pair template + + +class KeepAliveVrfEnum(str, Enum): + """ + VPC keep-alive VRF options. + + VRF used for vPC keep-alive link traffic. + """ + + DEFAULT = "default" # Use default VRF + MANAGEMENT = "management" # Use management VRF + + +class PoModeEnum(str, Enum): + """ + Port-channel mode options for vPC interfaces. + + Defines LACP behavior. + """ + + ON = "on" # Static channel mode (no LACP) + ACTIVE = "active" # LACP active mode (initiates negotiation) + PASSIVE = "passive" # LACP passive mode (waits for negotiation) + + +class PortChannelDuplexEnum(str, Enum): + """ + Port-channel duplex mode options. + """ + + HALF = "half" # Half duplex mode + FULL = "full" # Full duplex mode + + +# ============================================================================ +# VPC ROLE AND STATUS ENUMS +# ============================================================================ + + +class VpcRoleEnum(str, Enum): + """ + VPC role designation for switches in a vPC pair. + """ + + PRIMARY = "primary" # Configured primary peer + SECONDARY = "secondary" # Configured secondary peer + OPERATIONAL_PRIMARY = "operationalPrimary" # Runtime primary role + OPERATIONAL_SECONDARY = "operationalSecondary" # Runtime secondary role + + +class MaintenanceModeEnum(str, Enum): + """ + Switch maintenance mode status. + """ + + MAINTENANCE = "maintenance" # Switch in maintenance mode + NORMAL = "normal" # Switch in normal operation + + +# ============================================================================ +# QUERY AND VIEW ENUMS +# ============================================================================ + + +class ComponentTypeOverviewEnum(str, Enum): + """ + VPC pair overview component types. + + Used for filtering overview endpoint responses. + """ + + FULL = "full" # Full overview with all components + HEALTH = "health" # Health status only + MODULE = "module" # Module information only + VXLAN = "vxlan" # VXLAN configuration only + OVERLAY = "overlay" # Overlay information only + PAIRS_INFO = "pairsInfo" # Pairs information only + INVENTORY = "inventory" # Inventory information only + ANOMALIES = "anomalies" # Anomalies information only + + +class ComponentTypeSupportEnum(str, Enum): + """ + VPC pair support check types. + + Used for validation endpoints. + """ + + CHECK_PAIRING = "checkPairing" # Check if pairing is allowed + CHECK_FABRIC_PEERING_SUPPORT = "checkFabricPeeringSupport" # Check fabric support + + +class VpcPairViewEnum(str, Enum): + """ + VPC pairs list view options. + + Controls which VPC pairs are returned in queries. + """ + + INTENDED_PAIRS = "intendedPairs" # Show intended VPC pairs + DISCOVERED_PAIRS = "discoveredPairs" # Show discovered VPC pairs (default) + + +# ============================================================================ +# API FIELD NAME CONSTANTS (Not Enums - Used as Dict Keys) +# ============================================================================ + + +class VpcFieldNames: + """ + API field name constants for VPC pair operations. + + These are string constants, not enums, because they're used as + dictionary keys in API payloads and responses. + + Centralized to: + - Eliminate magic strings + - Enable IDE autocomplete + - Prevent typos + - Easy refactoring + """ + + # VPC Action Discriminator Field + VPC_ACTION = "vpcAction" + + # Primary Identifier Fields (API format) + SWITCH_ID = "switchId" + PEER_SWITCH_ID = "peerSwitchId" + USE_VIRTUAL_PEER_LINK = "useVirtualPeerLink" + + # Ansible Playbook Fields (user input aliases) + ANSIBLE_PEER1_SWITCH_ID = "peer1SwitchId" + ANSIBLE_PEER2_SWITCH_ID = "peer2SwitchId" + + # Configuration Fields + VPC_PAIR_DETAILS = "vpcPairDetails" + DOMAIN_ID = "domainId" + SWITCH_NAME = "switchName" + PEER_SWITCH_NAME = "peerSwitchName" + TEMPLATE_NAME = "templateName" + TEMPLATE_TYPE = "type" + + # Status Fields (for query responses) + VPC_CONFIGURED = "vpcConfigured" + CONFIG_SYNC_STATUS = "configSyncStatus" + CURRENT_PEER = "currentPeer" + IS_CURRENT_PEER = "isCurrentPeer" + IS_CONSISTENT = "isConsistent" + IS_DISCOVERED = "isDiscovered" + + # Response Keys + VPC_PAIRS = "vpcPairs" + SWITCHES = "switches" + DATA = "data" + VPC_DATA = "vpcData" + + # Network Fields + FABRIC_MGMT_IP = "fabricManagementIp" + SERIAL_NUMBER = "serialNumber" + IP_ADDRESS = "ipAddress" + + # Validation Fields (for pre-deletion checks) + OVERLAY = "overlay" + INVENTORY = "inventory" + NETWORK_COUNT = "networkCount" + VRF_COUNT = "vrfCount" + VPC_INTERFACE_COUNT = "vpcInterfaceCount" + + # Template Detail Fields + KEEP_ALIVE_VRF = "keepAliveVrf" + PEER_KEEPALIVE_DEST = "peerKeepAliveDest" + PEER_GATEWAY_ENABLE = "peerGatewayEnable" + AUTO_RECOVERY_ENABLE = "autoRecoveryEnable" + DELAY_RESTORE = "delayRestore" + DELAY_RESTORE_TIME = "delayRestoreTime" + + # Port-Channel Fields + PO_MODE = "poMode" + PO_SPEED = "poSpeed" + PO_DESCRIPTION = "poDescription" + PO_DUPLEX = "poDuplex" + PO_MTU = "poMtu" diff --git a/plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py b/plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py new file mode 100644 index 00000000..ad53525f --- /dev/null +++ b/plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py @@ -0,0 +1,931 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Neil John (@neijohn) + +# 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 + +""" +Pydantic models for VPC pair management in Nexus Dashboard 4.x API. + +This module provides comprehensive models covering all 34 OpenAPI schemas +organized into functional domains: +- Configuration Domain: VPC pairing and lifecycle management +- Inventory Domain: VPC pair listing and discovery +- Monitoring Domain: Health, status, and operational metrics +- Consistency Domain: Configuration consistency validation +- Validation Domain: Support checks and peer recommendations +""" + +from abc import ABC, abstractmethod +from pydantic import BaseModel, ConfigDict, Field, BeforeValidator, field_validator, model_validator +from typing import List, Dict, Any, Optional, Union, Tuple, ClassVar, Literal, Annotated +from typing_extensions import Self + +# Import enums from centralized location +from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.enums import ( + VpcActionEnum, + VpcPairTypeEnum, + KeepAliveVrfEnum, + PoModeEnum, + PortChannelDuplexEnum, + VpcRoleEnum, + MaintenanceModeEnum, + ComponentTypeOverviewEnum, + ComponentTypeSupportEnum, + VpcPairViewEnum, + VpcFieldNames, +) + +# ============================================================================ +# TYPE COERCION HELPERS +# ============================================================================ + + +def coerce_str_to_int(data): + """Convert string to int, handle None.""" + if data is None: + return None + if isinstance(data, str): + if data.strip() and data.lstrip("-").isdigit(): + return int(data) + raise ValueError(f"Cannot convert '{data}' to int") + return int(data) + + +def coerce_to_bool(data): + """Convert various formats to bool.""" + if data is None: + return None + if isinstance(data, str): + return data.lower() in ("true", "1", "yes", "on") + return bool(data) + + +def coerce_list_of_str(data): + """Ensure data is a list of strings.""" + if data is None: + return None + if isinstance(data, str): + return [item.strip() for item in data.split(",") if item.strip()] + if isinstance(data, list): + return [str(item) for item in data] + return data + + +# Type aliases for flexible validation +FlexibleInt = Annotated[int, BeforeValidator(coerce_str_to_int)] +FlexibleBool = Annotated[bool, BeforeValidator(coerce_to_bool)] +FlexibleListStr = Annotated[List[str], BeforeValidator(coerce_list_of_str)] + + +# ============================================================================ +# BASE CLASSES +# ============================================================================ + + +class NDVpcPairBaseModel(BaseModel, ABC): + """ + Base model for VPC pair objects with identifiers. + + Similar to NDBaseModel from base.py but specific to VPC pair resources. + """ + + model_config = ConfigDict(str_strip_whitespace=True, use_enum_values=True, validate_assignment=True, populate_by_name=True, extra="ignore") + + # Subclasses MUST define these + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" + + # Optional: fields to exclude from diffs + exclude_from_diff: ClassVar[List[str]] = [] + + @abstractmethod + def to_payload(self) -> Dict[str, Any]: + """Convert model to API payload format.""" + pass + + @classmethod + @abstractmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create model instance from API response.""" + pass + + def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: + """ + Extract identifier value(s) from this instance. + + For VPC pairs, uses composite strategy with (switchId, peerSwitchId). + """ + if not self.identifiers: + raise ValueError(f"{self.__class__.__name__} has no identifiers defined") + + if self.identifier_strategy == "single": + value = getattr(self, self.identifiers[0], None) + if value is None: + raise ValueError(f"Single identifier field '{self.identifiers[0]}' is None") + return value + + elif self.identifier_strategy == "composite": + values = [] + 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. All required: {self.identifiers}") + + return tuple(values) + + elif self.identifier_strategy == "hierarchical": + for field in self.identifiers: + value = getattr(self, field, None) + if value is not None: + return (field, value) + + raise ValueError(f"No non-None value in hierarchical fields {self.identifiers}") + + else: + raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") + + def get_switch_pair_key(self) -> str: + """ + Generate a unique key for VPC pair (sorted switch IDs). + + Returns: + str: Unique identifier in format "switchId1-switchId2" + """ + if self.identifier_strategy != "composite" or len(self.identifiers) != 2: + raise ValueError("get_switch_pair_key only works with composite strategy and 2 identifiers") + + values = self.get_identifier_value() + sorted_ids = sorted([str(v) for v in values]) + return f"{sorted_ids[0]}-{sorted_ids[1]}" + + def to_diff_dict(self) -> Dict[str, Any]: + """Export for diff comparison (excludes sensitive fields).""" + return self.model_dump(by_alias=True, exclude_none=True, exclude=set(self.exclude_from_diff)) + + +class NDVpcPairNestedModel(BaseModel): + """ + Base for nested VPC pair models without identifiers. + + Similar to NDNestedModel from base.py. + """ + + model_config = ConfigDict(str_strip_whitespace=True, use_enum_values=True, validate_assignment=True, populate_by_name=True, extra="ignore") + + def to_payload(self) -> Dict[str, Any]: + """Convert model to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create model instance from API response.""" + return cls.model_validate(response) + + +# ============================================================================ +# NESTED MODELS (No Identifiers) +# ============================================================================ + + +class SwitchInfo(NDVpcPairNestedModel): + """Generic switch information for both peers.""" + + switch: str = Field(alias="switch", description="Switch value") + peer_switch: str = Field(alias="peerSwitch", description="Peer switch value") + + +class SwitchIntInfo(NDVpcPairNestedModel): + """Generic switch integer information for both peers.""" + + switch: FlexibleInt = Field(alias="switch", description="Switch value") + peer_switch: FlexibleInt = Field(alias="peerSwitch", description="Peer switch value") + + +class SwitchBoolInfo(NDVpcPairNestedModel): + """Generic switch boolean information for both peers.""" + + switch: FlexibleBool = Field(alias="switch", description="Switch value") + peer_switch: FlexibleBool = Field(alias="peerSwitch", description="Peer switch value") + + +class SyncCounts(NDVpcPairNestedModel): + """Sync status counts.""" + + in_sync: FlexibleInt = Field(default=0, alias="inSync", description="In-sync items") + pending: FlexibleInt = Field(default=0, alias="pending", description="Pending items") + out_of_sync: FlexibleInt = Field(default=0, alias="outOfSync", description="Out-of-sync items") + in_progress: FlexibleInt = Field(default=0, alias="inProgress", description="In-progress items") + + +class AnomaliesCount(NDVpcPairNestedModel): + """Anomaly counts by severity.""" + + critical: FlexibleInt = Field(default=0, alias="critical", description="Critical anomalies") + major: FlexibleInt = Field(default=0, alias="major", description="Major anomalies") + minor: FlexibleInt = Field(default=0, alias="minor", description="Minor anomalies") + warning: FlexibleInt = Field(default=0, alias="warning", description="Warning anomalies") + + +class HealthMetrics(NDVpcPairNestedModel): + """Health metrics for both switches.""" + + switch: str = Field(alias="switch", description="Switch health status") + peer_switch: str = Field(alias="peerSwitch", description="Peer switch health status") + + +class ResourceMetrics(NDVpcPairNestedModel): + """Resource utilization metrics.""" + + switch: FlexibleInt = Field(alias="switch", description="Switch metric value") + peer_switch: FlexibleInt = Field(alias="peerSwitch", description="Peer switch metric value") + + +class InterfaceStatusCounts(NDVpcPairNestedModel): + """Interface status counts.""" + + up: FlexibleInt = Field(alias="up", description="Interfaces in up state") + down: FlexibleInt = Field(alias="down", description="Interfaces in down state") + + +class LogicalInterfaceCounts(NDVpcPairNestedModel): + """Logical interface type counts.""" + + port_channel: FlexibleInt = Field(alias="portChannel", description="Port channel interfaces") + loopback: FlexibleInt = Field(alias="loopback", description="Loopback interfaces") + vpc: FlexibleInt = Field(alias="vPC", description="VPC interfaces") + vlan: FlexibleInt = Field(alias="vlan", description="VLAN interfaces") + nve: FlexibleInt = Field(alias="nve", description="NVE interfaces") + + +class ResponseCounts(NDVpcPairNestedModel): + """Response metadata counts.""" + + total: FlexibleInt = Field(alias="total", description="Total count") + remaining: FlexibleInt = Field(alias="remaining", description="Remaining count") + + +# ============================================================================ +# VPC PAIR DETAILS MODELS (Nested Template Configuration) +# ============================================================================ + + +class VpcPairDetailsDefault(NDVpcPairNestedModel): + """ + Default template VPC pair configuration. + + OpenAPI: vpcPairDetailsDefault + """ + + type: Literal["default"] = Field(default="default", alias="type", description="Template type") + domain_id: Optional[FlexibleInt] = Field(default=None, alias="domainId", description="VPC domain ID") + switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="switchKeepAliveLocalIp", description="Peer-1 keep-alive IP") + peer_switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="peerSwitchKeepAliveLocalIp", description="Peer-2 keep-alive IP") + keep_alive_vrf: Optional[KeepAliveVrfEnum] = Field(default=None, alias="keepAliveVrf", description="Keep-alive VRF") + keep_alive_hold_timeout: Optional[FlexibleInt] = Field(default=3, alias="keepAliveHoldTimeout", description="Keep-alive hold timeout") + enable_mirror_config: Optional[FlexibleBool] = Field(default=False, alias="enableMirrorConfig", description="Enable config mirroring") + is_vpc_plus: Optional[FlexibleBool] = Field(default=False, alias="isVpcPlus", description="VPC+ topology") + fabric_path_switch_id: Optional[FlexibleInt] = Field(default=None, alias="fabricPathSwitchId", description="FabricPath switch ID") + is_vteps: Optional[FlexibleBool] = Field(default=False, alias="isVteps", description="Configure NVE source loopback") + nve_interface: Optional[FlexibleInt] = Field(default=1, alias="nveInterface", description="NVE interface") + switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="switchSourceLoopback", description="Peer-1 source loopback") + peer_switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchSourceLoopback", description="Peer-2 source loopback") + switch_primary_ip: Optional[str] = Field(default=None, alias="switchPrimaryIp", description="Peer-1 primary IP") + peer_switch_primary_ip: Optional[str] = Field(default=None, alias="peerSwitchPrimaryIp", description="Peer-2 primary IP") + loopback_secondary_ip: Optional[str] = Field(default=None, alias="loopbackSecondaryIp", description="Secondary loopback IP") + switch_domain_config: Optional[str] = Field(default=None, alias="switchDomainConfig", description="Peer-1 domain config CLI") + peer_switch_domain_config: Optional[str] = Field(default=None, alias="peerSwitchDomainConfig", description="Peer-2 domain config CLI") + switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="switchPoId", description="Peer-1 port-channel ID") + peer_switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchPoId", description="Peer-2 port-channel ID") + switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="switchMemberInterfaces", description="Peer-1 member interfaces") + peer_switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="peerSwitchMemberInterfaces", description="Peer-2 member interfaces") + po_mode: Optional[str] = Field(default="active", alias="poMode", description="Port-channel mode") + switch_po_description: Optional[str] = Field(default=None, alias="switchPoDescription", description="Peer-1 port-channel description") + peer_switch_po_description: Optional[str] = Field(default=None, alias="peerSwitchPoDescription", description="Peer-2 port-channel description") + admin_state: Optional[FlexibleBool] = Field(default=True, alias="adminState", description="Admin state") + allowed_vlans: Optional[str] = Field(default="all", alias="allowedVlans", description="Allowed VLANs") + switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="switchNativeVlan", description="Peer-1 native VLAN") + peer_switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchNativeVlan", description="Peer-2 native VLAN") + switch_po_config: Optional[str] = Field(default=None, alias="switchPoConfig", description="Peer-1 port-channel freeform config") + peer_switch_po_config: Optional[str] = Field(default=None, alias="peerSwitchPoConfig", description="Peer-2 port-channel freeform config") + fabric_name: Optional[str] = Field(default=None, alias="fabricName", description="Fabric name") + + +class VpcPairDetailsCustom(NDVpcPairNestedModel): + """ + Custom template VPC pair configuration. + + OpenAPI: vpcPairDetailsCustom + """ + + type: Literal["custom"] = Field(default="custom", alias="type", description="Template type") + template_name: str = Field(alias="templateName", description="Name of the custom template") + template_config: Dict[str, Any] = Field(alias="templateConfig", description="Free-form configuration") + + +# ============================================================================ +# CONFIGURATION DOMAIN MODELS +# ============================================================================ + + +class VpcPairBase(NDVpcPairBaseModel): + """ + Base schema for VPC pairing with common properties. + + Identifier: (switch_id, peer_switch_id) - composite + OpenAPI: vpcPairBase + + Note: The nd_vpc_pair module uses a separate VpcPairModel class (not this one) because: + - Module needs use_virtual_peer_link=True as default (this uses False per API spec) + - Module uses NDBaseModel base class for framework integration + - Module needs strict bool types, this uses FlexibleBool for API flexibility + See plugins/modules/nd_vpc_pair.py VpcPairModel for the module-specific implementation. + """ + + # Identifier configuration + identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" + + # Fields with validation constraints + switch_id: str = Field( + alias="switchId", + description="Switch serial number (Peer-1)", + min_length=3, + max_length=64 + ) + peer_switch_id: str = Field( + alias="peerSwitchId", + description="Peer switch serial number (Peer-2)", + min_length=3, + max_length=64 + ) + use_virtual_peer_link: FlexibleBool = Field(default=False, alias="useVirtualPeerLink", description="Virtual peer link present") + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, discriminator="type", alias="vpcPairDetails", description="VPC pair configuration details" + ) + + @field_validator("switch_id", "peer_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Switch ID value + + Returns: + Stripped switch ID + + Raises: + ValueError: If switch ID is empty or whitespace + """ + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> Self: + """ + Ensure switch_id and peer_switch_id are different. + + Returns: + Validated model instance + + Raises: + ValueError: If switch_id equals peer_switch_id + """ + if self.switch_id == self.peer_switch_id: + raise ValueError( + f"switch_id and peer_switch_id must be different: {self.switch_id}" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create instance from API response.""" + return cls.model_validate(response) + + +class VpcPairingRequest(NDVpcPairBaseModel): + """ + Request schema for pairing VPC switches. + + Identifier: (switch_id, peer_switch_id) - composite + OpenAPI: vpcPairingRequest + """ + + # Identifier configuration + identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" + + # Fields with validation constraints + vpc_action: VpcActionEnum = Field(default=VpcActionEnum.PAIR, alias="vpcAction", description="Action to pair") + switch_id: str = Field( + alias="switchId", + description="Switch serial number (Peer-1)", + min_length=3, + max_length=64 + ) + peer_switch_id: str = Field( + alias="peerSwitchId", + description="Peer switch serial number (Peer-2)", + min_length=3, + max_length=64 + ) + use_virtual_peer_link: FlexibleBool = Field(default=False, alias="useVirtualPeerLink", description="Virtual peer link present") + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, discriminator="type", alias="vpcPairDetails", description="VPC pair configuration details" + ) + + @field_validator("switch_id", "peer_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Switch ID value + + Returns: + Stripped switch ID + + Raises: + ValueError: If switch ID is empty or whitespace + """ + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> Self: + """ + Ensure switch_id and peer_switch_id are different. + + Returns: + Validated model instance + + Raises: + ValueError: If switch_id equals peer_switch_id + """ + if self.switch_id == self.peer_switch_id: + raise ValueError( + f"switch_id and peer_switch_id must be different: {self.switch_id}" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create instance from API response.""" + return cls.model_validate(response) + + +class VpcUnpairingRequest(NDVpcPairBaseModel): + """ + Request schema for unpairing VPC switches. + + Identifier: N/A (no specific switch IDs in unpair request) + OpenAPI: vpcUnpairingRequest + """ + + # No identifiers for unpair request + identifiers: ClassVar[List[str]] = [] + + # Fields + vpc_action: VpcActionEnum = Field(default=VpcActionEnum.UNPAIR, alias="vpcAction", description="Action to unpair") + + def get_identifier_value(self) -> str: + """Override - unpair doesn't have identifiers.""" + return "unpair" + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create instance from API response.""" + return cls.model_validate(response) + + +# ============================================================================ +# MONITORING DOMAIN MODELS +# ============================================================================ + + +class VpcPairsInfoBase(NDVpcPairNestedModel): + """ + VPC pair information base. + + OpenAPI: vpcPairsInfoBase + """ + + switch_name: SwitchInfo = Field(alias="switchName", description="Switch name") + ip_address: SwitchInfo = Field(alias="ipAddress", description="IP address") + fabric_name: str = Field(alias="fabricName", description="Fabric name") + connectivity_status: SwitchInfo = Field(alias="connectivityStatus", description="Connectivity status") + maintenance_mode: SwitchInfo = Field(alias="maintenanceMode", description="Maintenance mode") + uptime: SwitchInfo = Field(alias="uptime", description="Switch uptime") + switch_id: SwitchInfo = Field(alias="switchId", description="Switch serial number") + model: SwitchInfo = Field(alias="model", description="Switch model") + switch_role: SwitchInfo = Field(alias="switchRole", description="Switch role") + is_consistent: SwitchBoolInfo = Field(alias="isConsistent", description="Consistency status") + domain_id: SwitchIntInfo = Field(alias="domainId", description="Domain ID") + platform_type: SwitchInfo = Field(alias="platformType", description="Platform type") + + +class VpcPairHealthBase(NDVpcPairNestedModel): + """ + VPC pair health information. + + OpenAPI: vpcPairHealthBase + """ + + switch_id: str = Field(alias="switchId", description="Switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer switch serial number") + health: HealthMetrics = Field(alias="health", description="Health status") + cpu: ResourceMetrics = Field(alias="cpu", description="CPU utilization") + memory: ResourceMetrics = Field(alias="memory", description="Memory utilization") + temperature: ResourceMetrics = Field(alias="temperature", description="Temperature in Celsius") + + +class VpcPairsVxlanBase(NDVpcPairNestedModel): + """ + VPC pairs VXLAN details. + + OpenAPI: vpcPairsVxlanBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + routing_loopback: SwitchInfo = Field(alias="routingLoopback", description="Routing loopback") + routing_loopback_status: SwitchInfo = Field(alias="routingLoopbackStatus", description="Routing loopback status") + routing_loopback_primary_ip: SwitchInfo = Field(alias="routingLoopbackPrimaryIp", description="Routing loopback primary IP") + routing_loopback_secondary_ip: Optional[SwitchInfo] = Field(default=None, alias="routingLoopbackSecondaryIp", description="Routing loopback secondary IP") + vtep_loopback: SwitchInfo = Field(alias="vtepLoopback", description="VTEP loopback") + vtep_loopback_status: SwitchInfo = Field(alias="vtepLoopbackStatus", description="VTEP loopback status") + vtep_loopback_primary_ip: SwitchInfo = Field(alias="vtepLoopbackPrimaryIp", description="VTEP loopback primary IP") + vtep_loopback_secondary_ip: Optional[SwitchInfo] = Field(default=None, alias="vtepLoopbackSecondaryIp", description="VTEP loopback secondary IP") + nve_interface: SwitchInfo = Field(alias="nveInterface", description="NVE interface") + nve_status: SwitchInfo = Field(alias="nveStatus", description="NVE status") + multisite_loopback: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopback", description="Multisite loopback") + multisite_loopback_status: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopbackStatus", description="Multisite loopback status") + multisite_loopback_primary_ip: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopbackPrimaryIp", description="Multisite loopback primary IP") + + +class VpcPairsOverlayBase(NDVpcPairNestedModel): + """ + VPC pairs overlay base. + + OpenAPI: vpcPairsOverlayBase + """ + + network_count: SyncCounts = Field(alias="networkCount", description="Network count") + vrf_count: SyncCounts = Field(alias="vrfCount", description="VRF count") + + +class VpcPairsInventoryBase(NDVpcPairNestedModel): + """ + VPC pair inventory base. + + OpenAPI: vpcPairsInventoryBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + admin_status: InterfaceStatusCounts = Field(alias="adminStatus", description="Admin status") + operational_status: InterfaceStatusCounts = Field(alias="operationalStatus", description="Operational status") + sync_status: Dict[str, FlexibleInt] = Field(alias="syncStatus", description="Sync status") + logical_interfaces: LogicalInterfaceCounts = Field(alias="logicalInterfaces", description="Logical interfaces") + + +class VpcPairsModuleBase(NDVpcPairNestedModel): + """ + VPC pair module base. + + OpenAPI: vpcPairsModuleBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + module_information: Dict[str, str] = Field(default_factory=dict, alias="moduleInformation", description="VPC pair module information") + fex_details: Dict[str, str] = Field(default_factory=dict, alias="fexDetails", description="Fex details name-value pair(s)") + + +class VpcPairAnomaliesBase(NDVpcPairNestedModel): + """ + VPC pair anomalies information. + + OpenAPI: vpcPairAnomaliesBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + anomalies_count: AnomaliesCount = Field(alias="anomaliesCount", description="Anomaly counts by severity") + + +# ============================================================================ +# CONSISTENCY DOMAIN MODELS +# ============================================================================ + + +class CommonVpcConsistencyParams(NDVpcPairNestedModel): + """ + Common consistency parameters for VPC domain. + + OpenAPI: commonVpcConsistencyParams + """ + + # Basic identifiers + switch_name: str = Field(alias="switchName", description="Switch name") + ip_address: str = Field(alias="ipAddress", description="IP address") + domain_id: FlexibleInt = Field(alias="domainId", description="Domain ID") + + # Port channel info + peer_link_port_channel: FlexibleInt = Field(alias="peerLinkPortChannel", description="Port channel peer link") + port_channel_name: Optional[str] = Field(default=None, alias="portChannelName", description="Port channel name") + description: Optional[str] = Field(default=None, alias="description", description="Port channel description") + + # VPC system parameters + system_mac_address: str = Field(alias="systemMacAddress", description="System MAC address") + system_priority: FlexibleInt = Field(alias="systemPriority", description="System priority") + udp_port: FlexibleInt = Field(alias="udpPort", description="UDP port") + interval: FlexibleInt = Field(alias="interval", description="Interval") + timeout: FlexibleInt = Field(alias="timeout", description="Timeout") + + # Additional fields (simplified - add as needed) + # NOTE: OpenAPI has many more fields - add them as required + + +class VpcPairConsistency(NDVpcPairNestedModel): + """ + VPC pair consistency check results. + + OpenAPI: vpcPairConsistency + """ + + switch_id: str = Field(alias="switchId", description="Primary switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Secondary switch serial number") + type2_consistency: FlexibleBool = Field(alias="type2Consistency", description="Type-2 consistency status") + type2_consistency_reason: str = Field(alias="type2ConsistencyReason", description="Consistency reason") + timestamp: Optional[FlexibleInt] = Field(default=None, alias="timestamp", description="Timestamp of check") + primary_parameters: CommonVpcConsistencyParams = Field(alias="primaryParameters", description="Primary switch consistency parameters") + secondary_parameters: CommonVpcConsistencyParams = Field(alias="secondaryParameters", description="Secondary switch consistency parameters") + is_consistent: Optional[FlexibleBool] = Field(default=None, alias="isConsistent", description="Overall consistency") + is_discovered: Optional[FlexibleBool] = Field(default=None, alias="isDiscovered", description="Whether pair is discovered") + + +# ============================================================================ +# VALIDATION DOMAIN MODELS +# ============================================================================ + + +class VpcPairRecommendation(NDVpcPairNestedModel): + """ + Recommendation information for a switch. + + OpenAPI: vpcPairRecommendation + """ + + hostname: str = Field(alias="hostname", description="Logical name of switch") + ip_address: str = Field(alias="ipAddress", description="IP address of switch") + switch_id: str = Field(alias="switchId", description="Serial number of the switch") + software_version: str = Field(alias="softwareVersion", description="NXOS version of switch") + fabric_name: str = Field(alias="fabricName", description="Fabric name") + recommendation_reason: str = Field(alias="recommendationReason", description="Recommendation message") + block_selection: FlexibleBool = Field(alias="blockSelection", description="Block selection") + platform_type: str = Field(alias="platformType", description="Platform type of switch") + use_virtual_peer_link: FlexibleBool = Field(alias="useVirtualPeerLink", description="Virtual peer link available") + is_current_peer: FlexibleBool = Field(alias="isCurrentPeer", description="Device is current peer") + is_recommended: FlexibleBool = Field(alias="isRecommended", description="Recommended device") + + +# ============================================================================ +# INVENTORY DOMAIN MODELS +# ============================================================================ + + +class VpcPairBaseSwitchDetails(NDVpcPairNestedModel): + """ + Base fields for VPC pair records. + + OpenAPI: vpcPairBaseSwitchDetails + """ + + domain_id: FlexibleInt = Field(alias="domainId", description="Domain ID of the VPC") + switch_id: str = Field(alias="switchId", description="Serial number of the switch") + switch_name: str = Field(alias="switchName", description="Hostname of the switch") + peer_switch_id: str = Field(alias="peerSwitchId", description="Serial number of the peer switch") + peer_switch_name: str = Field(alias="peerSwitchName", description="Hostname of the peer switch") + + +class VpcPairIntended(VpcPairBaseSwitchDetails): + """ + Intended VPC pair record. + + OpenAPI: vpcPairIntended + """ + + type: Literal["intendedPairs"] = Field(default="intendedPairs", alias="type", description="Type identifier") + + +class VpcPairDiscovered(VpcPairBaseSwitchDetails): + """ + Discovered VPC pair record. + + OpenAPI: vpcPairDiscovered + """ + + type: Literal["discoveredPairs"] = Field(default="discoveredPairs", alias="type", description="Type identifier") + switch_vpc_role: VpcRoleEnum = Field(alias="switchVpcRole", description="VPC role of the switch") + peer_switch_vpc_role: VpcRoleEnum = Field(alias="peerSwitchVpcRole", description="VPC role of the peer switch") + intended_peer_name: str = Field(alias="intendedPeerName", description="Name of the intended peer switch") + description: str = Field(alias="description", description="Description of any discrepancies or issues") + + +class Metadata(NDVpcPairNestedModel): + """ + Metadata for pagination and links. + + OpenAPI: Metadata + """ + + counts: ResponseCounts = Field(alias="counts", description="Count information") + links: Optional[Dict[str, str]] = Field(default=None, alias="links", description="Pagination links (next, previous)") + + +class VpcPairsResponse(NDVpcPairNestedModel): + """ + Response schema for listing VPC pairs. + + OpenAPI: vpcPairsResponse + """ + + vpc_pairs: List[Union[VpcPairIntended, VpcPairDiscovered]] = Field(alias="vpcPairs", description="List of VPC pairs") + meta: Metadata = Field(alias="meta", description="Response metadata") + + +# ============================================================================ +# WRAPPER MODELS WITH COMPONENT TYPE +# ============================================================================ + + +class VpcPairsInfo(NDVpcPairNestedModel): + """VPC pairs information wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.PAIRS_INFO, alias="componentType", description="Type of the component") + info: VpcPairsInfoBase = Field(alias="info", description="VPC pair info") + + +class VpcPairHealth(NDVpcPairNestedModel): + """VPC pair health wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.HEALTH, alias="componentType", description="Type of the component") + health: VpcPairHealthBase = Field(alias="health", description="Health details") + + +class VpcPairsModule(NDVpcPairNestedModel): + """VPC pairs module wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.MODULE, alias="componentType", description="Type of the component") + module: VpcPairsModuleBase = Field(alias="module", description="Module details") + + +class VpcPairAnomalies(NDVpcPairNestedModel): + """VPC pair anomalies wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.ANOMALIES, alias="componentType", description="Type of the component") + anomalies: VpcPairAnomaliesBase = Field(alias="anomalies", description="Anomalies details") + + +class VpcPairsVxlan(NDVpcPairNestedModel): + """VPC pairs VXLAN wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.VXLAN, alias="componentType", description="Type of the component") + vxlan: VpcPairsVxlanBase = Field(alias="vxlan", description="VXLAN details") + + +class VpcPairsOverlay(NDVpcPairNestedModel): + """VPC overlay details wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.OVERLAY, alias="componentType", description="Type of the component") + overlay: VpcPairsOverlayBase = Field(alias="overlay", description="Overlay details") + + +class VpcPairsInventory(NDVpcPairNestedModel): + """VPC pairs inventory details wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.INVENTORY, alias="componentType", description="Type of the component") + inventory: VpcPairsInventoryBase = Field(alias="inventory", description="Inventory details") + + +class FullOverview(NDVpcPairNestedModel): + """Full VPC overview response.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.FULL, alias="componentType", description="Type of the component") + anomalies: VpcPairAnomaliesBase = Field(alias="anomalies", description="VPC pair anomalies") + health: VpcPairHealthBase = Field(alias="health", description="VPC pair health") + module: VpcPairsModuleBase = Field(alias="module", description="VPC pair module") + vxlan: VpcPairsVxlanBase = Field(alias="vxlan", description="VPC pair VXLAN") + overlay: VpcPairsOverlayBase = Field(alias="overlay", description="VPC pair overlay") + pairs_info: VpcPairsInfoBase = Field(alias="pairsInfo", description="VPC pair info") + inventory: VpcPairsInventoryBase = Field(alias="inventory", description="VPC pair inventory") + + +# ============================================================================ +# BACKWARD COMPATIBILITY CONTAINER (NdVpcPairSchema) +# ============================================================================ + + +class NdVpcPairSchema: + """ + Backward compatibility container for all VPC pair schemas. + + This provides a namespace similar to the old structure where models + were nested inside a container class. Allows imports like: + + from model_playbook_vpc_pair_nested import NdVpcPairSchema + vpc_pair = NdVpcPairSchema.VpcPairBase(**data) + """ + + # Base classes + VpcPairBaseModel = NDVpcPairBaseModel + VpcPairNestedModel = NDVpcPairNestedModel + + # Enumerations (these are class variable type hints, not assignments) + # VpcRole = VpcRoleEnum # Commented out - not needed + # TemplateType = VpcPairTypeEnum # Commented out - not needed + # KeepAliveVrf = KeepAliveVrfEnum # Commented out - not needed + # VpcAction = VpcActionEnum # Commented out - not needed + # ComponentType = ComponentTypeOverviewEnum # Commented out - not needed + + # Nested helper models + SwitchInfo = SwitchInfo + SwitchIntInfo = SwitchIntInfo + SwitchBoolInfo = SwitchBoolInfo + SyncCounts = SyncCounts + AnomaliesCount = AnomaliesCount + HealthMetrics = HealthMetrics + ResourceMetrics = ResourceMetrics + InterfaceStatusCounts = InterfaceStatusCounts + LogicalInterfaceCounts = LogicalInterfaceCounts + ResponseCounts = ResponseCounts + + # VPC pair details (template configuration) + VpcPairDetailsDefault = VpcPairDetailsDefault + VpcPairDetailsCustom = VpcPairDetailsCustom + + # Configuration domain + VpcPairBase = VpcPairBase + VpcPairingRequest = VpcPairingRequest + VpcUnpairingRequest = VpcUnpairingRequest + + # Monitoring domain + VpcPairsInfoBase = VpcPairsInfoBase + VpcPairHealthBase = VpcPairHealthBase + VpcPairsVxlanBase = VpcPairsVxlanBase + VpcPairsOverlayBase = VpcPairsOverlayBase + VpcPairsInventoryBase = VpcPairsInventoryBase + VpcPairsModuleBase = VpcPairsModuleBase + VpcPairAnomaliesBase = VpcPairAnomaliesBase + + # Monitoring domain wrappers + VpcPairsInfo = VpcPairsInfo + VpcPairHealth = VpcPairHealth + VpcPairsModule = VpcPairsModule + VpcPairAnomalies = VpcPairAnomalies + VpcPairsVxlan = VpcPairsVxlan + VpcPairsOverlay = VpcPairsOverlay + VpcPairsInventory = VpcPairsInventory + FullOverview = FullOverview + + # Consistency domain + CommonVpcConsistencyParams = CommonVpcConsistencyParams + VpcPairConsistency = VpcPairConsistency + + # Validation domain + VpcPairRecommendation = VpcPairRecommendation + + # Inventory domain + VpcPairBaseSwitchDetails = VpcPairBaseSwitchDetails + VpcPairIntended = VpcPairIntended + VpcPairDiscovered = VpcPairDiscovered + Metadata = Metadata + VpcPairsResponse = VpcPairsResponse diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py b/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py new file mode 100644 index 00000000..5a8c1b05 --- /dev/null +++ b/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py @@ -0,0 +1,441 @@ +# Copyright (c) 2026 Cisco and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +VPC Pair endpoint models. + +This module contains endpoint definitions for VPC pair management operations +in the ND Manage API. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__author__ = "Sivakami Sivaraman" + +from typing import TYPE_CHECKING, Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.base_paths import VpcPairBasePath +from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.endpoint_mixins import ( + ComponentTypeMixin, + FabricNameMixin, + FilterMixin, + FromClusterMixin, + PaginationMixin, + SortMixin, + SwitchIdMixin, + TicketIdMixin, + ViewMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import BaseModel, ConfigDict, Field + +# Common config for basic validation +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +# ============================================================================ +# VPC Pair Details Endpoints (/vpcPair) +# ============================================================================ + + +class _EpVpcPairBase(FabricNameMixin, SwitchIdMixin, FromClusterMixin, BaseModel): + """ + Base class for VPC pair details endpoints. + + Provides common functionality for all HTTP methods on the + /fabrics/{fabricName}/switches/{switchId}/vpcPair endpoint. + """ + + model_config = COMMON_CONFIG + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Returns + + - Complete endpoint path string + """ + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + return VpcPairBasePath.vpc_pair(self.fabric_name, self.switch_id) + + +class EpVpcPairGet(_EpVpcPairBase): + """ + # Summary + + VPC Pair Details GET Endpoint + + ## Description + + Endpoint to retrieve VPC pair details for a specific switch. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + + ## Verb + + - GET + + ## Usage + + ```python + # Get VPC pair details + endpoint = EpVpcPairGet() + endpoint.fabric_name = "Fabric1" + endpoint.switch_id = "FDO23040Q85" + endpoint.from_cluster = "cluster1" # Optional + path = endpoint.path + verb = endpoint.verb + ``` + """ + + class_name: Literal["EpVpcPairGet"] = Field(default="EpVpcPairGet", description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class EpVpcPairPut(_EpVpcPairBase, TicketIdMixin): + """ + # Summary + + VPC Pair Management PUT Endpoint + + ## Description + + Endpoint to manage (create, update, delete) VPC pair configuration. + Use vpcAction="pair" for pairing and vpcAction="unPair" for unpairing. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + + ## Verb + + - PUT + + ## Usage + + ```python + # Manage VPC pair + endpoint = EpVpcPairPut() + endpoint.fabric_name = "Fabric1" + endpoint.switch_id = "FDO23040Q85" + endpoint.ticket_id = "CHG001" # Optional + path = endpoint.path + verb = endpoint.verb + ``` + """ + + class_name: Literal["EpVpcPairPut"] = Field(default="EpVpcPairPut", description="Class name for backward compatibility") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.PUT + + +# ============================================================================ +# VPC Pair Support Endpoints (/vpcPairSupport) +# ============================================================================ + + +class EpVpcPairSupportGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, ComponentTypeMixin, BaseModel): + """ + # Summary + + VPC Pair Support Check GET Endpoint + + ## Description + + Endpoint to check VPC pairing support and validation details. + Supports componentType="checkPairing" or "checkFabricPeeringSupport". + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport + + ## Verb + + - GET + + ## Gathered Parameters + + - componentType: Required. Values: "checkPairing", "checkFabricPeeringSupport" + + ## Usage + + ```python + # Check if pairing is allowed + endpoint = EpVpcPairSupportGet() + endpoint.fabric_name = "Fabric1" + endpoint.switch_id = "FDO23040Q85" + endpoint.component_type = "checkPairing" + path = endpoint.path + verb = endpoint.verb + ``` + """ + + model_config = COMMON_CONFIG + class_name: Literal["EpVpcPairSupportGet"] = Field(default="EpVpcPairSupportGet", description="Class name for backward compatibility") + + @property + def path(self) -> str: + """Build the endpoint path.""" + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + return VpcPairBasePath.vpc_pair_support(self.fabric_name, self.switch_id) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +# ============================================================================ +# VPC Pair Overview Endpoints (/vpcPairOverview) +# ============================================================================ + + +class EpVpcPairOverviewGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, ComponentTypeMixin, BaseModel): + """ + # Summary + + VPC Pair Overview GET Endpoint + + ## Description + + Endpoint to retrieve VPC pair overview details with various component types. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview + + ## Verb + + - GET + + ## Gathered Parameters + + - componentType: Required. Values: "full", "health", "module", "vxlan", + "overlay", "pairsInfo", "inventory", "anomalies" + + ## Usage + + ```python + # Get full overview + endpoint = EpVpcPairOverviewGet() + endpoint.fabric_name = "Fabric1" + endpoint.switch_id = "FDO23040Q85" + endpoint.component_type = "full" + path = endpoint.path + verb = endpoint.verb + ``` + """ + + model_config = COMMON_CONFIG + class_name: Literal["EpVpcPairOverviewGet"] = Field(default="EpVpcPairOverviewGet", description="Class name for backward compatibility") + + @property + def path(self) -> str: + """Build the endpoint path.""" + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + return VpcPairBasePath.vpc_pair_overview(self.fabric_name, self.switch_id) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +# ============================================================================ +# VPC Pair Recommendation Endpoints (/vpcPairRecommendation) +# ============================================================================ + + +class EpVpcPairRecommendationGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, BaseModel): + """ + # Summary + + VPC Pair Recommendation GET Endpoint + + ## Description + + Endpoint to get recommendations for VPC pairing with available devices. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation + + ## Verb + + - GET + + ## Gathered Parameters + + - useVirtualPeerLink: Optional boolean + + ## Usage + + ```python + # Get pairing recommendations + endpoint = EpVpcPairRecommendationGet() + endpoint.fabric_name = "Fabric1" + endpoint.switch_id = "FDO23040Q85" + endpoint.use_virtual_peer_link = True + path = endpoint.path + verb = endpoint.verb + ``` + """ + + model_config = COMMON_CONFIG + class_name: Literal["EpVpcPairRecommendationGet"] = Field(default="EpVpcPairRecommendationGet", description="Class name for backward compatibility") + + use_virtual_peer_link: Optional[bool] = Field(default=None, description="Virtual peer link available") + + @property + def path(self) -> str: + """Build the endpoint path.""" + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + return VpcPairBasePath.vpc_pair_recommendation(self.fabric_name, self.switch_id) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +# ============================================================================ +# VPC Pair Consistency Endpoints (/vpcPairConsistency) +# ============================================================================ + + +class EpVpcPairConsistencyGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, BaseModel): + """ + # Summary + + VPC Pair Consistency GET Endpoint + + ## Description + + Endpoint to retrieve VPC pair consistency details between peers. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency + + ## Verb + + - GET + + ## Usage + + ```python + # Get consistency details + endpoint = EpVpcPairConsistencyGet() + endpoint.fabric_name = "Fabric1" + endpoint.switch_id = "FDO23040Q85" + path = endpoint.path + verb = endpoint.verb + ``` + """ + + model_config = COMMON_CONFIG + class_name: Literal["EpVpcPairConsistencyGet"] = Field(default="EpVpcPairConsistencyGet", description="Class name for backward compatibility") + + @property + def path(self) -> str: + """Build the endpoint path.""" + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + return VpcPairBasePath.vpc_pair_consistency(self.fabric_name, self.switch_id) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +# ============================================================================ +# VPC Pairs List Endpoints (/vpcPairs) +# ============================================================================ + + +class EpVpcPairsListGet(FabricNameMixin, FromClusterMixin, FilterMixin, PaginationMixin, SortMixin, ViewMixin, BaseModel): + """ + # Summary + + VPC Pairs List GET Endpoint + + ## Description + + Endpoint to list all VPC pairs for a specific fabric. + Supports filtering, pagination, and sorting. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/vpcPairs + + ## Verb + + - GET + + ## Gathered Parameters + + - filter: Optional filter expression + - max: Optional maximum number of results + - offset: Optional offset for pagination + - sort: Optional sort field + - view: Optional. Values: "intendedPairs" + + ## Usage + + ```python + # List VPC pairs with filtering + endpoint = EpVpcPairsListGet() + endpoint.fabric_name = "Fabric1" + endpoint.filter = "domainId:10" + endpoint.max = 50 + endpoint.offset = 0 + endpoint.sort = "switchName:asc" + endpoint.view = "intendedPairs" + path = endpoint.path + verb = endpoint.verb + ``` + """ + + model_config = COMMON_CONFIG + class_name: Literal["EpVpcPairsListGet"] = Field(default="EpVpcPairsListGet", description="Class name for backward compatibility") + + @property + def path(self) -> str: + """Build the endpoint path.""" + if self.fabric_name is None: + raise ValueError("fabric_name is required") + return VpcPairBasePath.vpc_pairs_list(self.fabric_name) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py b/plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py new file mode 100644 index 00000000..25f95795 --- /dev/null +++ b/plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import importlib +import sys +import types as py_types +from typing import Any, Callable, Dict, List, Optional + +from ansible.module_utils.basic import AnsibleModule +from pydantic import ValidationError + + +def register_nd_state_machine_import_aliases() -> None: + """ + Register compatibility aliases required by nd_state_machine flat imports. + """ + try: + nd_module = importlib.import_module( + "ansible_collections.cisco.nd.plugins.module_utils.nd" + ) + constants_module = importlib.import_module( + "ansible_collections.cisco.nd.plugins.module_utils.constants" + ) + nd_config_collection_module = importlib.import_module( + "ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection" + ) + models_base_module = importlib.import_module( + "ansible_collections.cisco.nd.plugins.module_utils.models.base" + ) + except Exception: + # Keep vpc_pair files importable even when state-machine framework files + # are intentionally not present in this branch. + return + + sys.modules.setdefault("nd", nd_module) + sys.modules.setdefault("constants", constants_module) + sys.modules.setdefault("nd_config_collection", nd_config_collection_module) + + # Keep compatibility scoped to vpc_pair runtime: NDStateMachine expects + # NDConfigCollection.to_list(), while PR172 exposes to_ansible_config(). + nd_config_collection_cls = getattr( + nd_config_collection_module, "NDConfigCollection", None + ) + if ( + nd_config_collection_cls is not None + and not hasattr(nd_config_collection_cls, "to_list") + ): + def _to_list(self, **kwargs): + return self.to_ansible_config(**kwargs) + + setattr(nd_config_collection_cls, "to_list", _to_list) + + models_pkg = sys.modules.get("models") + if models_pkg is None: + models_pkg = py_types.ModuleType("models") + models_pkg.__path__ = [] + sys.modules["models"] = models_pkg + + setattr(models_pkg, "base", models_base_module) + sys.modules.setdefault("models.base", models_base_module) + + # nd_state_machine imports orchestrators.base for typing. Prefer the real + # module and only install a shim if importing it fails in this environment. + orchestrators_pkg_name = ( + "ansible_collections.cisco.nd.plugins.module_utils.orchestrators" + ) + orchestrator_base_name = f"{orchestrators_pkg_name}.base" + if orchestrator_base_name not in sys.modules: + try: + importlib.import_module(orchestrator_base_name) + except Exception: + orchestrators_pkg = sys.modules.get(orchestrators_pkg_name) + if orchestrators_pkg is None: + orchestrators_pkg = py_types.ModuleType(orchestrators_pkg_name) + orchestrators_pkg.__path__ = [] + sys.modules[orchestrators_pkg_name] = orchestrators_pkg + + orchestrator_base_module = py_types.ModuleType(orchestrator_base_name) + + class NDBaseOrchestrator: # pragma: no cover - import shim + pass + + orchestrator_base_module.NDBaseOrchestrator = NDBaseOrchestrator + setattr(orchestrators_pkg, "base", orchestrator_base_module) + sys.modules[orchestrator_base_name] = orchestrator_base_module + + +register_nd_state_machine_import_aliases() + +HAS_ND_STATE_MACHINE = True +try: + from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine +except Exception: + HAS_ND_STATE_MACHINE = False + + class NDStateMachine: # pragma: no cover - compatibility shim + def __init__(self, *args, **kwargs): + _ = (args, kwargs) + + +ActionHandler = Callable[[Any], Any] +RunStateHandler = Callable[[Any], Dict[str, Any]] +DeployHandler = Callable[[Any, str, Dict[str, Any]], Dict[str, Any]] +NeedsDeployHandler = Callable[[Dict[str, Any], Any], bool] + + +class _VpcPairQueryContext: + """Minimal context object for query_all during NDStateMachine initialization.""" + + def __init__(self, module: AnsibleModule): + self.module = module + + +class VpcPairOrchestrator: + """ + VPC orchestrator implementation for NDStateMachine. + + Delegates CRUD operations to injected vPC action handlers. + """ + + model_class = None + + def __init__(self, module: AnsibleModule): + self.module = module + self.state_machine = None + + self.model_class = getattr(self.module, "_vpc_pair_model_class", None) + self.actions = getattr(self.module, "_vpc_pair_actions", {}) + + if self.model_class is None: + raise ValueError("Missing _vpc_pair_model_class in module params") + required_actions = {"query_all", "create", "update", "delete"} + if not required_actions.issubset(set(self.actions)): + raise ValueError( + "Missing required _vpc_pair_actions. Required keys: " + "query_all, create, update, delete" + ) + + def bind_state_machine(self, state_machine: "VpcPairStateMachine") -> None: + self.state_machine = state_machine + + def query_all(self): + context = self.state_machine if self.state_machine is not None else _VpcPairQueryContext(self.module) + return self.actions["query_all"](context) + + def create(self, model_instance, **kwargs): + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return self.actions["create"](self.state_machine) + + def update(self, model_instance, **kwargs): + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return self.actions["update"](self.state_machine) + + def delete(self, model_instance, **kwargs): + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return self.actions["delete"](self.state_machine) + + +class VpcPairStateMachine(NDStateMachine): + """NDStateMachine adapter with state handling compatible with nd_vpc_pair.""" + + def __init__(self, module: AnsibleModule): + super().__init__(module=module, model_orchestrator=VpcPairOrchestrator) + self.model_orchestrator.bind_state_machine(self) + self.current_identifier = None + self.existing_config: Dict[str, Any] = {} + self.proposed_config: Dict[str, Any] = {} + + def manage_state( + self, + state: str, + new_configs: List[Dict[str, Any]], + unwanted_keys: Optional[List] = None, + override_exceptions: Optional[List] = None, + ) -> None: + unwanted_keys = unwanted_keys or [] + override_exceptions = override_exceptions or [] + + self.state = state + self.params["state"] = state + self.ansible_config = new_configs or [] + + try: + parsed_items = [] + for config in self.ansible_config: + try: + parsed_items.append(self.model_class.model_validate(config)) + except ValidationError as e: + self.fail_json( + msg=f"Invalid configuration: {e}", + config=config, + validation_errors=e.errors(), + ) + return + + self.proposed = self.nd_config_collection(model_class=self.model_class, items=parsed_items) + self.previous = self.existing.copy() + except Exception as e: + self.fail_json(msg=f"Failed to prepare configurations: {e}", error=str(e)) + return + + if state in ["merged", "replaced", "overridden"]: + self._manage_create_update_state(state, unwanted_keys) + if state == "overridden": + self._manage_override_deletions(override_exceptions) + elif state == "deleted": + self._manage_delete_state() + else: + self.fail_json(msg=f"Invalid state: {state}") + + def _manage_create_update_state(self, state: str, unwanted_keys: List) -> None: + for proposed_item in self.proposed: + identifier = proposed_item.get_identifier_value() + try: + self.current_identifier = identifier + + existing_item = self.existing.get(identifier) + self.existing_config = ( + existing_item.model_dump(by_alias=True, exclude_none=True) + if existing_item + else {} + ) + + try: + diff_status = self.existing.get_diff_config( + proposed_item, unwanted_keys=unwanted_keys + ) + except TypeError: + diff_status = self.existing.get_diff_config(proposed_item) + + if diff_status == "no_diff": + self.format_log( + identifier=identifier, + status="no_change", + after_data=self.existing_config, + ) + continue + + if state == "merged" and existing_item: + final_item = self.existing.merge(proposed_item) + else: + if existing_item: + self.existing.replace(proposed_item) + else: + self.existing.add(proposed_item) + final_item = proposed_item + + self.proposed_config = final_item.to_payload() + + if diff_status == "changed": + response = self.model_orchestrator.update(final_item) + operation_status = "updated" + else: + response = self.model_orchestrator.create(final_item) + operation_status = "created" + + if not self.module.check_mode: + self.sent.add(final_item) + sent_payload = self.proposed_config + else: + sent_payload = None + + self.format_log( + identifier=identifier, + status=operation_status, + after_data=( + response + if not self.module.check_mode + else final_item.model_dump(by_alias=True, exclude_none=True) + ), + sent_payload_data=sent_payload, + ) + except Exception as e: + error_msg = f"Failed to process {identifier}: {e}" + self.format_log( + identifier=identifier, + status="no_change", + after_data=self.existing_config, + ) + if not self.module.params.get("ignore_errors", False): + self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) + return + + def _manage_override_deletions(self, override_exceptions: List) -> None: + diff_identifiers = self.previous.get_diff_identifiers(self.proposed) + for identifier in diff_identifiers: + if identifier in override_exceptions: + continue + + try: + self.current_identifier = identifier + existing_item = self.existing.get(identifier) + if not existing_item: + continue + self.existing_config = existing_item.model_dump( + by_alias=True, exclude_none=True + ) + self.model_orchestrator.delete(existing_item) + self.existing.delete(identifier) + self.format_log(identifier=identifier, status="deleted", after_data={}) + except Exception as e: + error_msg = f"Failed to delete {identifier}: {e}" + if not self.module.params.get("ignore_errors", False): + self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) + return + + def _manage_delete_state(self) -> None: + for proposed_item in self.proposed: + identifier = proposed_item.get_identifier_value() + try: + self.current_identifier = identifier + existing_item = self.existing.get(identifier) + if not existing_item: + self.format_log(identifier=identifier, status="no_change", after_data={}) + continue + + self.existing_config = existing_item.model_dump( + by_alias=True, exclude_none=True + ) + self.model_orchestrator.delete(existing_item) + self.existing.delete(identifier) + self.format_log(identifier=identifier, status="deleted", after_data={}) + except Exception as e: + error_msg = f"Failed to delete {identifier}: {e}" + if not self.module.params.get("ignore_errors", False): + self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) + return + + +class VpcPairResourceService: + """ + Runtime service for nd_vpc_pair execution flow. + + Orchestrates state management and optional deployment while keeping module + entrypoint thin. + """ + + def __init__( + self, + module: AnsibleModule, + model_class: Any, + actions: Dict[str, ActionHandler], + run_state_handler: RunStateHandler, + deploy_handler: DeployHandler, + needs_deployment_handler: NeedsDeployHandler, + ): + self.module = module + self.model_class = model_class + self.actions = actions + self.run_state_handler = run_state_handler + self.deploy_handler = deploy_handler + self.needs_deployment_handler = needs_deployment_handler + + def _prime_runtime_context(self) -> None: + required_actions = {"query_all", "create", "update", "delete"} + if not required_actions.issubset(set(self.actions)): + raise ValueError( + "Invalid vPC action map. Required keys: query_all, create, update, delete" + ) + # Store runtime objects on module attributes (not params) to avoid + # JSON-serialization issues in httpapi connection parameter handling. + self.module._vpc_pair_model_class = self.model_class + self.module._vpc_pair_actions = self.actions + + def execute(self, fabric_name: str) -> Dict[str, Any]: + if not HAS_ND_STATE_MACHINE: + raise RuntimeError( + "nd_vpc_pair requires nd_state_machine framework files, " + "which are not present in this branch." + ) + self._prime_runtime_context() + nd_vpc_pair = VpcPairStateMachine(module=self.module) + result = self.run_state_handler(nd_vpc_pair) + + if "_ip_to_sn_mapping" in self.module.params: + result["ip_to_sn_mapping"] = self.module.params["_ip_to_sn_mapping"] + + deploy = self.module.params.get("deploy", False) + if deploy and not self.module.check_mode: + deploy_result = self.deploy_handler(nd_vpc_pair, fabric_name, result) + result["deployment"] = deploy_result + result["deployment_needed"] = deploy_result.get( + "deployment_needed", + self.needs_deployment_handler(result, nd_vpc_pair), + ) + + return result diff --git a/plugins/modules/nd_vpc_pair.py b/plugins/modules/nd_vpc_pair.py new file mode 100644 index 00000000..2a6a7945 --- /dev/null +++ b/plugins/modules/nd_vpc_pair.py @@ -0,0 +1,2778 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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 +__copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." +__author__ = "Sivakami S" + +DOCUMENTATION = """ +--- +module: nd_vpc_pair +short_description: Manage vPC pairs in Nexus devices. +version_added: "1.0.0" +description: +- Create, update, delete, override, and gather vPC pairs on Nexus devices. +- Uses NDStateMachine framework with a vPC orchestrator. +- Integrates RestSend for battle-tested HTTP handling with retry logic. +- Handles VPC API quirks via custom orchestrator action handlers. +options: + state: + choices: + - merged + - replaced + - deleted + - overridden + - gathered + default: merged + description: + - The state of the vPC pair configuration after module completion. + - C(gathered) is the query/read-only mode for this module. + type: str + fabric_name: + description: + - Name of the fabric. + required: true + type: str + deploy: + description: + - Deploy configuration changes after applying them. + - Saves fabric configuration and triggers deployment. + type: bool + default: false + dry_run: + description: + - Show what changes would be made without executing them. + - Maps to Ansible check_mode internally. + type: bool + default: false + force: + description: + - Force deletion without pre-deletion validation checks. + - 'WARNING: Bypasses safety checks for networks, VRFs, and vPC interfaces.' + - Use only when validation API timeouts or you are certain deletion is safe. + - Only applies to deleted state. + type: bool + default: false + api_timeout: + description: + - API request timeout in seconds for primary operations (create, update, delete). + - Increase for large fabrics or slow networks. + type: int + default: 30 + query_timeout: + description: + - API request timeout in seconds for query and recommendation operations. + - Lower timeout for non-critical queries to avoid port exhaustion. + type: int + default: 10 + config: + description: + - List of vPC pair configuration dictionaries. + type: list + elements: dict + suboptions: + peer1_switch_id: + description: + - Peer1 switch serial number for the vPC pair. + required: true + type: str + peer2_switch_id: + description: + - Peer2 switch serial number for the vPC pair. + required: true + type: str + use_virtual_peer_link: + description: + - Enable virtual peer link for the vPC pair. + type: bool + default: true +notes: + - This module uses NDStateMachine framework for state management + - RestSend provides protocol-based HTTP abstraction with automatic retry logic + - Results are aggregated using the Results class for consistent output format + - Check mode is fully supported via both framework and RestSend +""" + +EXAMPLES = """ +# Create a new vPC pair +- name: Create vPC pair + cisco.nd.nd_vpc_pair: + fabric_name: myFabric + state: merged + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" + use_virtual_peer_link: true + +# Delete a vPC pair +- name: Delete vPC pair + cisco.nd.nd_vpc_pair: + fabric_name: myFabric + state: deleted + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" + +# Gather existing vPC pairs +- name: Gather all vPC pairs + cisco.nd.nd_vpc_pair: + fabric_name: myFabric + state: gathered + +# Create and deploy +- name: Create vPC pair and deploy + cisco.nd.nd_vpc_pair: + fabric_name: myFabric + state: merged + deploy: true + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" + +# Dry run to see what would change +- name: Dry run vPC pair creation + cisco.nd.nd_vpc_pair: + fabric_name: myFabric + state: merged + dry_run: true + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" +""" + +RETURN = """ +changed: + description: Whether the module made any changes + type: bool + returned: always + sample: true +before: + description: vPC pair state before changes + type: list + returned: always + sample: [{"switchId": "FDO123", "peerSwitchId": "FDO456", "useVirtualPeerLink": false}] +after: + description: vPC pair state after changes + type: list + returned: always + sample: [{"switchId": "FDO123", "peerSwitchId": "FDO456", "useVirtualPeerLink": true}] +gathered: + description: Current vPC pairs (gathered state only) + type: dict + returned: when state is gathered + contains: + vpc_pairs: + description: List of configured VPC pairs + type: list + pending_create_vpc_pairs: + description: VPC pairs ready to be created (switches are paired but VPC not configured) + type: list + pending_delete_vpc_pairs: + description: VPC pairs in transitional delete state + type: list + sample: + vpc_pairs: [{"switchId": "FDO123", "peerSwitchId": "FDO456"}] + pending_create_vpc_pairs: [] + pending_delete_vpc_pairs: [] +response: + description: List of all API responses + type: list + returned: always + sample: [{"RETURN_CODE": 200, "METHOD": "PUT", "MESSAGE": "Success"}] +result: + description: List of all operation results + type: list + returned: always + sample: [{"success": true, "changed": true}] +diff: + description: List of all changes made, organized by operation + type: list + returned: always + contains: + operation: + description: Type of operation (POST/PUT/DELETE) + type: str + vpc_pair_key: + description: Identifier for the VPC pair (switchId-peerSwitchId) + type: str + path: + description: API endpoint path used + type: str + payload: + description: Request payload sent to API + type: dict + sample: [{"operation": "PUT", "vpc_pair_key": "FDO123-FDO456", "path": "/api/v1/...", "payload": {}}] +metadata: + description: Operation metadata with sequence and identifiers + type: dict + returned: when operations are performed + contains: + vpc_pair_key: + description: VPC pair identifier + type: str + operation: + description: Operation type (create/update/delete) + type: str + sequence_number: + description: Operation sequence in batch + type: int + sample: {"vpc_pair_key": "FDO123-FDO456", "operation": "create", "sequence_number": 1} +warnings: + description: List of warning messages from validation or operations + type: list + returned: when warnings occur + sample: ["VPC pair has 2 vPC interfaces - deletion may require manual cleanup"] +failed: + description: Whether any operation failed + type: bool + returned: when operations fail + sample: false +ip_to_sn_mapping: + description: Mapping of switch IP addresses to serial numbers + type: dict + returned: when available from fabric inventory + sample: {"10.1.1.1": "FDO123", "10.1.1.2": "FDO456"} +deployment: + description: Deployment operation results (when deploy=true) + type: dict + returned: when deploy parameter is true + contains: + deployment_needed: + description: Whether deployment was needed based on changes + type: bool + changed: + description: Whether deployment made changes + type: bool + response: + description: List of deployment API responses (save and deploy) + type: list + sample: {"deployment_needed": true, "changed": true, "response": [...]} +deployment_needed: + description: Flag indicating if deployment was needed + type: bool + returned: when deploy=true + sample: true +pending_create_pairs_not_in_delete: + description: VPC pairs in pending create state not included in delete wants (deleted state only) + type: list + returned: when state is deleted and pending create pairs exist + sample: [{"switchId": "FDO789", "peerSwitchId": "FDO012"}] +pending_delete_pairs_not_in_delete: + description: VPC pairs in pending delete state not included in delete wants (deleted state only) + type: list + returned: when state is deleted and pending delete pairs exist + sample: [] +""" + +import json +import logging +import sys +import traceback +from typing import Any, Dict, List, Optional, Union + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib + +# Service layer imports +from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_resources import ( + VpcPairResourceService, +) + +# Static imports so Ansible's AnsiballZ packager includes these files in the +# module zip. Keep them optional when framework files are intentionally absent. +try: + from ansible_collections.cisco.nd.plugins.module_utils import nd_config_collection as _nd_config_collection # noqa: F401 + from ansible_collections.cisco.nd.plugins.module_utils import utils as _nd_utils # noqa: F401 +except Exception: # pragma: no cover - compatibility for stripped framework trees + _nd_config_collection = None # noqa: F841 + _nd_utils = None # noqa: F841 + +try: + # pre-PR172 layout + from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDNestedModel +except Exception: + try: + # PR172 layout + from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel + except Exception: + from pydantic import BaseModel as NDNestedModel + +# Enum imports +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair import ( + ComponentTypeSupportEnum, + VpcActionEnum, + VpcFieldNames, +) +from ansible_collections.cisco.nd.plugins.module_utils.ep.v1 import ( + EpVpcPairConsistencyGet, + EpVpcPairGet, + EpVpcPairPut, + EpVpcPairOverviewGet, + EpVpcPairRecommendationGet, + EpVpcPairSupportGet, + EpVpcPairsListGet, + VpcPairBasePath, +) + +# RestSend imports +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule as NDModuleV2, + NDModuleError, +) +try: + from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results +except Exception: + from ansible_collections.cisco.nd.plugins.module_utils.results import Results + +# Pydantic imports +from pydantic import Field, field_validator, model_validator + +# VPC Pair schema imports (for vpc_pair_details support) +from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.model_playbook_vpc_pair import ( + VpcPairDetailsDefault, + VpcPairDetailsCustom, +) + +# DeepDiff for intelligent change detection +try: + from deepdiff import DeepDiff + HAS_DEEPDIFF = True + DEEPDIFF_IMPORT_ERROR = None +except ImportError: + HAS_DEEPDIFF = False + DEEPDIFF_IMPORT_ERROR = traceback.format_exc() + + +def _collection_to_list_flex(collection) -> List[Dict[str, Any]]: + """ + Serialize NDConfigCollection across old/new framework variants. + """ + if collection is None: + return [] + if hasattr(collection, "to_list"): + return collection.to_list() + if hasattr(collection, "to_payload_list"): + return collection.to_payload_list() + if hasattr(collection, "to_ansible_config"): + return collection.to_ansible_config() + return [] + + +# ===== API Endpoints ===== + + +class VpcPairEndpoints: + """ + Centralized API endpoint path management for VPC pair operations. + + All API endpoint paths are defined here to: + - Eliminate scattered path definitions + - Make API evolution easier + - Enable easy endpoint discovery + - Support multiple API versions + + Usage: + # Get a path with parameters + path = VpcPairEndpoints.vpc_pair_put(fabric_name="myFabric", switch_id="FDO123") + # Returns: "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair/fabrics/myFabric/switches/FDO123" + """ + + # Base paths + NDFC_BASE = "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest" + MANAGE_BASE = "/api/v1/manage" + + # Path templates for VPC pair operations (NDFC API) + VPC_PAIR_BASE = f"{NDFC_BASE}/vpcpair/fabrics/{{fabric_name}}" + VPC_PAIR_SWITCH = f"{NDFC_BASE}/vpcpair/fabrics/{{fabric_name}}/switches/{{switch_id}}" + + # Path templates for fabric operations (Manage API - for config save/deploy actions) + FABRIC_CONFIG_SAVE = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/actions/configSave" + FABRIC_CONFIG_DEPLOY = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/actions/deploy" + + # Path templates for switch/inventory operations (Manage API) + FABRIC_SWITCHES = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches" + SWITCH_VPC_PAIR = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPair" + SWITCH_VPC_RECOMMENDATIONS = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPairRecommendations" + SWITCH_VPC_OVERVIEW = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPairOverview" + + @staticmethod + def vpc_pair_base(fabric_name: str) -> str: + """ + Get base path for VPC pair operations. + + Args: + fabric_name: Fabric name + + Returns: + Base VPC pairs list path + + Example: + >>> VpcPairEndpoints.vpc_pair_base("myFabric") + '/api/v1/manage/fabrics/myFabric/vpcPairs' + """ + endpoint = EpVpcPairsListGet(fabric_name=fabric_name) + return endpoint.path + + @staticmethod + def vpc_pairs_list(fabric_name: str) -> str: + """ + Get path for querying VPC pairs list in a fabric. + + Args: + fabric_name: Fabric name + + Returns: + VPC pairs list path + """ + endpoint = EpVpcPairsListGet(fabric_name=fabric_name) + return endpoint.path + + @staticmethod + def vpc_pair_put(fabric_name: str, switch_id: str) -> str: + """ + Get path for VPC pair PUT operations (create/update/delete). + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + VPC pair PUT path + + Example: + >>> VpcPairEndpoints.vpc_pair_put("myFabric", "FDO123") + '/api/v1/manage/fabrics/myFabric/switches/FDO123/vpcPair' + """ + endpoint = EpVpcPairPut(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def fabric_switches(fabric_name: str) -> str: + """ + Get path for querying fabric switch inventory. + + Args: + fabric_name: Fabric name + + Returns: + Fabric switches path + + Example: + >>> VpcPairEndpoints.fabric_switches("myFabric") + '/api/v1/manage/fabrics/myFabric/switches' + """ + return VpcPairBasePath.fabrics(fabric_name, "switches") + + @staticmethod + def switch_vpc_pair(fabric_name: str, switch_id: str) -> str: + """ + Get path for querying specific switch VPC pair. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + Switch VPC pair path + + Example: + >>> VpcPairEndpoints.switch_vpc_pair("myFabric", "FDO123") + '/api/v1/manage/fabrics/myFabric/switches/FDO123/vpcPair' + """ + endpoint = EpVpcPairGet(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def switch_vpc_recommendations(fabric_name: str, switch_id: str) -> str: + """ + Get path for querying VPC pair recommendations for a switch. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + VPC recommendations path + + Example: + >>> VpcPairEndpoints.switch_vpc_recommendations("myFabric", "FDO123") + '/api/v1/manage/fabrics/myFabric/switches/FDO123/vpcPairRecommendations' + """ + endpoint = EpVpcPairRecommendationGet(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def switch_vpc_overview(fabric_name: str, switch_id: str, component_type: str = "full") -> str: + """ + Get path for querying VPC pair overview (for pre-deletion validation). + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + component_type: Component type ("full" or "minimal"), default "full" + + Returns: + VPC overview path with query parameters + + Example: + >>> VpcPairEndpoints.switch_vpc_overview("myFabric", "FDO123") + '/api/v1/manage/fabrics/myFabric/switches/FDO123/vpcPairOverview?componentType=full' + """ + endpoint = EpVpcPairOverviewGet(fabric_name=fabric_name, switch_id=switch_id) + base_path = endpoint.path + return f"{base_path}?componentType={component_type}" + + @staticmethod + def switch_vpc_support( + fabric_name: str, + switch_id: str, + component_type: str = ComponentTypeSupportEnum.CHECK_PAIRING.value, + ) -> str: + """ + Get path for querying VPC pair support details. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + component_type: Support check type + + Returns: + VPC support path with query parameters + """ + endpoint = EpVpcPairSupportGet( + fabric_name=fabric_name, + switch_id=switch_id, + component_type=component_type, + ) + base_path = endpoint.path + return f"{base_path}?componentType={component_type}" + + @staticmethod + def switch_vpc_consistency(fabric_name: str, switch_id: str) -> str: + """ + Get path for querying VPC pair consistency details. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + VPC consistency path + """ + endpoint = EpVpcPairConsistencyGet(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def fabric_config_save(fabric_name: str) -> str: + """ + Get path for saving fabric configuration. + + Args: + fabric_name: Fabric name + + Returns: + Fabric config save path + + Example: + >>> VpcPairEndpoints.fabric_config_save("myFabric") + '/api/v1/manage/fabrics/myFabric/actions/configSave' + """ + return VpcPairBasePath.fabrics(fabric_name, "actions", "configSave") + + @staticmethod + def fabric_config_deploy(fabric_name: str, force_show_run: bool = True) -> str: + """ + Get path for deploying fabric configuration. + + Args: + fabric_name: Fabric name + force_show_run: Include forceShowRun query parameter, default True + + Returns: + Fabric config deploy path with query parameters + + Example: + >>> VpcPairEndpoints.fabric_config_deploy("myFabric") + '/api/v1/manage/fabrics/myFabric/actions/deploy?forceShowRun=true' + """ + base_path = VpcPairBasePath.fabrics(fabric_name, "actions", "deploy") + if force_show_run: + return f"{base_path}?forceShowRun=true" + return base_path + + +# ===== VPC Pair Model ===== + + +class VpcPairModel(NDNestedModel): + """ + Pydantic model for VPC pair configuration specific to nd_vpc_pair module. + + Uses composite identifier: (switch_id, peer_switch_id) + + Note: This model is separate from VpcPairBase in model_playbook_vpc_pair.py because: + 1. Different base class: NDNestedModel (module-specific) vs NDVpcPairBaseModel (API-generic) + 2. Different defaults: use_virtual_peer_link=True (module default) vs False (API default) + 3. Different type coercion: bool (strict) vs FlexibleBool (flexible API input) + 4. Module-specific validation and error messages tailored to Ansible user experience + + These models serve different purposes: + - VpcPairModel: Ansible module input validation and framework integration + - VpcPairBase: Generic API schema for broader vpc_pair functionality + + DO NOT consolidate without ensuring all tests pass and defaults match module documentation. + """ + + # Identifier configuration + identifiers = ["switch_id", "peer_switch_id"] + identifier_strategy = "composite" + + # Fields (Ansible names -> API aliases) + switch_id: str = Field( + alias=VpcFieldNames.SWITCH_ID, + description="Peer-1 switch serial number", + min_length=3, + max_length=64 + ) + peer_switch_id: str = Field( + alias=VpcFieldNames.PEER_SWITCH_ID, + description="Peer-2 switch serial number", + min_length=3, + max_length=64 + ) + use_virtual_peer_link: bool = Field( + default=True, + alias=VpcFieldNames.USE_VIRTUAL_PEER_LINK, + description="Virtual peer link enabled" + ) + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, + discriminator="type", + alias=VpcFieldNames.VPC_PAIR_DETAILS, + description="VPC pair configuration details (default or custom template)" + ) + + @field_validator("switch_id", "peer_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Switch ID value + + Returns: + Stripped switch ID + + Raises: + ValueError: If switch ID is empty or whitespace + """ + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> "VpcPairModel": + """ + Ensure switch_id and peer_switch_id are different. + + Returns: + Validated model instance + + Raises: + ValueError: If switch_id equals peer_switch_id + """ + if self.switch_id == self.peer_switch_id: + raise ValueError( + f"switch_id and peer_switch_id must be different: {self.switch_id}" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + """ + Convert to API payload format. + + Note: vpcAction is added by custom functions, not here. + """ + return self.model_dump(by_alias=True, exclude_none=True) + + def to_config(self, **kwargs) -> Dict[str, Any]: + """ + Convert to Ansible config shape with snake_case field names. + """ + return self.model_dump(by_alias=False, exclude_none=True, **kwargs) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> "VpcPairModel": + """ + Parse VPC pair from API response. + + Handles API field name variations. + """ + data = { + VpcFieldNames.SWITCH_ID: response.get(VpcFieldNames.SWITCH_ID), + VpcFieldNames.PEER_SWITCH_ID: response.get(VpcFieldNames.PEER_SWITCH_ID), + VpcFieldNames.USE_VIRTUAL_PEER_LINK: response.get( + VpcFieldNames.USE_VIRTUAL_PEER_LINK, True + ), + } + return cls.model_validate(data) + + +# ===== Helper Functions ===== + + +def _is_update_needed(want: Dict[str, Any], have: Dict[str, Any]) -> bool: + """ + Determine if an update is needed by comparing want and have using DeepDiff. + + Uses DeepDiff for intelligent comparison that handles: + - Field additions + - Value changes + - Nested structure changes + - Ignores field order + + Falls back to simple comparison if DeepDiff is unavailable. + + Args: + want: Desired VPC pair configuration (dict) + have: Current VPC pair configuration (dict) + + Returns: + bool: True if update is needed, False if already in desired state + + Example: + >>> want = {"switchId": "FDO123", "useVirtualPeerLink": True} + >>> have = {"switchId": "FDO123", "useVirtualPeerLink": False} + >>> _is_update_needed(want, have) + True + """ + if not HAS_DEEPDIFF: + # Fallback to simple comparison + return want != have + + try: + # Use DeepDiff for intelligent comparison + diff = DeepDiff(have, want, ignore_order=True) + return bool(diff) + except Exception: + # Fallback to simple comparison if DeepDiff fails + return want != have + + +def _get_template_config(vpc_pair_model) -> Optional[Dict[str, Any]]: + """ + Extract template configuration from VPC pair model if present. + + Supports both default and custom template types: + - default: Standard parameters (domainId, keepAliveVrf, etc.) + - custom: User-defined template with custom fields + + Args: + vpc_pair_model: VpcPairModel instance + + Returns: + dict: Template configuration or None if not provided + + Example: + # For default template: + config = _get_template_config(model) + # Returns: {"type": "default", "domainId": 100, ...} + + # For custom template: + config = _get_template_config(model) + # Returns: {"type": "custom", "templateName": "my_template", ...} + """ + # Check if model has vpc_pair_details + if not hasattr(vpc_pair_model, "vpc_pair_details"): + return None + + vpc_pair_details = vpc_pair_model.vpc_pair_details + if not vpc_pair_details: + return None + + # Return the validated Pydantic model as dict + return vpc_pair_details.model_dump(by_alias=True, exclude_none=True) + + +def _build_vpc_pair_payload(vpc_pair_model) -> Dict[str, Any]: + """ + Build the 4.2 API payload for pairing a VPC. + + Constructs payload according to OpenAPI spec with vpcAction + discriminator and optional template details. + + Args: + vpc_pair_model: VpcPairModel instance with configuration + + Returns: + dict: Complete payload for PUT request in 4.2 format + + Example: + payload = _build_vpc_pair_payload(vpc_pair_model) + # Returns: + # { + # "vpcAction": "pair", + # "switchId": "FDO123", + # "peerSwitchId": "FDO456", + # "useVirtualPeerLink": True, + # "vpcPairDetails": {...} # Optional + # } + """ + # Handle both dict and model object inputs + if isinstance(vpc_pair_model, dict): + switch_id = vpc_pair_model.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = vpc_pair_model.get(VpcFieldNames.PEER_SWITCH_ID) + use_virtual_peer_link = vpc_pair_model.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + else: + switch_id = vpc_pair_model.switch_id + peer_switch_id = vpc_pair_model.peer_switch_id + use_virtual_peer_link = vpc_pair_model.use_virtual_peer_link + + # Base payload with vpcAction discriminator + payload = { + VpcFieldNames.VPC_ACTION: VpcActionEnum.PAIR.value, + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_virtual_peer_link, + } + + # Add template configuration if provided (only for model objects) + if not isinstance(vpc_pair_model, dict): + template_config = _get_template_config(vpc_pair_model) + if template_config: + payload[VpcFieldNames.VPC_PAIR_DETAILS] = template_config + + return payload + + +# API field compatibility mapping +# ND API versions use inconsistent field names - this mapping provides a canonical interface +API_FIELD_ALIASES = { + # Primary field name -> list of alternative field names to check + "useVirtualPeerLink": ["useVirtualPeerlink"], # ND 4.2+ uses camelCase "Link", older versions use lowercase "link" + "serialNumber": ["serial_number", "serialNo"], # Alternative serial number field names +} + + +def _get_api_field_value(api_response: Dict, field_name: str, default=None): + """ + Get field value from API response handling inconsistent field naming across ND API versions. + + Different ND API versions use inconsistent field names (useVirtualPeerLink vs useVirtualPeerlink). + This function checks the primary field name and all known aliases. + + Args: + api_response: API response dictionary + field_name: Primary field name to retrieve + default: Default value if field not found + + Returns: + Field value or default if not found + + Example: + >>> recommendation = {"useVirtualPeerlink": True} # Old API format + >>> _get_api_field_value(recommendation, "useVirtualPeerLink", False) + True # Found via alias mapping + + >>> recommendation = {"useVirtualPeerLink": True} # New API format + >>> _get_api_field_value(recommendation, "useVirtualPeerLink", False) + True # Found via primary field name + """ + if not isinstance(api_response, dict): + return default + + # Check primary field name first + if field_name in api_response: + return api_response[field_name] + + # Check aliases + aliases = API_FIELD_ALIASES.get(field_name, []) + for alias in aliases: + if alias in api_response: + return api_response[alias] + + return default + + +def _get_recommendation_details(nd_v2, fabric_name: str, switch_id: str, timeout: Optional[int] = None) -> Optional[Dict]: + """ + Get VPC pair recommendation details from ND for a specific switch. + + Returns peer switch info and useVirtualPeerLink status. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + timeout: Optional timeout override (uses module param if not specified) + + Returns: + Dict with peer info or None if not found (404) + + Raises: + NDModuleError: On API errors other than 404 (timeouts, 500s, etc.) + """ + # Validate inputs to prevent injection + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: + raise ValueError(f"Invalid switch_id: {switch_id}") + + try: + path = VpcPairEndpoints.switch_vpc_recommendations(fabric_name, switch_id) + + # Use query timeout from module params or override + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + vpc_recommendations = nd_v2.request(path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if vpc_recommendations is None or vpc_recommendations == {}: + return None + + # Validate response structure and look for current peer + if isinstance(vpc_recommendations, list): + for sw in vpc_recommendations: + # Validate each entry + if not isinstance(sw, dict): + nd_v2.module.warn( + f"Skipping invalid recommendation entry for switch {switch_id}: " + f"expected dict, got {type(sw).__name__}" + ) + continue + + # Check for current peer indicators + if sw.get(VpcFieldNames.CURRENT_PEER) or sw.get(VpcFieldNames.IS_CURRENT_PEER): + # Validate required fields exist + if VpcFieldNames.SERIAL_NUMBER not in sw: + nd_v2.module.warn( + f"Recommendation missing serialNumber field for switch {switch_id}" + ) + continue + return sw + elif vpc_recommendations: + # Unexpected response format + nd_v2.module.warn( + f"Unexpected recommendation response format for switch {switch_id}: " + f"expected list, got {type(vpc_recommendations).__name__}" + ) + + return None + except NDModuleError as error: + # Handle expected error codes gracefully + if error.status == 404: + # No recommendations exist (expected for switches without VPC) + return None + elif error.status == 500: + # Server error - recommendation API may be unstable + # Treat as "no recommendations available" to allow graceful degradation + nd_v2.module.warn( + f"VPC recommendation API returned 500 error for switch {switch_id} - " + f"treating as no recommendations available" + ) + return None + # Let other errors (timeouts, rate limits) propagate + raise + + +def _extract_vpc_pairs_from_list_response(vpc_pairs_response: Any) -> List[Dict[str, Any]]: + """ + Extract VPC pair list entries from /vpcPairs response payload. + + Supports common response wrappers used by ND API. + """ + if not isinstance(vpc_pairs_response, dict): + return [] + + candidates = None + for key in (VpcFieldNames.VPC_PAIRS, "items", VpcFieldNames.DATA): + value = vpc_pairs_response.get(key) + if isinstance(value, list): + candidates = value + break + + if not isinstance(candidates, list): + return [] + + extracted_pairs = [] + for item in candidates: + if not isinstance(item, dict): + continue + + switch_id = item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = item.get(VpcFieldNames.PEER_SWITCH_ID) + + # Handle alternate response shape if switch IDs are nested under "switch"/"peerSwitch" + if isinstance(switch_id, dict) and isinstance(peer_switch_id, dict): + switch_id = switch_id.get("switch") + peer_switch_id = peer_switch_id.get("peerSwitch") + + if not switch_id or not peer_switch_id: + continue + + extracted_pairs.append( + { + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: item.get( + VpcFieldNames.USE_VIRTUAL_PEER_LINK, True + ), + } + ) + + return extracted_pairs + + +def _get_pairing_support_details( + nd_v2, + fabric_name: str, + switch_id: str, + component_type: str = ComponentTypeSupportEnum.CHECK_PAIRING.value, + timeout: Optional[int] = None, +) -> Optional[Dict[str, Any]]: + """ + Query /vpcPairSupport endpoint to validate pairing support. + """ + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: + raise ValueError(f"Invalid switch_id: {switch_id}") + + path = VpcPairEndpoints.switch_vpc_support( + fabric_name=fabric_name, + switch_id=switch_id, + component_type=component_type, + ) + + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + support_details = nd_v2.request(path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if isinstance(support_details, dict): + return support_details + return None + + +def _validate_fabric_peering_support( + nrm, + nd_v2, + fabric_name: str, + switch_id: str, + peer_switch_id: str, + use_virtual_peer_link: bool, +) -> None: + """ + Validate fabric peering support when virtual peer link is requested. + + If API explicitly reports unsupported fabric peering, logs warning and + continues. If support API is unavailable, logs warning and continues. + """ + if not use_virtual_peer_link: + return + + switches_to_check = [switch_id, peer_switch_id] + for support_switch_id in switches_to_check: + if not support_switch_id: + continue + + try: + support_details = _get_pairing_support_details( + nd_v2, + fabric_name=fabric_name, + switch_id=support_switch_id, + component_type=ComponentTypeSupportEnum.CHECK_FABRIC_PEERING_SUPPORT.value, + ) + if not support_details: + continue + + is_supported = _get_api_field_value( + support_details, "isVpcFabricPeeringSupported", None + ) + if is_supported is False: + status = _get_api_field_value( + support_details, "status", "Fabric peering not supported" + ) + nrm.module.warn( + f"VPC fabric peering is not supported for switch {support_switch_id}: {status}. " + f"Continuing, but config save/deploy may report a platform limitation. " + f"Consider setting use_virtual_peer_link=false for this platform." + ) + except Exception as support_error: + nrm.module.warn( + f"Fabric peering support check failed for switch {support_switch_id}: " + f"{str(support_error).splitlines()[0]}. Continuing with create/update operation." + ) + + +def _get_consistency_details( + nd_v2, + fabric_name: str, + switch_id: str, + timeout: Optional[int] = None, +) -> Optional[Dict[str, Any]]: + """ + Query /vpcPairConsistency endpoint for consistency diagnostics. + """ + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: + raise ValueError(f"Invalid switch_id: {switch_id}") + + path = VpcPairEndpoints.switch_vpc_consistency(fabric_name, switch_id) + + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + consistency_details = nd_v2.request(path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if isinstance(consistency_details, dict): + return consistency_details + return None + + +def _is_switch_in_vpc_pair( + nd_v2, + fabric_name: str, + switch_id: str, + timeout: Optional[int] = None, +) -> Optional[bool]: + """ + Best-effort active-membership check via vPC overview endpoint. + + Returns: + - True: overview query succeeded (switch is part of a vPC pair) + - False: API explicitly reports switch is not in a vPC pair + - None: unknown/error (do not block caller logic) + """ + if not fabric_name or not switch_id: + return None + + path = VpcPairEndpoints.switch_vpc_overview( + fabric_name, switch_id, component_type="full" + ) + + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + nd_v2.request(path, HttpVerbEnum.GET) + return True + except NDModuleError as error: + error_msg = (error.msg or "").lower() + if error.status == 400 and "not a part of vpc pair" in error_msg: + return False + return None + except Exception: + return None + finally: + rest_send.restore_settings() + + +def _validate_fabric_switches(nd_v2, fabric_name: str) -> Dict[str, Dict]: + """ + Query and validate fabric switch inventory. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + + Returns: + Dict mapping switch serial number to switch info + + Raises: + ValueError: If inputs are invalid + NDModuleError: If fabric switch query fails + """ + # Input validation + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + + # Use api_timeout from module params + timeout = nd_v2.module.params.get("api_timeout", 30) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + switches_path = VpcPairEndpoints.fabric_switches(fabric_name) + switches_response = nd_v2.request(switches_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if not switches_response: + return {} + + # Validate response structure + if not isinstance(switches_response, dict): + nd_v2.module.warn( + f"Unexpected switches response format: expected dict, got {type(switches_response).__name__}" + ) + return {} + + switches = switches_response.get(VpcFieldNames.SWITCHES, []) + + # Validate switches is a list + if not isinstance(switches, list): + nd_v2.module.warn( + f"Unexpected switches format: expected list, got {type(switches).__name__}" + ) + return {} + + # Build validated switch dictionary + result = {} + for sw in switches: + if not isinstance(sw, dict): + nd_v2.module.warn(f"Skipping invalid switch entry: expected dict, got {type(sw).__name__}") + continue + + serial_number = sw.get(VpcFieldNames.SERIAL_NUMBER) + if not serial_number: + continue + + # Validate serial number format + if not isinstance(serial_number, str) or len(serial_number) < 3: + nd_v2.module.warn(f"Skipping switch with invalid serial number: {serial_number}") + continue + + result[serial_number] = sw + + return result + + +def _validate_switch_conflicts(want_configs: List[Dict], have_vpc_pairs: List[Dict], module) -> None: + """ + Validate that switches in want configs aren't already in different VPC pairs. + + Optimized implementation using index-based lookup for O(n) time complexity instead of O(n²). + + Args: + want_configs: List of desired VPC pair configs + have_vpc_pairs: List of existing VPC pairs + module: AnsibleModule instance for fail_json + + Raises: + AnsibleModule.fail_json: If switch conflicts detected + """ + conflicts = [] + + # Build index of existing VPC pairs by switch ID - O(m) where m = len(have_vpc_pairs) + # Maps switch_id -> list of VPC pairs containing that switch + switch_to_vpc_index = {} + for have in have_vpc_pairs: + have_switch_id = have.get(VpcFieldNames.SWITCH_ID) + have_peer_id = have.get(VpcFieldNames.PEER_SWITCH_ID) + + if have_switch_id: + if have_switch_id not in switch_to_vpc_index: + switch_to_vpc_index[have_switch_id] = [] + switch_to_vpc_index[have_switch_id].append(have) + + if have_peer_id: + if have_peer_id not in switch_to_vpc_index: + switch_to_vpc_index[have_peer_id] = [] + switch_to_vpc_index[have_peer_id].append(have) + + # Check each want config for conflicts - O(n) where n = len(want_configs) + for want in want_configs: + want_switches = {want.get(VpcFieldNames.SWITCH_ID), want.get(VpcFieldNames.PEER_SWITCH_ID)} + want_switches.discard(None) + + # Build set of all VPC pairs that contain any switch from want_switches - O(1) lookup per switch + # Use set to track VPC IDs we've already checked to avoid duplicate processing + conflicting_vpcs = {} # vpc_id -> vpc dict + for switch in want_switches: + if switch in switch_to_vpc_index: + for vpc in switch_to_vpc_index[switch]: + # Use tuple of sorted switch IDs as unique identifier + vpc_id = tuple(sorted([vpc.get(VpcFieldNames.SWITCH_ID), vpc.get(VpcFieldNames.PEER_SWITCH_ID)])) + # Only add if we haven't seen this VPC ID before (avoids duplicate processing) + if vpc_id not in conflicting_vpcs: + conflicting_vpcs[vpc_id] = vpc + + # Check each potentially conflicting VPC pair + for vpc_id, have in conflicting_vpcs.items(): + have_switches = {have.get(VpcFieldNames.SWITCH_ID), have.get(VpcFieldNames.PEER_SWITCH_ID)} + have_switches.discard(None) + + # Same VPC pair is OK + if want_switches == have_switches: + continue + + # Check for switch overlap with different pairs + switch_overlap = want_switches & have_switches + if switch_overlap: + # Filter out None values and ensure strings for joining + overlap_list = [str(s) for s in switch_overlap if s is not None] + want_key = f"{want.get(VpcFieldNames.SWITCH_ID)}-{want.get(VpcFieldNames.PEER_SWITCH_ID)}" + have_key = f"{have.get(VpcFieldNames.SWITCH_ID)}-{have.get(VpcFieldNames.PEER_SWITCH_ID)}" + conflicts.append( + f"Switch(es) {', '.join(overlap_list)} in wanted VPC pair {want_key} " + f"are already part of existing VPC pair {have_key}" + ) + + if conflicts: + module.fail_json( + msg="Switch conflicts detected. A switch can only be part of one VPC pair at a time.", + conflicts=conflicts + ) + + +def _validate_switches_exist_in_fabric( + nrm, + fabric_name: str, + switch_id: str, + peer_switch_id: str, +) -> None: + """ + Validate both switches exist in discovered fabric inventory. + + This check is mandatory for create/update. Empty inventory is treated as + a validation error to avoid bypassing guardrails and failing later with a + less actionable API error. + """ + fabric_switches = nrm.module.params.get("_fabric_switches") + + if fabric_switches is None: + nrm.module.fail_json( + msg=( + f"Switch validation failed for fabric '{fabric_name}': switch inventory " + "was not loaded from query_all. Unable to validate requested vPC pair." + ), + vpc_pair_key=nrm.current_identifier, + fabric=fabric_name, + ) + + valid_switches = sorted(list(fabric_switches)) + if not valid_switches: + nrm.module.fail_json( + msg=( + f"Switch validation failed for fabric '{fabric_name}': no switches were " + "discovered in fabric inventory. Cannot create/update vPC pairs without " + "validated switch membership." + ), + vpc_pair_key=nrm.current_identifier, + fabric=fabric_name, + total_valid_switches=0, + ) + + missing_switches = [] + if switch_id not in fabric_switches: + missing_switches.append(switch_id) + if peer_switch_id not in fabric_switches: + missing_switches.append(peer_switch_id) + + if not missing_switches: + return + + max_switches_in_error = 10 + error_msg = ( + f"Switch validation failed: The following switch(es) do not exist in fabric '{fabric_name}':\n" + f" Missing switches: {', '.join(missing_switches)}\n" + f" Affected vPC pair: {nrm.current_identifier}\n\n" + "Please ensure:\n" + " 1. Switch serial numbers are correct (not IP addresses)\n" + " 2. Switches are discovered and present in the fabric\n" + " 3. You have the correct fabric name specified\n\n" + ) + + if len(valid_switches) <= max_switches_in_error: + error_msg += f"Valid switches in fabric: {', '.join(valid_switches)}" + else: + error_msg += ( + f"Valid switches in fabric (first {max_switches_in_error}): " + f"{', '.join(valid_switches[:max_switches_in_error])} ... and " + f"{len(valid_switches) - max_switches_in_error} more" + ) + + nrm.module.fail_json( + msg=error_msg, + missing_switches=missing_switches, + vpc_pair_key=nrm.current_identifier, + total_valid_switches=len(valid_switches), + ) + + +def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pair_key: str, module) -> None: + """ + Validate VPC pair can be safely deleted by checking for dependencies. + + This function prevents data loss by ensuring the VPC pair has no active: + 1. Networks (networkCount must be 0 for all statuses) + 2. VRFs (vrfCount must be 0 for all statuses) + 3. Warns if vPC interfaces exist (vpcInterfaceCount > 0) + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + vpc_pair_key: VPC pair identifier (e.g., "FDO123-FDO456") for error messages + module: AnsibleModule instance for fail_json/warn + + Raises: + AnsibleModule.fail_json: If VPC pair has active networks or VRFs + + Example: + _validate_vpc_pair_deletion(nd_v2, "myFabric", "FDO123", "FDO123-FDO456", module) + """ + try: + # Query overview endpoint with full component data + overview_path = VpcPairEndpoints.switch_vpc_overview(fabric_name, switch_id, component_type="full") + + # Bound overview validation call by query_timeout for deterministic behavior. + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = nd_v2.module.params.get("query_timeout", 10) + try: + response = nd_v2.request(overview_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + # If no response, VPC pair doesn't exist - deletion not needed + if not response: + module.warn( + f"VPC pair {vpc_pair_key} not found in overview query. " + f"It may not exist or may have already been deleted." + ) + return + + # Query consistency endpoint for additional diagnostics before deletion. + # This is best effort and should not block deletion workflows. + try: + consistency = _get_consistency_details(nd_v2, fabric_name, switch_id) + if consistency: + type2_consistency = _get_api_field_value(consistency, "type2Consistency", None) + if type2_consistency is False: + reason = _get_api_field_value( + consistency, "type2ConsistencyReason", "unknown reason" + ) + module.warn( + f"VPC pair {vpc_pair_key} reports type2 consistency issue: {reason}" + ) + except Exception as consistency_error: + module.warn( + f"Failed to query consistency details for VPC pair {vpc_pair_key}: " + f"{str(consistency_error).splitlines()[0]}" + ) + + # Validate response structure + if not isinstance(response, dict): + module.fail_json( + msg=f"Expected dict response from vPC pair overview for {vpc_pair_key}, got {type(response).__name__}", + response=response + ) + + # Validate overlay data exists + overlay = response.get(VpcFieldNames.OVERLAY) + if not overlay: + module.fail_json( + msg=( + f"vPC pair {vpc_pair_key} might not exist or overlay data unavailable. " + f"Cannot safely validate deletion." + ), + vpc_pair_key=vpc_pair_key, + response=response + ) + + # Check 1: Validate no networks are attached + network_count = overlay.get(VpcFieldNames.NETWORK_COUNT, {}) + if isinstance(network_count, dict): + for status, count in network_count.items(): + try: + count_int = int(count) + if count_int != 0: + module.fail_json( + msg=( + f"Cannot delete vPC pair {vpc_pair_key}. " + f"{count_int} network(s) with status '{status}' still exist. " + f"Remove all networks from this vPC pair before deleting it." + ), + vpc_pair_key=vpc_pair_key, + network_count=network_count, + blocking_status=status, + blocking_count=count_int + ) + except (ValueError, TypeError) as e: + # Best effort - log warning and continue + module.warn(f"Error parsing network count for status '{status}': {e}") + elif network_count: + # Non-dict format - log warning + module.warn( + f"networkCount is not a dict for {vpc_pair_key}: {type(network_count).__name__}. " + f"Skipping network validation." + ) + + # Check 2: Validate no VRFs are attached + vrf_count = overlay.get(VpcFieldNames.VRF_COUNT, {}) + if isinstance(vrf_count, dict): + for status, count in vrf_count.items(): + try: + count_int = int(count) + if count_int != 0: + module.fail_json( + msg=( + f"Cannot delete vPC pair {vpc_pair_key}. " + f"{count_int} VRF(s) with status '{status}' still exist. " + f"Remove all VRFs from this vPC pair before deleting it." + ), + vpc_pair_key=vpc_pair_key, + vrf_count=vrf_count, + blocking_status=status, + blocking_count=count_int + ) + except (ValueError, TypeError) as e: + # Best effort - log warning and continue + module.warn(f"Error parsing VRF count for status '{status}': {e}") + elif vrf_count: + # Non-dict format - log warning + module.warn( + f"vrfCount is not a dict for {vpc_pair_key}: {type(vrf_count).__name__}. " + f"Skipping VRF validation." + ) + + # Check 3: Warn if vPC interfaces exist (non-blocking) + inventory = response.get(VpcFieldNames.INVENTORY, {}) + if inventory and isinstance(inventory, dict): + vpc_interface_count = inventory.get(VpcFieldNames.VPC_INTERFACE_COUNT) + if vpc_interface_count: + try: + count_int = int(vpc_interface_count) + if count_int > 0: + module.warn( + f"vPC pair {vpc_pair_key} has {count_int} vPC interface(s). " + f"Deletion may fail or require manual cleanup of interfaces. " + f"Consider removing vPC interfaces before deleting the vPC pair." + ) + except (ValueError, TypeError) as e: + # Best effort - just log debug message + pass + elif not inventory: + # No inventory data - warn user + module.warn( + f"Inventory data not available in overview response for {vpc_pair_key}. " + f"Proceeding with deletion, but it may fail if vPC interfaces exist." + ) + + except NDModuleError as error: + error_msg = str(error.msg).lower() if error.msg else "" + status_code = error.status or 0 + + # If the overview query returns 400 with "not a part of" it means + # the pair no longer exists on the controller. Signal the caller + # by raising a ValueError with a sentinel message so that the + # delete function can treat this as an idempotent no-op. + if status_code == 400 and "not a part of" in error_msg: + raise ValueError( + f"VPC pair {vpc_pair_key} is already unpaired on the controller. " + f"No deletion required." + ) + + # Best effort validation - if overview query fails, log warning and proceed + # The API will still reject deletion if dependencies exist + module.warn( + f"Could not validate vPC pair {vpc_pair_key} for deletion: {error.msg}. " + f"Proceeding with deletion attempt. API will reject if dependencies exist." + ) + + except Exception as e: + # Best effort validation - log warning and continue + module.warn( + f"Unexpected error validating VPC pair {vpc_pair_key} for deletion: {str(e)}. " + f"Proceeding with deletion attempt." + ) + + +# ===== Custom Action Functions (used by VpcPairResourceService via orchestrator) ===== + + +def custom_vpc_query_all(nrm) -> List[Dict]: + """ + Custom query function for VPC pairs using RestSend with full state tracking. + + - Validates fabric and queries switch inventory (UpdateInventory) + - Tracks 3-state: have, pending_create, pending_delete (GetHave) + - Queries recommendation API for virtual peer link details + - Falls back to direct VPC query if recommendation fails + - Builds IP-to-SN mapping from switch inventory + - Stores fabric switches for validation + + Args: + nrm: NDStateMachine instance + + Returns: + List of VPC pair dictionaries from API (have state) + + Raises: + ValueError: If fabric_name is not configured + NDModuleError: If critical queries fail + """ + fabric_name = nrm.module.params.get("fabric_name") + + # Fabric validation (from UpdateInventory.__init__) + if not fabric_name or not isinstance(fabric_name, str) or not fabric_name.strip(): + raise ValueError(f"fabric_name must be a non-empty string. Got: {fabric_name!r}") + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + + try: + # Step 1: Query and validate fabric switches (UpdateInventory.refresh()) + fabric_switches = _validate_fabric_switches(nd_v2, fabric_name) + + if not fabric_switches: + nrm.module.warn(f"No switches found in fabric {fabric_name}") + nrm.module.params["_fabric_switches"] = [] # Use list for JSON serialization + nrm.module.params["_fabric_switches_count"] = 0 + nrm.module.params["_have"] = [] + nrm.module.params["_pending_create"] = [] + nrm.module.params["_pending_delete"] = [] + return [] + + # Memory optimization: Convert to list immediately to avoid keeping full dict in memory + # Keep only switch IDs for validation (not full switch objects) + # Use list (not set) for JSON serialization compatibility + fabric_switches_list = list(fabric_switches.keys()) + nrm.module.params["_fabric_switches"] = fabric_switches_list + nrm.module.params["_fabric_switches_count"] = len(fabric_switches) + + # Build IP-to-SN mapping (extract before dict is discarded) + ip_to_sn = { + sw.get(VpcFieldNames.FABRIC_MGMT_IP): sw.get(VpcFieldNames.SERIAL_NUMBER) + for sw in fabric_switches.values() + if VpcFieldNames.FABRIC_MGMT_IP in sw + } + nrm.module.params["_ip_to_sn_mapping"] = ip_to_sn + + # Step 2: Seed existing VPC pairs from list endpoint (/vpcPairs) + have = [] + try: + list_path = VpcPairEndpoints.vpc_pairs_list(fabric_name) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = nrm.module.params.get("query_timeout", 10) + try: + vpc_pairs_response = nd_v2.request(list_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + have.extend(_extract_vpc_pairs_from_list_response(vpc_pairs_response)) + except Exception as list_error: + nrm.module.warn( + f"VPC pairs list query failed for fabric {fabric_name}: " + f"{str(list_error).splitlines()[0]}. Continuing with switch-level queries." + ) + + # Step 3: Track 3-state VPC pairs (GetHave.refresh()) + pending_create = [] + pending_delete = [] + processed_switches = set() + + # Build set of switch IDs from user config to limit recommendation queries. + # For gathered state, main() stores filters in _gather_filter_config and clears + # config before framework initialization to guarantee read-only behavior. + state = nrm.module.params.get("state", "merged") + if state == "gathered": + config = nrm.module.params.get("_gather_filter_config") or [] + else: + config = nrm.module.params.get("config") or [] + desired_pairs = {} + config_switch_ids = set() + for item in config: + # Note: config items have been normalized to snake_case (switch_id, peer_switch_id) + # not the original Ansible input names (peer1_switch_id, peer2_switch_id) + switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + + if switch_id_val: + config_switch_ids.add(switch_id_val) + if peer_switch_id_val: + config_switch_ids.add(peer_switch_id_val) + + if switch_id_val and peer_switch_id_val: + desired_pairs[tuple(sorted([switch_id_val, peer_switch_id_val]))] = item + + for switch_id, switch in fabric_switches.items(): + if switch_id in processed_switches: + continue + + vpc_configured = switch.get(VpcFieldNames.VPC_CONFIGURED, False) + vpc_data = switch.get("vpcData", {}) + + if vpc_configured and vpc_data: + peer_switch_id = vpc_data.get("peerSwitchId") + processed_switches.add(switch_id) + processed_switches.add(peer_switch_id) + + # For configured pairs, prefer direct vPC query as the source of truth. + # Recommendation payloads can be stale for useVirtualPeerLink. + try: + vpc_pair_path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = 5 + try: + direct_vpc = nd_v2.request(vpc_pair_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + except (NDModuleError, Exception): + direct_vpc = None + + if direct_vpc: + resolved_peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) or peer_switch_id + if resolved_peer_switch_id: + processed_switches.add(resolved_peer_switch_id) + use_vpl = _get_api_field_value(direct_vpc, "useVirtualPeerLink", False) + + # Direct /vpcPair can be stale for a short period after delete. + # Cross-check overview to avoid reporting stale active pairs. + membership = _is_switch_in_vpc_pair( + nd_v2, fabric_name, switch_id, timeout=5 + ) + if membership is False: + pair_key = None + if resolved_peer_switch_id: + pair_key = tuple(sorted([switch_id, resolved_peer_switch_id])) + desired_item = desired_pairs.get(pair_key) if pair_key else None + desired_use_vpl = None + if desired_item: + desired_use_vpl = desired_item.get("use_virtual_peer_link") + if desired_use_vpl is None: + desired_use_vpl = desired_item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) + + # Narrow override: only trust direct payload for write states when + # it matches desired pair intent. This preserves idempotence without + # masking true post-delete stale data during gathered/deleted flows. + if state in ("merged", "replaced", "overridden") and desired_item is not None: + if desired_use_vpl is None or bool(desired_use_vpl) == bool(use_vpl): + nrm.module.warn( + f"Overview membership check returned 'not paired' for switch {switch_id}, " + "but direct /vpcPair matched requested config. Treating pair as active." + ) + membership = True + if membership is False: + pending_delete.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + have.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + # Direct query failed - fall back to recommendation. + try: + recommendation = _get_recommendation_details(nd_v2, fabric_name, switch_id) + except Exception as rec_error: + error_msg = str(rec_error).splitlines()[0] + nrm.module.warn( + f"Recommendation query failed for switch {switch_id}: {error_msg}. " + f"Unable to read configured vPC pair details." + ) + recommendation = None + + if recommendation: + resolved_peer_switch_id = _get_api_field_value(recommendation, "serialNumber") or peer_switch_id + if resolved_peer_switch_id: + processed_switches.add(resolved_peer_switch_id) + use_vpl = _get_api_field_value(recommendation, "useVirtualPeerLink", False) + have.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + # VPC configured but query failed - mark as pending delete + pending_delete.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: False, + }) + elif not config_switch_ids or switch_id in config_switch_ids: + # For unconfigured switches, prefer direct vPC pair query first. + # Recommendation endpoints can lag and may return stale useVirtualPeerLink. + try: + vpc_pair_path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = 5 + try: + direct_vpc = nd_v2.request(vpc_pair_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + except (NDModuleError, Exception): + direct_vpc = None + + if direct_vpc: + peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) + if peer_switch_id: + processed_switches.add(switch_id) + processed_switches.add(peer_switch_id) + + use_vpl = _get_api_field_value(direct_vpc, "useVirtualPeerLink", False) + membership = _is_switch_in_vpc_pair( + nd_v2, fabric_name, switch_id, timeout=5 + ) + if membership is False: + pair_key = tuple(sorted([switch_id, peer_switch_id])) + desired_item = desired_pairs.get(pair_key) + desired_use_vpl = None + if desired_item: + desired_use_vpl = desired_item.get("use_virtual_peer_link") + if desired_use_vpl is None: + desired_use_vpl = desired_item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) + + if state in ("merged", "replaced", "overridden") and desired_item is not None: + if desired_use_vpl is None or bool(desired_use_vpl) == bool(use_vpl): + nrm.module.warn( + f"Overview membership check returned 'not paired' for switch {switch_id}, " + "but direct /vpcPair matched requested config. Treating pair as active." + ) + membership = True + if membership is False: + pending_delete.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + have.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + # No direct pair; check recommendation for pending create candidates. + try: + recommendation = _get_recommendation_details(nd_v2, fabric_name, switch_id) + except Exception as rec_error: + error_msg = str(rec_error).splitlines()[0] + nrm.module.warn( + f"Recommendation query failed for switch {switch_id}: {error_msg}. " + f"No recommendation details available." + ) + recommendation = None + + if recommendation: + peer_switch_id = _get_api_field_value(recommendation, "serialNumber") + if peer_switch_id: + processed_switches.add(switch_id) + processed_switches.add(peer_switch_id) + + use_vpl = _get_api_field_value(recommendation, "useVirtualPeerLink", False) + pending_create.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + + # Step 4: Store all states for use in create/update/delete + nrm.module.params["_have"] = have + nrm.module.params["_pending_create"] = pending_create + nrm.module.params["_pending_delete"] = pending_delete + + # Build effective existing set for state reconciliation: + # - Include active pairs (have) and pending-create pairs. + # - Exclude pending-delete pairs from active set to avoid stale + # idempotence false-negatives right after unpair operations. + pair_by_key = {} + for pair in pending_create + have: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id or not peer_switch_id: + continue + key = tuple(sorted([switch_id, peer_switch_id])) + pair_by_key[key] = pair + + for pair in pending_delete: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id or not peer_switch_id: + continue + key = tuple(sorted([switch_id, peer_switch_id])) + pair_by_key.pop(key, None) + + existing_pairs = list(pair_by_key.values()) + + # Note: Memory optimization already applied at line 1219-1220 + # fabric_switches dict was converted to set immediately after query + return existing_pairs + + except NDModuleError as error: + error_dict = error.to_dict() + # Preserve original API error message with different key to avoid conflict + if 'msg' in error_dict: + error_dict['api_error_msg'] = error_dict.pop('msg') + nrm.module.fail_json( + msg=f"Failed to query VPC pairs: {error.msg}", + fabric=fabric_name, + **error_dict + ) + except Exception as e: + nrm.module.fail_json( + msg=f"Failed to query VPC pairs: {str(e)}", + fabric=fabric_name, + exception_type=type(e).__name__ + ) + + +def custom_vpc_create(nrm) -> Optional[Dict[str, Any]]: + """ + Custom create function for VPC pairs using RestSend with PUT + discriminator. + - Validates switches exist in fabric (Common.validate_switches_exist) + - Checks for switch conflicts (Common.validate_no_switch_conflicts) + - Uses PUT instead of POST (non-RESTful API) + - Adds vpcAction: "pair" discriminator + - Proper error handling with NDModuleError + - Results aggregation + + Args: + nrm: NDStateMachine instance + + Returns: + API response dictionary or None + + Raises: + ValueError: If fabric_name or switch_id is not provided + AnsibleModule.fail_json: If validation fails + """ + if nrm.module.check_mode: + return nrm.proposed_config + + fabric_name = nrm.module.params.get("fabric_name") + switch_id = nrm.proposed_config.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = nrm.proposed_config.get(VpcFieldNames.PEER_SWITCH_ID) + + # Path validation + if not fabric_name: + raise ValueError("fabric_name is required but was not provided") + if not switch_id: + raise ValueError("switch_id is required but was not provided") + if not peer_switch_id: + raise ValueError("peer_switch_id is required but was not provided") + + # Validation Step 1: both switches must exist in discovered fabric inventory. + _validate_switches_exist_in_fabric( + nrm=nrm, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + ) + + # Validation Step 2: Check for switch conflicts (from Common.validate_no_switch_conflicts) + have_vpc_pairs = nrm.module.params.get("_have", []) + if have_vpc_pairs: + _validate_switch_conflicts([nrm.proposed_config], have_vpc_pairs, nrm.module) + + # Validation Step 3: Check if create is actually needed (idempotence check) + if nrm.existing_config: + want_dict = nrm.proposed_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.proposed_config, 'model_dump') else nrm.proposed_config + have_dict = nrm.existing_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.existing_config, 'model_dump') else nrm.existing_config + + if not _is_update_needed(want_dict, have_dict): + # Already exists in desired state - return existing config without changes + nrm.module.warn( + f"VPC pair {nrm.current_identifier} already exists in desired state - skipping create" + ) + return nrm.existing_config + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + use_virtual_peer_link = nrm.proposed_config.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + # Validate pairing support using dedicated endpoint. + # Only fail when API explicitly states pairing is not allowed. + try: + support_details = _get_pairing_support_details( + nd_v2, + fabric_name=fabric_name, + switch_id=switch_id, + component_type=ComponentTypeSupportEnum.CHECK_PAIRING.value, + ) + if support_details: + is_pairing_allowed = _get_api_field_value( + support_details, "isPairingAllowed", None + ) + if is_pairing_allowed is False: + reason = _get_api_field_value( + support_details, "reason", "pairing blocked by support checks" + ) + nrm.module.fail_json( + msg=f"VPC pairing is not allowed for switch {switch_id}: {reason}", + fabric=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + support_details=support_details, + ) + except Exception as support_error: + nrm.module.warn( + f"Pairing support check failed for switch {switch_id}: " + f"{str(support_error).splitlines()[0]}. Continuing with create operation." + ) + + # Validate fabric peering support if virtual peer link is requested. + _validate_fabric_peering_support( + nrm=nrm, + nd_v2=nd_v2, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + use_virtual_peer_link=use_virtual_peer_link, + ) + + # Build path with switch ID using Manage API (not NDFC API) + # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available + # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + + # Build payload with discriminator using helper (supports vpc_pair_details) + payload = _build_vpc_pair_payload(nrm.proposed_config) + + # Log the operation + nrm.format_log( + identifier=nrm.current_identifier, + status="created", + after_data=payload, + sent_payload_data=payload + ) + + try: + # Use PUT (not POST!) for create via RestSend + response = nd_v2.request(path, HttpVerbEnum.PUT, payload) + return response + + except NDModuleError as error: + error_dict = error.to_dict() + # Preserve original API error message with different key to avoid conflict + if 'msg' in error_dict: + error_dict['api_error_msg'] = error_dict.pop('msg') + nrm.module.fail_json( + msg=f"Failed to create VPC pair {nrm.current_identifier}: {error.msg}", + fabric=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + path=path, + **error_dict + ) + except Exception as e: + nrm.module.fail_json( + msg=f"Failed to create VPC pair {nrm.current_identifier}: {str(e)}", + fabric=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + path=path, + exception_type=type(e).__name__ + ) + + +def custom_vpc_update(nrm) -> Optional[Dict[str, Any]]: + """ + Custom update function for VPC pairs using RestSend. + + - Uses PUT with discriminator (same as create) + - Validates switches exist in fabric + - Checks for switch conflicts + - Uses DeepDiff to detect if update is actually needed + - Proper error handling + + Args: + nrm: NDStateMachine instance + + Returns: + API response dictionary or None + + Raises: + ValueError: If fabric_name or switch_id is not provided + """ + if nrm.module.check_mode: + return nrm.proposed_config + + fabric_name = nrm.module.params.get("fabric_name") + switch_id = nrm.proposed_config.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = nrm.proposed_config.get(VpcFieldNames.PEER_SWITCH_ID) + + # Path validation + if not fabric_name: + raise ValueError("fabric_name is required but was not provided") + if not switch_id: + raise ValueError("switch_id is required but was not provided") + if not peer_switch_id: + raise ValueError("peer_switch_id is required but was not provided") + + # Validation Step 1: both switches must exist in discovered fabric inventory. + _validate_switches_exist_in_fabric( + nrm=nrm, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + ) + + # Validation Step 2: Check for switch conflicts (from Common.validate_no_switch_conflicts) + have_vpc_pairs = nrm.module.params.get("_have", []) + if have_vpc_pairs: + # Filter out the current VPC pair being updated + other_vpc_pairs = [ + vpc for vpc in have_vpc_pairs + if vpc.get(VpcFieldNames.SWITCH_ID) != switch_id + ] + if other_vpc_pairs: + _validate_switch_conflicts([nrm.proposed_config], other_vpc_pairs, nrm.module) + + # Validation Step 3: Check if update is actually needed using DeepDiff + if nrm.existing_config: + want_dict = nrm.proposed_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.proposed_config, 'model_dump') else nrm.proposed_config + have_dict = nrm.existing_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.existing_config, 'model_dump') else nrm.existing_config + + if not _is_update_needed(want_dict, have_dict): + # No changes needed - return existing config + nrm.module.warn( + f"VPC pair {nrm.current_identifier} is already in desired state - skipping update" + ) + return nrm.existing_config + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + use_virtual_peer_link = nrm.proposed_config.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + # Validate fabric peering support if virtual peer link is requested. + _validate_fabric_peering_support( + nrm=nrm, + nd_v2=nd_v2, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + use_virtual_peer_link=use_virtual_peer_link, + ) + + # Build path with switch ID using Manage API (not NDFC API) + # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available + # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + + # Build payload with discriminator using helper (supports vpc_pair_details) + payload = _build_vpc_pair_payload(nrm.proposed_config) + + # Log the operation + nrm.format_log( + identifier=nrm.current_identifier, + status="updated", + after_data=payload, + sent_payload_data=payload + ) + + try: + # Use PUT for update via RestSend + response = nd_v2.request(path, HttpVerbEnum.PUT, payload) + return response + + except NDModuleError as error: + error_dict = error.to_dict() + # Preserve original API error message with different key to avoid conflict + if 'msg' in error_dict: + error_dict['api_error_msg'] = error_dict.pop('msg') + nrm.module.fail_json( + msg=f"Failed to update VPC pair {nrm.current_identifier}: {error.msg}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + **error_dict + ) + except Exception as e: + nrm.module.fail_json( + msg=f"Failed to update VPC pair {nrm.current_identifier}: {str(e)}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + exception_type=type(e).__name__ + ) + + +def custom_vpc_delete(nrm) -> None: + """ + Custom delete function for VPC pairs using RestSend with PUT + discriminator. + + - Pre-deletion validation (network/VRF/interface checks) + - Uses PUT instead of DELETE (non-RESTful API) + - Adds vpcAction: "unpair" discriminator + - Proper error handling with NDModuleError + + Args: + nrm: NDStateMachine instance + + Raises: + ValueError: If fabric_name or switch_id is not provided + AnsibleModule.fail_json: If validation fails (networks/VRFs attached) + """ + if nrm.module.check_mode: + return + + fabric_name = nrm.module.params.get("fabric_name") + switch_id = nrm.existing_config.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = nrm.existing_config.get(VpcFieldNames.PEER_SWITCH_ID) + + # Path validation + if not fabric_name: + raise ValueError("fabric_name is required but was not provided") + if not switch_id: + raise ValueError("switch_id is required but was not provided") + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + + # CRITICAL: Pre-deletion validation to prevent data loss + # Checks for active networks, VRFs, and warns about vPC interfaces + vpc_pair_key = f"{switch_id}-{peer_switch_id}" if peer_switch_id else switch_id + + # Track whether force parameter was actually needed + force_delete = nrm.module.params.get("force", False) + validation_succeeded = False + + # Perform validation with timeout protection + try: + _validate_vpc_pair_deletion(nd_v2, fabric_name, switch_id, vpc_pair_key, nrm.module) + validation_succeeded = True + + # If force was enabled but validation succeeded, inform user it wasn't needed + if force_delete: + nrm.module.warn( + f"Force deletion was enabled for {vpc_pair_key}, but pre-deletion validation succeeded. " + f"The 'force: true' parameter was not necessary in this case. " + f"Consider removing 'force: true' to benefit from safety checks in future runs." + ) + + except ValueError as already_unpaired: + # Sentinel from _validate_vpc_pair_deletion: pair no longer exists. + # Treat as idempotent success — nothing to delete. + nrm.module.warn(str(already_unpaired)) + return + + except (NDModuleError, Exception) as validation_error: + # Validation failed - check if force deletion is enabled + if not force_delete: + nrm.module.fail_json( + msg=( + f"Pre-deletion validation failed for VPC pair {vpc_pair_key}. " + f"Error: {str(validation_error)}. " + f"If you're certain the VPC pair can be safely deleted, use 'force: true' parameter. " + f"WARNING: Force deletion bypasses safety checks and may cause data loss." + ), + vpc_pair_key=vpc_pair_key, + validation_error=str(validation_error), + force_available=True + ) + else: + # Force enabled and validation failed - this is when force was actually needed + nrm.module.warn( + f"Force deletion enabled for {vpc_pair_key} - bypassing pre-deletion validation. " + f"Validation error was: {str(validation_error)}. " + f"WARNING: Proceeding without safety checks - ensure no data loss will occur." + ) + + # Build path with switch ID using Manage API (not NDFC API) + # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available + # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + + # Build minimal payload with discriminator for delete + payload = { + VpcFieldNames.VPC_ACTION: VpcActionEnum.UNPAIR.value, # ← Discriminator for DELETE + VpcFieldNames.SWITCH_ID: nrm.existing_config.get(VpcFieldNames.SWITCH_ID), + VpcFieldNames.PEER_SWITCH_ID: nrm.existing_config.get(VpcFieldNames.PEER_SWITCH_ID) + } + + # Log the operation + nrm.format_log( + identifier=nrm.current_identifier, + status="deleted", + sent_payload_data=payload + ) + + try: + # Use PUT (not DELETE!) for unpair via RestSend + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = nrm.module.params.get("api_timeout", 30) + try: + nd_v2.request(path, HttpVerbEnum.PUT, payload) + finally: + rest_send.restore_settings() + + except NDModuleError as error: + error_msg = str(error.msg).lower() if error.msg else "" + status_code = error.status or 0 + + # Idempotent handling: if the API says the switch is not part of any + # vPC pair, the pair is already gone — treat as a successful no-op. + if status_code == 400 and "not a part of" in error_msg: + nrm.module.warn( + f"VPC pair {nrm.current_identifier} is already unpaired on the controller. " + f"Treating as idempotent success. API response: {error.msg}" + ) + return + + error_dict = error.to_dict() + # Preserve original API error message with different key to avoid conflict + if 'msg' in error_dict: + error_dict['api_error_msg'] = error_dict.pop('msg') + nrm.module.fail_json( + msg=f"Failed to delete VPC pair {nrm.current_identifier}: {error.msg}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + **error_dict + ) + except Exception as e: + nrm.module.fail_json( + msg=f"Failed to delete VPC pair {nrm.current_identifier}: {str(e)}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + exception_type=type(e).__name__ + ) + + +def _needs_deployment(result: Dict, nrm) -> bool: + """ + Determine if deployment is needed based on changes and pending operations. + + Deployment is needed if any of: + 1. There are items in the diff (configuration changes) + 2. There are pending create VPC pairs + 3. There are pending delete VPC pairs + + Args: + result: Module result dictionary with diff info + nrm: NDStateMachine instance + + Returns: + True if deployment is needed, False otherwise + """ + # Check if there are any changes in the result + has_changes = result.get("changed", False) + + # Check diff - framework stores before/after + before = result.get("before", []) + after = result.get("after", []) + has_diff_changes = before != after + + # Check pending operations + pending_create = nrm.module.params.get("_pending_create", []) + pending_delete = nrm.module.params.get("_pending_delete", []) + has_pending = bool(pending_create or pending_delete) + + needs_deploy = has_changes or has_diff_changes or has_pending + + return needs_deploy + + +def _is_non_fatal_config_save_error(error: NDModuleError) -> bool: + """ + Return True only for known non-fatal configSave platform limitations. + """ + if not isinstance(error, NDModuleError): + return False + + # Keep this allowlist tight to avoid masking real config-save failures. + if error.status != 500: + return False + + message = (error.msg or "").lower() + non_fatal_signatures = ( + "vpc fabric peering is not supported", + "vpcsanitycheck", + "unexpected error generating vpc configuration", + ) + return any(signature in message for signature in non_fatal_signatures) + + +def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: + """ + Custom deploy function for fabric configuration changes using RestSend. + + - Smart deployment decision (Common.needs_deployment) + - Step 1: Save fabric configuration + - Step 2: Deploy fabric with forceShowRun=true + - Proper error handling with NDModuleError + - Results aggregation + - Only deploys if there are actual changes or pending operations + + Args: + nrm: NDStateMachine instance + fabric_name: Fabric name to deploy + result: Module result dictionary to check for changes + + Returns: + Deployment result dictionary + + Raises: + NDModuleError: If deployment fails + """ + # Smart deployment decision (from Common.needs_deployment) + if not _needs_deployment(result, nrm): + return { + "msg": "No configuration changes or pending operations detected, skipping deployment", + "fabric": fabric_name, + "deployment_needed": False, + "changed": False + } + + if nrm.module.check_mode: + # Dry run deployment info (similar to show_dry_run_deployment_info) + before = result.get("before", []) + after = result.get("after", []) + pending_create = nrm.module.params.get("_pending_create", []) + pending_delete = nrm.module.params.get("_pending_delete", []) + + deployment_info = { + "msg": "CHECK MODE: Would save and deploy fabric configuration", + "fabric": fabric_name, + "deployment_needed": True, + "changed": True, + "would_deploy": True, + "deployment_decision_factors": { + "diff_has_changes": before != after, + "pending_create_operations": len(pending_create), + "pending_delete_operations": len(pending_delete), + "actual_changes": result.get("changed", False) + }, + "planned_actions": [ + f"POST {VpcPairEndpoints.fabric_config_save(fabric_name)}", + f"POST {VpcPairEndpoints.fabric_config_deploy(fabric_name, force_show_run=True)}" + ] + } + return deployment_info + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + results = Results() + + # Step 1: Save config + save_path = VpcPairEndpoints.fabric_config_save(fabric_name) + + try: + nd_v2.request(save_path, HttpVerbEnum.POST, {}) + + results.response_current = { + "RETURN_CODE": nd_v2.status, + "METHOD": "POST", + "REQUEST_PATH": save_path, + "MESSAGE": "Config saved successfully", + "DATA": {}, + } + results.result_current = {"success": True, "changed": True} + results.register_task_result() + + except NDModuleError as error: + if _is_non_fatal_config_save_error(error): + # Known platform limitation warning; continue to deploy step. + nrm.module.warn(f"Config save failed: {error.msg}") + + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "REQUEST_PATH": save_path, + "METHOD": "POST", + "DATA": {}, + } + results.result_current = {"success": True, "changed": False} + results.register_task_result() + else: + # Unknown config-save failures are fatal. + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "REQUEST_PATH": save_path, + "METHOD": "POST", + "DATA": {}, + } + results.result_current = {"success": False, "changed": False} + results.register_task_result() + results.build_final_result() + nrm.module.fail_json(msg=f"Config save failed: {error.msg}", **results.final_result) + + # Step 2: Deploy + deploy_path = VpcPairEndpoints.fabric_config_deploy(fabric_name, force_show_run=True) + + try: + nd_v2.request(deploy_path, HttpVerbEnum.POST, {}) + + results.response_current = { + "RETURN_CODE": nd_v2.status, + "METHOD": "POST", + "REQUEST_PATH": deploy_path, + "MESSAGE": "Deployment successful", + "DATA": {}, + } + results.result_current = {"success": True, "changed": True} + results.register_task_result() + + except NDModuleError as error: + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "REQUEST_PATH": deploy_path, + "METHOD": "POST", + "DATA": {}, + } + results.result_current = {"success": False, "changed": False} + results.register_task_result() + + # Build final result and fail + results.build_final_result() + nrm.module.fail_json(**results.final_result) + + # Build final result + results.build_final_result() + return results.final_result + + +def run_vpc_module(nrm) -> Dict[str, Any]: + """ + Run VPC module state machine with VPC-specific gathered output. + + gathered is the query/read-only mode for VPC pairs. + """ + state = nrm.module.params.get("state", "merged") + config = nrm.module.params.get("config", []) + + if state == "gathered": + nrm.add_logs_and_outputs() + nrm.result["changed"] = False + + current_pairs = nrm.result.get("current", []) or [] + pending_delete = nrm.module.params.get("_pending_delete", []) or [] + + # Exclude pairs in pending-delete from active gathered set. + pending_delete_keys = set() + for pair in pending_delete: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") + if switch_id and peer_switch_id: + pending_delete_keys.add(tuple(sorted([switch_id, peer_switch_id]))) + + filtered_current = [] + for pair in current_pairs: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") + if switch_id and peer_switch_id: + pair_key = tuple(sorted([switch_id, peer_switch_id])) + if pair_key in pending_delete_keys: + continue + filtered_current.append(pair) + + nrm.result["current"] = filtered_current + nrm.result["gathered"] = { + "vpc_pairs": filtered_current, + "pending_create_vpc_pairs": nrm.module.params.get("_pending_create", []), + "pending_delete_vpc_pairs": pending_delete, + } + return nrm.result + + # state=deleted with empty config means "delete all existing pairs in this fabric". + # + # state=overridden with empty config has the same user intent (TC4): + # remove all existing pairs from this fabric. + if state in ("deleted", "overridden") and not config: + # Use the live existing collection from NDStateMachine. + # nrm.result["current"] is only populated after add_logs_and_outputs(), so relying on + # it here would incorrectly produce an empty delete list. + existing_pairs = _collection_to_list_flex(getattr(nrm, "existing", None)) + if not existing_pairs: + existing_pairs = nrm.result.get("current", []) or [] + + delete_all_config = [] + for pair in existing_pairs: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") + if switch_id and peer_switch_id: + use_vpl = pair.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) + if use_vpl is None: + use_vpl = pair.get("use_virtual_peer_link", True) + delete_all_config.append( + { + "switch_id": switch_id, + "peer_switch_id": peer_switch_id, + "use_virtual_peer_link": use_vpl, + } + ) + config = delete_all_config + # Force explicit delete operations instead of relying on overridden-state + # reconciliation behavior with empty desired config. + if state == "overridden": + state = "deleted" + + nrm.manage_state(state=state, new_configs=config) + nrm.add_logs_and_outputs() + return nrm.result + + +# ===== Module Entry Point ===== + + +def main(): + """ + Module entry point combining framework + RestSend. + + Architecture: + - Thin module entrypoint delegates to VpcPairResourceService + - VpcPairResourceService handles NDStateMachine orchestration + - Custom actions use RestSend (NDModuleV2) for HTTP with retry logic + """ + argument_spec = dict( + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "deleted", "overridden", "gathered"], + ), + fabric_name=dict(type="str", required=True), + deploy=dict(type="bool", default=False), + dry_run=dict(type="bool", default=False), + force=dict( + type="bool", + default=False, + description="Force deletion without pre-deletion validation (bypasses safety checks)" + ), + api_timeout=dict( + type="int", + default=30, + description="API request timeout in seconds for primary operations" + ), + query_timeout=dict( + type="int", + default=10, + description="API request timeout in seconds for query/recommendation operations" + ), + config=dict( + type="list", + elements="dict", + options=dict( + peer1_switch_id=dict(type="str", required=True, aliases=["switch_id"]), + peer2_switch_id=dict(type="str", required=True, aliases=["peer_switch_id"]), + use_virtual_peer_link=dict(type="bool", default=True), + vpc_pair_details=dict(type="dict"), + ), + ), + ) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + # Module-level validations + if sys.version_info < (3, 9): + module.fail_json(msg="Python version 3.9 or higher is required for this module.") + + if not HAS_DEEPDIFF: + module.fail_json( + msg=missing_required_lib("deepdiff"), + exception=DEEPDIFF_IMPORT_ERROR + ) + + # State-specific parameter validations + state = module.params.get("state") + deploy = module.params.get("deploy") + dry_run = module.params.get("dry_run") + + if state == "gathered" and deploy: + module.fail_json(msg="Deploy parameter cannot be used with 'gathered' state") + + if state == "gathered" and dry_run: + module.fail_json(msg="Dry_run parameter cannot be used with 'gathered' state") + + # Map dry_run to check_mode + if dry_run: + module.check_mode = True + + # Validate force parameter usage: + # - state=deleted + # - state=overridden with empty config (interpreted as delete-all) + force = module.params.get("force", False) + state = module.params.get("state", "merged") + user_config = module.params.get("config") or [] + force_applicable = state == "deleted" or ( + state == "overridden" and len(user_config) == 0 + ) + if force and not force_applicable: + module.warn( + "Parameter 'force' only applies to state 'deleted' or to " + "state 'overridden' when config is empty (delete-all behavior). " + f"Ignoring force for state '{state}'." + ) + + # Normalize config keys for model + config = module.params.get("config") or [] + normalized_config = [] + + for item in config: + normalized = { + "switch_id": item.get("peer1_switch_id") or item.get("switch_id"), + "peer_switch_id": item.get("peer2_switch_id") or item.get("peer_switch_id"), + "use_virtual_peer_link": item.get("use_virtual_peer_link", True), + "vpc_pair_details": item.get("vpc_pair_details"), + } + normalized_config.append(normalized) + + module.params["config"] = normalized_config + + # Gather must remain strictly read-only. Preserve user-provided config as a + # query filter, but clear the framework desired config to avoid unintended + # reconciliation before run_vpc_module() handles gathered output. + if state == "gathered": + module.params["_gather_filter_config"] = list(normalized_config) + module.params["config"] = [] + else: + module.params["_gather_filter_config"] = [] + + # VpcPairResourceService bridges NDStateMachine lifecycle hooks to RestSend actions. + fabric_name = module.params.get("fabric_name") + actions = { + "query_all": custom_vpc_query_all, + "create": custom_vpc_create, + "update": custom_vpc_update, + "delete": custom_vpc_delete, + } + + try: + service = VpcPairResourceService( + module=module, + model_class=VpcPairModel, + actions=actions, + run_state_handler=run_vpc_module, + deploy_handler=custom_vpc_deploy, + needs_deployment_handler=_needs_deployment, + ) + result = service.execute(fabric_name=fabric_name) + + module.exit_json(**result) + + except Exception as e: + module.fail_json(msg=str(e)) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml new file mode 100644 index 00000000..82279bfe --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml @@ -0,0 +1,261 @@ +- name: ND vPC pair delete tests + hosts: nd + gather_facts: false + tasks: + ############################################## + ## SETUP ## + ############################################## + + - name: DELETE - Test Entry Point - [nd_vpc_pair] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ Executing Delete Tests - [nd_vpc_pair] +" + - "----------------------------------------------------------------" + tags: delete + + ############################################## + ## Setup Internal TestCase Variables ## + ############################################## + + - name: DELETE - Setup Internal TestCase Variables + ansible.builtin.set_fact: + deploy_local: true + test_fabric: "{{ fabric_name }}" + test_switch1: "{{ switch1_serial }}" + test_switch2: "{{ switch2_serial }}" + test_data_deleted: + vpc_pair_setup_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + use_virtual_peer_link: true + tags: delete + + ############################################## + ## DELETE ## + ############################################## + + # TC1 - Setup: Create vPC pair for deletion tests + - name: DELETE - TC1 - DELETE - Clean up any existing vPC pairs + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + tags: delete + + - name: DELETE - TC1 - MERGE - Create vPC pair for deletion testing + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ test_data_deleted.vpc_pair_setup_conf }}" + register: result + tags: delete + + - name: DELETE - TC1 - ASSERT - Check if creation successful + ansible.builtin.assert: + that: + - result.changed == true + - result.failed == false + tags: delete + + - name: DELETE - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + + - name: DELETE - TC1 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + tags: delete + + # TC2 - Delete vPC pair with specific config + - name: DELETE - TC2 - DELETE - Delete vPC pair with specific peer config + cisco.nd.nd_vpc_pair: &delete_specific + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: delete + + - name: DELETE - TC2 - ASSERT - Check if deletion successful + ansible.builtin.assert: + that: + - result.changed == true + - result.failed == false + tags: delete + + - name: DELETE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + + - name: DELETE - TC2 - ASSERT - Verify vPC pair deletion + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 0' + tags: delete + + # TC3 - Idempotence test for deletion + - name: DELETE - TC3 - conf - Idempotence + cisco.nd.nd_vpc_pair: *delete_specific + register: result + tags: delete + + - name: DELETE - TC3 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: delete + + # TC4 - Create another vPC pair for bulk deletion test + - name: DELETE - TC4 - MERGE - Create vPC pair for bulk deletion testing + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ test_data_deleted.vpc_pair_setup_conf }}" + register: result + tags: delete + + - name: DELETE - TC4 - ASSERT - Check if creation successful + ansible.builtin.assert: + that: + - result.changed == true + - result.failed == false + tags: delete + + - name: DELETE - TC4 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + + - name: DELETE - TC4 - ASSERT - Verify vPC pair state in ND for bulk deletion setup + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + tags: delete + + # TC5 - Delete all vPC pairs without specific config + - name: DELETE - TC5 - DELETE - Delete all vPC pairs without specific config + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + register: result + tags: delete + + - name: DELETE - TC5 - ASSERT - Check if bulk deletion successful + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true or (result.current | length) == 0 + tags: delete + + - name: DELETE - TC5 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + register: verify_result + tags: delete + + - name: DELETE - TC5 - ASSERT - Verify bulk deletion + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 0' + tags: delete + + # TC6 - Delete from empty fabric (should be no-op) + - name: DELETE - TC6 - DELETE - Delete from empty fabric (no-op) + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + register: result + tags: delete + + - name: DELETE - TC6 - ASSERT - Check if no change occurred + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: delete + + # TC7 - Force deletion bypass path + - name: DELETE - TC7 - MERGE - Create vPC pair for force delete test + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ test_data_deleted.vpc_pair_setup_conf }}" + register: result + tags: delete + + - name: DELETE - TC7 - ASSERT - Verify setup creation for force test + ansible.builtin.assert: + that: + - result.failed == false + tags: delete + + - name: DELETE - TC7 - DELETE - Delete vPC pair with force true + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + force: true + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: delete + + - name: DELETE - TC7 - ASSERT - Verify force delete execution + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: delete + + - name: DELETE - TC7 - GATHER - Verify force deletion result in ND + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + + - name: DELETE - TC7 - ASSERT - Confirm pair deleted with force + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 0' + tags: delete + + ############################################## + ## CLEAN-UP ## + ############################################## + + - name: DELETE - END - ensure clean state + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: delete diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml new file mode 100644 index 00000000..f34d9b80 --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml @@ -0,0 +1,289 @@ +- name: ND vPC pair gather tests + hosts: nd + gather_facts: false + tasks: + ############################################## + ## SETUP ## + ############################################## + + - name: GATHER - Test Entry Point - [nd_vpc_pair] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ Executing Gather Tests - [nd_vpc_pair] +" + - "----------------------------------------------------------------" + tags: gather + + ############################################## + ## Setup Internal TestCase Variables ## + ############################################## + + - name: GATHER - Setup Internal TestCase Variables + ansible.builtin.set_fact: + deploy_local: true + test_fabric: "{{ fabric_name }}" + test_switch1: "{{ switch1_serial }}" + test_switch2: "{{ switch2_serial }}" + test_data_query: + vpc_pair_setup_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + use_virtual_peer_link: true + tags: gather + + ############################################## + ## GATHER ## + ############################################## + + # TC1 - Setup: Create vPC pair for gather tests + - name: GATHER - TC1 - DELETE - Clean up any existing vPC pairs + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + tags: gather + + - name: GATHER - TC1 - MERGE - Create vPC pair for testing + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ test_data_query.vpc_pair_setup_conf }}" + register: result + tags: gather + + - name: GATHER - TC1 - ASSERT - Check if creation successful + ansible.builtin.assert: + that: + - result.failed == false + tags: gather + + - name: GATHER - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + tags: gather + + - name: GATHER - TC1 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + tags: gather + + # TC2 - Gather with no filters + - name: GATHER - TC2 - GATHER - Gather all vPC pairs with no filters + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + register: result + tags: gather + + - name: GATHER - TC2 - ASSERT - Check gather results + ansible.builtin.assert: + that: + - result.failed == false + - '(result.gathered.vpc_pairs | length) == 1' + tags: gather + + # TC3 - Gather with both peers specified + - name: GATHER - TC3 - GATHER - Gather vPC pair with both peers specified + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: gather + + - name: GATHER - TC3 - ASSERT - Check gather results with both peers + ansible.builtin.assert: + that: + - result.failed == false + - '(result.gathered.vpc_pairs | length) == 1' + tags: gather + + # TC4 - Gather with one peer specified (not supported in nd_vpc_pair) + - name: GATHER - TC4 - GATHER - Gather vPC pair with one peer specified + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + register: result + ignore_errors: true + tags: gather + + - name: GATHER - TC4 - ASSERT - Verify partial peer gather is rejected + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is defined + tags: gather + + # TC5 - Gather with second peer specified (not supported in nd_vpc_pair) + - name: GATHER - TC5 - GATHER - Gather vPC pair with second peer specified + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer2_switch_id: "{{ test_switch2 }}" + register: result + ignore_errors: true + tags: gather + + - name: GATHER - TC5 - ASSERT - Verify partial peer gather is rejected + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is defined + tags: gather + + # TC6 - Gather with non-existent peer + - name: GATHER - TC6 - GATHER - Gather vPC pair with non-existent peer + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "INVALID_SERIAL" + peer2_switch_id: "{{ test_switch2 }}" + register: result + ignore_errors: true + tags: gather + + - name: GATHER - TC6 - ASSERT - Check gather results with non-existent peer + ansible.builtin.assert: + that: + - result.failed == false + tags: gather + + # TC7 - Gather with custom query_timeout + - name: GATHER - TC7 - GATHER - Gather with query_timeout override + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + query_timeout: 20 + register: result + tags: gather + + - name: GATHER - TC7 - ASSERT - Verify query_timeout path execution + ansible.builtin.assert: + that: + - result.failed == false + - result.gathered is defined + tags: gather + + # TC8 - gathered + deploy validation (must fail) + - name: GATHER - TC8 - GATHER - Gather with deploy enabled (invalid) + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + deploy: true + register: result + ignore_errors: true + tags: gather + + - name: GATHER - TC8 - ASSERT - Verify gathered+deploy validation + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is search("Deploy parameter cannot be used") + tags: gather + + # TC9 - gathered + dry_run validation (must fail) + - name: GATHER - TC9 - GATHER - Gather with dry_run enabled (invalid) + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + dry_run: true + register: result + ignore_errors: true + tags: gather + + - name: GATHER - TC9 - ASSERT - Verify gathered+dry_run validation + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is search("Dry_run parameter cannot be used") + tags: gather + + # TC10 - Validate /vpcPairs list API alignment with module gathered output + - name: GATHER - TC10 - LIST - Query vPC pairs list endpoint directly + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/vpcPairs" + method: get + register: vpc_pairs_list_result + tags: gather + + - name: GATHER - TC10 - GATHER - Query module gathered output for comparison + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + register: gathered_result + tags: gather + + - name: GATHER - TC10 - ASSERT - Verify list and gathered payload availability + ansible.builtin.assert: + that: + - vpc_pairs_list_result.failed == false + - vpc_pairs_list_result.current.vpcPairs is defined + - vpc_pairs_list_result.current.vpcPairs is sequence + - gathered_result.failed == false + - gathered_result.gathered.vpc_pairs is defined + tags: gather + + - name: GATHER - TC10 - ASSERT - Ensure each /vpcPairs entry appears in gathered output + ansible.builtin.assert: + that: + - >- + ( + ( + gathered_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', item.switchId) + | selectattr('peer_switch_id', 'equalto', item.peerSwitchId) + | list + | length + ) > 0 + ) or + ( + ( + gathered_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', item.peerSwitchId) + | selectattr('peer_switch_id', 'equalto', item.switchId) + | list + | length + ) > 0 + ) + loop: "{{ vpc_pairs_list_result.current.vpcPairs | default([]) }}" + tags: gather + + # TC11 - Validate normalized pair matching for reversed switch order + - name: GATHER - TC11 - GATHER - Gather with reversed/duplicate pair filters + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + - peer1_switch_id: "{{ test_switch2 }}" + peer2_switch_id: "{{ test_switch1 }}" + register: result + tags: gather + + - name: GATHER - TC11 - ASSERT - Verify one pair returned for reversed filters + ansible.builtin.assert: + that: + - result.failed == false + - '(result.gathered.vpc_pairs | length) == 1' + tags: gather + + ############################################## + ## CLEAN-UP ## + ############################################## + + - name: GATHER - END - remove vPC pairs + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: gather diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml new file mode 100644 index 00000000..1b9a0b30 --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml @@ -0,0 +1,658 @@ +- name: ND vPC pair merge tests + hosts: nd + gather_facts: false + tasks: + ############################################## + ## SETUP ## + ############################################## + + - name: MERGE - Test Entry Point - [nd_vpc_pair] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ Executing Merge Tests - [nd_vpc_pair] +" + - "----------------------------------------------------------------" + tags: merge + + ############################################## + ## Setup Internal TestCase Variables ## + ############################################## + + - name: MERGE - Setup Internal TestCase Variables + ansible.builtin.set_fact: + deploy_local: false + test_fabric: "{{ fabric_name }}" + test_switch1: "{{ switch1_serial }}" + test_switch2: "{{ switch2_serial }}" + test_fabric_type: "{{ fabric_type | default('LANClassic') }}" + test_data_merged: + vpc_pair_full_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + use_virtual_peer_link: true + vpc_pair_full_deployed_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + use_virtual_peer_link: true + vpc_pair_modified_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + use_virtual_peer_link: false + vpc_pair_minimal_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + vpc_pair_no_deploy_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + use_virtual_peer_link: true + tags: merge + + - name: MERGE - Change deploy to true + ansible.builtin.set_fact: + deploy_local: true + tags: merge + + ############################################## + ## MERGE ## + ############################################## + + # TC1 - Create vPC pair with full configuration + - name: MERGE - TC1 - DELETE - Clean up any existing vPC pairs + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + tags: merge + + - name: MERGE - TC1 - MERGE - Create vPC pair with full configuration + cisco.nd.nd_vpc_pair: &conf_full + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ test_data_merged.vpc_pair_full_conf }}" + register: result + tags: merge + + - name: MERGE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: merge + + - name: MERGE - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + + - name: MERGE - TC1 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + tags: merge + + - name: MERGE - TC1 - conf - Idempotence + cisco.nd.nd_vpc_pair: *conf_full + register: result + tags: merge + + - name: MERGE - TC1 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: merge + + # TC2 - Modify existing vPC pair configuration + - name: MERGE - TC2 - MERGE - Modify vPC pair configuration + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ test_data_merged.vpc_pair_modified_conf }}" + register: result + when: test_fabric_type == "LANClassic" + tags: merge + + - name: MERGE - TC2 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + + - name: MERGE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + when: test_fabric_type == "LANClassic" + tags: merge + + - name: MERGE - TC2 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + - verify_result.gathered.vpc_pairs[0].use_virtual_peer_link == false + when: test_fabric_type == "LANClassic" + tags: merge + + # TC2b - VXLANFabric specific test + - name: MERGE - TC2b - MERGE - Merge vPC pair for VXLAN fabric + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + when: test_fabric_type == "VXLANFabric" + tags: merge + + - name: MERGE - TC2b - ASSERT - Check if changed flag is false for VXLAN + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "VXLANFabric" + tags: merge + + - name: MERGE - TC2b - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + when: test_fabric_type == "VXLANFabric" + tags: merge + + # TC3 - Delete vPC pair + - name: MERGE - TC3 - DELETE - Delete vPC pair + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: merge + + - name: MERGE - TC3 - ASSERT - Check if delete successfully + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + + - name: MERGE - TC3 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + + - name: MERGE - TC3 - ASSERT - Verify vPC pair deletion + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 0' + tags: merge + + # TC4 - Create vPC pair with minimal configuration + - name: MERGE - TC4 - MERGE - Create vPC pair with minimal configuration + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ test_data_merged.vpc_pair_minimal_conf }}" + register: result + when: test_fabric_type == "LANClassic" + tags: merge + + - name: MERGE - TC4 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + + - name: MERGE - TC4 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + when: test_fabric_type == "LANClassic" + tags: merge + + - name: MERGE - TC4 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + when: test_fabric_type == "LANClassic" + tags: merge + + # TC4b - Delete vPC pair after minimal test + - name: MERGE - TC4b - DELETE - Delete vPC pair after minimal test + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + when: test_fabric_type == "LANClassic" + tags: merge + + - name: MERGE - TC4b - ASSERT - Check if delete successfully + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + + # TC5 - Create vPC pair with defaults (state omitted) + - name: MERGE - TC5 - MERGE - Create vPC pair with defaults + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + config: "{{ test_data_merged.vpc_pair_minimal_conf }}" + register: result + tags: merge + + - name: MERGE - TC5 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + + - name: MERGE - TC5 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + + - name: MERGE - TC5 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + tags: merge + + # TC5b - Delete vPC pair after defaults test + - name: MERGE - TC5b - DELETE - Delete vPC pair after defaults test + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: merge + + - name: MERGE - TC5b - ASSERT - Check if delete successfully + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + + # TC6 - Create vPC pair with deploy flag false + - name: MERGE - TC6 - MERGE - Create vPC pair with deploy false + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + deploy: false + config: "{{ test_data_merged.vpc_pair_no_deploy_conf }}" + register: result + tags: merge + + - name: MERGE - TC6 - ASSERT - Check if changed flag is true and no deploy + ansible.builtin.assert: + that: + - result.failed == false + - result.deployment is not defined + tags: merge + + - name: MERGE - TC6 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + + - name: MERGE - TC6 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + tags: merge + + # TC7 - Merge with vpc_pair_details default template settings + - name: MERGE - TC7 - MERGE - Update vPC pair with default vpc_pair_details + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + vpc_pair_details: + type: default + domain_id: 10 + switch_keep_alive_local_ip: "192.0.2.11" + peer_switch_keep_alive_local_ip: "192.0.2.12" + keep_alive_vrf: management + register: result + ignore_errors: true + tags: merge + + - name: MERGE - TC7 - ASSERT - Verify default vpc_pair_details path + ansible.builtin.assert: + that: + - result.failed == false or (result.failed == true and ("Failed to update VPC pair" in result.msg or "Failed to create VPC pair" in result.msg)) + tags: merge + + # TC8 - Merge with vpc_pair_details custom template settings + - name: MERGE - TC8 - MERGE - Update vPC pair with custom vpc_pair_details + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + vpc_pair_details: + type: custom + template_name: "my_custom_template" + template_config: + domainId: "20" + customConfig: "vpc domain 20" + register: result + ignore_errors: true + tags: merge + + - name: MERGE - TC8 - ASSERT - Verify custom vpc_pair_details path + ansible.builtin.assert: + that: + - result.failed == false or (result.failed == true and ("Failed to update VPC pair" in result.msg or "Failed to create VPC pair" in result.msg)) + tags: merge + + # TC9 - Test invalid configurations + - name: MERGE - TC9 - MERGE - Create vPC pair with invalid peer switch + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "INVALID_SERIAL" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + register: result + ignore_errors: true + tags: merge + + - name: MERGE - TC9 - ASSERT - Check invalid peer switch error + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is defined + tags: merge + + # TC10 - Create vPC pair with deploy enabled (actual deployment path) + - name: MERGE - TC10 - DELETE - Ensure vPC pair is absent before deploy test + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + ignore_errors: true + tags: merge + + - name: MERGE - TC10 - PREP - Query fabric peering support for switch1 + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch1 }}/vpcPairSupport?componentType=checkFabricPeeringSupport" + method: get + register: tc10_support_switch1 + ignore_errors: true + tags: merge + + - name: MERGE - TC10 - PREP - Query fabric peering support for switch2 + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch2 }}/vpcPairSupport?componentType=checkFabricPeeringSupport" + method: get + register: tc10_support_switch2 + ignore_errors: true + tags: merge + + - name: MERGE - TC10 - PREP - Decide virtual peer link flag for deploy test + ansible.builtin.set_fact: + tc10_use_virtual_peer_link: >- + {{ + (not (tc10_support_switch1.failed | default(false))) and + (not (tc10_support_switch2.failed | default(false))) and + (tc10_support_switch1.current.isVpcFabricPeeringSupported | default(false) | bool) and + (tc10_support_switch2.current.isVpcFabricPeeringSupported | default(false) | bool) + }} + tags: merge + + - name: MERGE - TC10 - MERGE - Create vPC pair with deploy true + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + deploy: true + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: "{{ tc10_use_virtual_peer_link }}" + register: result + tags: merge + + - name: MERGE - TC10 - ASSERT - Verify deploy path execution + ansible.builtin.assert: + that: + - result.failed == false + - result.deployment is defined + tags: merge + + # TC11 - Delete with custom api_timeout + - name: MERGE - TC11 - DELETE - Delete vPC pair with api_timeout override + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + api_timeout: 60 + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: merge + + - name: MERGE - TC11 - ASSERT - Verify api_timeout path execution + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + + # TC12 - dry_run should not apply configuration changes + - name: MERGE - TC12 - DELETE - Ensure vPC pair is absent before dry_run test + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + ignore_errors: true + tags: merge + + - name: MERGE - TC12 - MERGE - Run dry_run create for vPC pair + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + dry_run: true + config: "{{ test_data_merged.vpc_pair_full_conf }}" + register: result + tags: merge + + - name: MERGE - TC12 - ASSERT - Verify dry_run invocation succeeded + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + + - name: MERGE - TC12 - GATHER - Verify dry_run did not create vPC pair + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + + - name: MERGE - TC12 - ASSERT - Confirm no persistent changes from dry_run + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 0' + tags: merge + + # TC13 - Native Ansible check_mode should not apply configuration changes + - name: MERGE - TC13 - MERGE - Run check_mode create for vPC pair + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ test_data_merged.vpc_pair_full_conf }}" + check_mode: true + register: result + tags: merge + + - name: MERGE - TC13 - ASSERT - Verify check_mode invocation succeeded + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + + - name: MERGE - TC13 - GATHER - Verify check_mode did not create vPC pair + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + + - name: MERGE - TC13 - ASSERT - Confirm no persistent changes from check_mode + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 0' + tags: merge + + # TC14 - Validate vpcPairSupport enforcement path (isPairingAllowed == false) + - name: MERGE - TC14 - PREP - Query fabric switches for support validation + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches" + method: get + register: switches_result + tags: merge + + - name: MERGE - TC14 - PREP - Query pairing support for each switch + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ item }}/vpcPairSupport?componentType=checkPairing" + method: get + loop: "{{ (switches_result.current.switches | default([])) | map(attribute='serialNumber') | select('defined') | list }}" + register: support_result + ignore_errors: true + tags: merge + + - name: MERGE - TC14 - PREP - Choose blocked and allowed switch candidates + ansible.builtin.set_fact: + blocked_switch_id: >- + {{ + ( + support_result.results + | selectattr('current', 'defined') + | selectattr('current.isPairingAllowed', 'defined') + | selectattr('current.isPairingAllowed', 'equalto', false) + | map(attribute='item') + | list + | first + ) | default('') + }} + allowed_switch_id: >- + {{ + ( + support_result.results + | selectattr('current', 'defined') + | selectattr('current.isPairingAllowed', 'defined') + | selectattr('current.isPairingAllowed', 'equalto', true) + | map(attribute='item') + | list + | first + ) | default('') + }} + tags: merge + + - name: MERGE - TC14 - ASSERT - Ensure support candidates are available + ansible.builtin.assert: + that: + - blocked_switch_id | length > 0 + - allowed_switch_id | length > 0 + - blocked_switch_id != allowed_switch_id + tags: merge + + - name: MERGE - TC14 - MERGE - Verify unsupported pairing is blocked by module + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ blocked_switch_id }}" + peer2_switch_id: "{{ allowed_switch_id }}" + use_virtual_peer_link: true + register: result + ignore_errors: true + tags: merge + + - name: MERGE - TC14 - ASSERT - Validate unsupported pairing failure details + ansible.builtin.assert: + that: + - result.failed == true + - > + ( + (result.msg is search("VPC pairing is not allowed for switch")) + and (result.support_details is defined) + and (result.support_details.isPairingAllowed == false) + ) + or + ( + (result.msg is search("Switch conflicts detected")) + and (result.conflicts is defined) + and ((result.conflicts | length) > 0) + ) + tags: merge + + ############################################## + ## CLEAN-UP ## + ############################################## + + - name: MERGE - END - remove vPC pairs + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: merge diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml new file mode 100644 index 00000000..1b94ed47 --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml @@ -0,0 +1,218 @@ +- name: ND vPC pair override tests + hosts: nd + gather_facts: false + tasks: + ############################################## + ## SETUP ## + ############################################## + + - name: OVERRIDE - Test Entry Point - [nd_vpc_pair] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ Executing Override Tests - [nd_vpc_pair] +" + - "----------------------------------------------------------------" + tags: override + + ############################################## + ## Setup Internal TestCase Variables ## + ############################################## + + - name: OVERRIDE - Setup Internal TestCase Variables + ansible.builtin.set_fact: + deploy_local: true + test_fabric: "{{ fabric_name }}" + test_switch1: "{{ switch1_serial }}" + test_switch2: "{{ switch2_serial }}" + test_fabric_type: "{{ fabric_type | default('LANClassic') }}" + test_data_overridden: + vpc_pair_initial_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + use_virtual_peer_link: true + vpc_pair_overridden_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + use_virtual_peer_link: false + tags: override + + ############################################## + ## OVERRIDE ## + ############################################## + + # TC1 - Override with a new vPC switch pair + - name: OVERRIDE - TC1 - DELETE - Clean up any existing vPC pairs + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + tags: override + + - name: OVERRIDE - TC1 - OVERRIDE - Create vPC pair using override state + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: overridden + config: "{{ test_data_overridden.vpc_pair_initial_conf }}" + register: result + tags: override + + - name: OVERRIDE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + tags: override + + - name: OVERRIDE - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: override + + - name: OVERRIDE - TC1 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + tags: override + + # TC2 - Override with same vPC switch pair with changes + - name: OVERRIDE - TC2 - OVERRIDE - Override vPC pair with changes + cisco.nd.nd_vpc_pair: &conf_overridden + fabric_name: "{{ test_fabric }}" + state: overridden + config: "{{ test_data_overridden.vpc_pair_overridden_conf }}" + register: result + tags: override + + - name: OVERRIDE - TC2 - ASSERT - Check if changed flag is true for LANClassic + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: override + + - name: OVERRIDE - TC2 - ASSERT - Check if changed flag is false for VXLANFabric + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "VXLANFabric" + tags: override + + - name: OVERRIDE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: override + + - name: OVERRIDE - TC2 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + - verify_result.gathered.vpc_pairs[0].use_virtual_peer_link == false + when: test_fabric_type == "LANClassic" + tags: override + + # TC3 - Idempotence test + - name: OVERRIDE - TC3 - conf - Idempotence + cisco.nd.nd_vpc_pair: *conf_overridden + register: result + tags: override + + - name: OVERRIDE - TC3 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: override + + # TC4 - Override existing vPC pair with no config (delete all) + - name: OVERRIDE - TC4 - OVERRIDE - Delete all vPC pairs via override with no config + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: overridden + config: [] + register: result + tags: override + + - name: OVERRIDE - TC4 - ASSERT - Check if deletion successful + ansible.builtin.assert: + that: + - result.failed == false + tags: override + + - name: OVERRIDE - TC4 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + tags: override + + - name: OVERRIDE - TC4 - ASSERT - Verify vPC pair deletion via override + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 0' + tags: override + + # TC5 - Gather to verify deletion + - name: OVERRIDE - TC5 - GATHER - Verify vPC pair deletion + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + until: + - '(result.gathered.vpc_pairs | length) == 0' + retries: 30 + delay: 5 + tags: override + + # TC6 - Override with no vPC pair and no config (should be no-op) + - name: OVERRIDE - TC6 - OVERRIDE - Override with no vPC pairs (no-op) + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: overridden + config: [] + register: result + tags: override + + - name: OVERRIDE - TC6 - ASSERT - Check if no change occurred + ansible.builtin.assert: + that: + - result.failed == false + tags: override + + - name: OVERRIDE - TC6 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + tags: override + + - name: OVERRIDE - TC6 - ASSERT - Verify no-op override + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 0' + tags: override + + ############################################## + ## CLEAN-UP ## + ############################################## + + - name: OVERRIDE - END - remove vPC pairs + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: override diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml new file mode 100644 index 00000000..0dbc1d4c --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml @@ -0,0 +1,145 @@ +- name: ND vPC pair replace tests + hosts: nd + gather_facts: false + tasks: + ############################################## + ## SETUP ## + ############################################## + + - name: REPLACE - Test Entry Point - [nd_vpc_pair] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ Executing Replace Tests - [nd_vpc_pair] +" + - "----------------------------------------------------------------" + tags: replace + + ############################################## + ## Setup Internal TestCase Variables ## + ############################################## + + - name: REPLACE - Setup Internal TestCase Variables + ansible.builtin.set_fact: + deploy_local: true + test_fabric: "{{ fabric_name }}" + test_switch1: "{{ switch1_serial }}" + test_switch2: "{{ switch2_serial }}" + test_fabric_type: "{{ fabric_type | default('LANClassic') }}" + test_data_replaced: + vpc_pair_initial_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + use_virtual_peer_link: true + vpc_pair_replaced_conf: + - peer1_switch_id: "{{ switch1_serial }}" + peer2_switch_id: "{{ switch2_serial }}" + use_virtual_peer_link: false + tags: replace + + ############################################## + ## REPLACE ## + ############################################## + + # TC1 - Create initial vPC pair using replace state + - name: REPLACE - TC1 - DELETE - Clean up any existing vPC pairs + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + tags: replace + + - name: REPLACE - TC1 - REPLACE - Create vPC pair using replace state + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: replaced + config: "{{ test_data_replaced.vpc_pair_initial_conf }}" + register: result + tags: replace + + - name: REPLACE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + tags: replace + + - name: REPLACE - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: replace + + - name: REPLACE - TC1 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + tags: replace + + # TC2 - Replace vPC pair configuration + - name: REPLACE - TC2 - REPLACE - Replace vPC pair configuration + cisco.nd.nd_vpc_pair: &conf_replaced + fabric_name: "{{ test_fabric }}" + state: replaced + config: "{{ test_data_replaced.vpc_pair_replaced_conf }}" + register: result + tags: replace + + - name: REPLACE - TC2 - ASSERT - Check if changed flag is true for LANClassic + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: replace + + - name: REPLACE - TC2 - ASSERT - Check if changed flag is false for VXLANFabric + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "VXLANFabric" + tags: replace + + - name: REPLACE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: replace + + - name: REPLACE - TC2 - ASSERT - Verify vPC pair state in ND + ansible.builtin.assert: + that: + - verify_result.failed == false + - '(verify_result.gathered.vpc_pairs | length) == 1' + - verify_result.gathered.vpc_pairs[0].use_virtual_peer_link == false + when: test_fabric_type == "LANClassic" + tags: replace + + # TC3 - Idempotence test + - name: REPLACE - TC3 - conf - Idempotence + cisco.nd.nd_vpc_pair: *conf_replaced + register: result + tags: replace + + - name: REPLACE - TC3 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: replace + + ############################################## + ## CLEAN-UP ## + ############################################## + + - name: REPLACE - END - remove vPC pairs + cisco.nd.nd_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: replace From d29a7d12d7bce33b8f5a418cef17a7167b643494 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Mon, 9 Mar 2026 23:58:46 +0530 Subject: [PATCH 04/39] Endpoint mixins, and param alignment --- .../module_utils/manage/vpc_pair/__init__.py | 2 +- .../{endpoint_mixins.py => mixins.py} | 0 .../manage/vpc_pair/vpc_pair_endpoints.py | 16 ++- .../manage/vpc_pair/vpc_pair_resources.py | 40 +++++-- plugins/modules/nd_vpc_pair.py | 102 +++++++++++++----- 5 files changed, 122 insertions(+), 38 deletions(-) rename plugins/module_utils/manage/vpc_pair/{endpoint_mixins.py => mixins.py} (100%) diff --git a/plugins/module_utils/manage/vpc_pair/__init__.py b/plugins/module_utils/manage/vpc_pair/__init__.py index d1507272..5aff3c6e 100644 --- a/plugins/module_utils/manage/vpc_pair/__init__.py +++ b/plugins/module_utils/manage/vpc_pair/__init__.py @@ -12,7 +12,7 @@ Components: - enums: Enumeration types for constrained values -- endpoint_mixins: Reusable field mixins for composition +- mixins: Reusable field mixins for composition - base_paths: Centralized API path builders - vpc_pair_endpoints: Endpoint models for each API operation - vpc_pair_schemas: Request/response data schemas diff --git a/plugins/module_utils/manage/vpc_pair/endpoint_mixins.py b/plugins/module_utils/manage/vpc_pair/mixins.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/endpoint_mixins.py rename to plugins/module_utils/manage/vpc_pair/mixins.py diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py b/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py index 5a8c1b05..79252092 100644 --- a/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py +++ b/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py @@ -26,7 +26,7 @@ from typing import TYPE_CHECKING, Literal, Optional from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.base_paths import VpcPairBasePath -from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.endpoint_mixins import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.mixins import ( ComponentTypeMixin, FabricNameMixin, FilterMixin, @@ -106,6 +106,8 @@ class EpVpcPairGet(_EpVpcPairBase): ``` """ + 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") class_name: Literal["EpVpcPairGet"] = Field(default="EpVpcPairGet", description="Class name for backward compatibility") @property @@ -146,6 +148,8 @@ class EpVpcPairPut(_EpVpcPairBase, TicketIdMixin): ``` """ + 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") class_name: Literal["EpVpcPairPut"] = Field(default="EpVpcPairPut", description="Class name for backward compatibility") @property @@ -196,6 +200,8 @@ class EpVpcPairSupportGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, Comp """ model_config = COMMON_CONFIG + 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") class_name: Literal["EpVpcPairSupportGet"] = Field(default="EpVpcPairSupportGet", description="Class name for backward compatibility") @property @@ -253,6 +259,8 @@ class EpVpcPairOverviewGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, Com """ model_config = COMMON_CONFIG + 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") class_name: Literal["EpVpcPairOverviewGet"] = Field(default="EpVpcPairOverviewGet", description="Class name for backward compatibility") @property @@ -309,6 +317,8 @@ class EpVpcPairRecommendationGet(FabricNameMixin, SwitchIdMixin, FromClusterMixi """ model_config = COMMON_CONFIG + 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") class_name: Literal["EpVpcPairRecommendationGet"] = Field(default="EpVpcPairRecommendationGet", description="Class name for backward compatibility") use_virtual_peer_link: Optional[bool] = Field(default=None, description="Virtual peer link available") @@ -362,6 +372,8 @@ class EpVpcPairConsistencyGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, """ model_config = COMMON_CONFIG + 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") class_name: Literal["EpVpcPairConsistencyGet"] = Field(default="EpVpcPairConsistencyGet", description="Class name for backward compatibility") @property @@ -426,6 +438,8 @@ class EpVpcPairsListGet(FabricNameMixin, FromClusterMixin, FilterMixin, Paginati """ model_config = COMMON_CONFIG + 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") class_name: Literal["EpVpcPairsListGet"] = Field(default="EpVpcPairsListGet", description="Class name for backward compatibility") @property diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py b/plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py index 25f95795..ac8caa83 100644 --- a/plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py +++ b/plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py @@ -110,6 +110,15 @@ def __init__(self, *args, **kwargs): NeedsDeployHandler = Callable[[Dict[str, Any], Any], bool] +class VpcPairResourceError(Exception): + """Structured error raised by vpc_pair runtime layers.""" + + def __init__(self, msg: str, **details: Any): + super().__init__(msg) + self.msg = msg + self.details = details + + class _VpcPairQueryContext: """Minimal context object for query_all during NDStateMachine initialization.""" @@ -198,18 +207,18 @@ def manage_state( try: parsed_items.append(self.model_class.model_validate(config)) except ValidationError as e: - self.fail_json( + raise VpcPairResourceError( msg=f"Invalid configuration: {e}", config=config, validation_errors=e.errors(), ) - return self.proposed = self.nd_config_collection(model_class=self.model_class, items=parsed_items) self.previous = self.existing.copy() except Exception as e: - self.fail_json(msg=f"Failed to prepare configurations: {e}", error=str(e)) - return + if isinstance(e, VpcPairResourceError): + raise + raise VpcPairResourceError(msg=f"Failed to prepare configurations: {e}", error=str(e)) if state in ["merged", "replaced", "overridden"]: self._manage_create_update_state(state, unwanted_keys) @@ -218,7 +227,7 @@ def manage_state( elif state == "deleted": self._manage_delete_state() else: - self.fail_json(msg=f"Invalid state: {state}") + raise VpcPairResourceError(msg=f"Invalid state: {state}") def _manage_create_update_state(self, state: str, unwanted_keys: List) -> None: for proposed_item in self.proposed: @@ -290,8 +299,11 @@ def _manage_create_update_state(self, state: str, unwanted_keys: List) -> None: after_data=self.existing_config, ) if not self.module.params.get("ignore_errors", False): - self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) - return + raise VpcPairResourceError( + msg=error_msg, + identifier=str(identifier), + error=str(e), + ) def _manage_override_deletions(self, override_exceptions: List) -> None: diff_identifiers = self.previous.get_diff_identifiers(self.proposed) @@ -313,8 +325,11 @@ def _manage_override_deletions(self, override_exceptions: List) -> None: except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" if not self.module.params.get("ignore_errors", False): - self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) - return + raise VpcPairResourceError( + msg=error_msg, + identifier=str(identifier), + error=str(e), + ) def _manage_delete_state(self) -> None: for proposed_item in self.proposed: @@ -335,8 +350,11 @@ def _manage_delete_state(self) -> None: except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" if not self.module.params.get("ignore_errors", False): - self.fail_json(msg=error_msg, identifier=str(identifier), error=str(e)) - return + raise VpcPairResourceError( + msg=error_msg, + identifier=str(identifier), + error=str(e), + ) class VpcPairResourceService: diff --git a/plugins/modules/nd_vpc_pair.py b/plugins/modules/nd_vpc_pair.py index 2a6a7945..d2507f46 100644 --- a/plugins/modules/nd_vpc_pair.py +++ b/plugins/modules/nd_vpc_pair.py @@ -279,6 +279,7 @@ # Service layer imports from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_resources import ( VpcPairResourceService, + VpcPairResourceError, ) # Static imports so Ansible's AnsiballZ packager includes these files in the @@ -317,6 +318,10 @@ EpVpcPairsListGet, VpcPairBasePath, ) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + CompositeQueryParams, + EndpointQueryParams, +) # RestSend imports from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( @@ -362,9 +367,26 @@ def _collection_to_list_flex(collection) -> List[Dict[str, Any]]: return [] +def _raise_vpc_error(msg: str, **details: Any) -> None: + """Raise a structured vpc_pair error for main() to format via fail_json.""" + raise VpcPairResourceError(msg=msg, **details) + + # ===== API Endpoints ===== +class _ComponentTypeQueryParams(EndpointQueryParams): + """Query params for endpoints that require componentType.""" + + component_type: Optional[str] = None + + +class _ForceShowRunQueryParams(EndpointQueryParams): + """Query params for deploy endpoint.""" + + force_show_run: Optional[bool] = None + + class VpcPairEndpoints: """ Centralized API endpoint path management for VPC pair operations. @@ -399,6 +421,15 @@ class VpcPairEndpoints: SWITCH_VPC_RECOMMENDATIONS = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPairRecommendations" SWITCH_VPC_OVERVIEW = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPairOverview" + @staticmethod + def _append_query(path: str, *query_groups: EndpointQueryParams) -> str: + """Compose query params using shared query param utilities.""" + composite_params = CompositeQueryParams() + for query_group in query_groups: + composite_params.add(query_group) + query_string = composite_params.to_query_string(url_encode=False) + return f"{path}?{query_string}" if query_string else path + @staticmethod def vpc_pair_base(fabric_name: str) -> str: """ @@ -524,7 +555,8 @@ def switch_vpc_overview(fabric_name: str, switch_id: str, component_type: str = """ endpoint = EpVpcPairOverviewGet(fabric_name=fabric_name, switch_id=switch_id) base_path = endpoint.path - return f"{base_path}?componentType={component_type}" + query_params = _ComponentTypeQueryParams(component_type=component_type) + return VpcPairEndpoints._append_query(base_path, query_params) @staticmethod def switch_vpc_support( @@ -549,7 +581,8 @@ def switch_vpc_support( component_type=component_type, ) base_path = endpoint.path - return f"{base_path}?componentType={component_type}" + query_params = _ComponentTypeQueryParams(component_type=component_type) + return VpcPairEndpoints._append_query(base_path, query_params) @staticmethod def switch_vpc_consistency(fabric_name: str, switch_id: str) -> str: @@ -600,9 +633,10 @@ def fabric_config_deploy(fabric_name: str, force_show_run: bool = True) -> str: '/api/v1/manage/fabrics/myFabric/actions/deploy?forceShowRun=true' """ base_path = VpcPairBasePath.fabrics(fabric_name, "actions", "deploy") - if force_show_run: - return f"{base_path}?forceShowRun=true" - return base_path + query_params = _ForceShowRunQueryParams( + force_show_run=True if force_show_run else None + ) + return VpcPairEndpoints._append_query(base_path, query_params) # ===== VPC Pair Model ===== @@ -1338,7 +1372,7 @@ def _validate_switch_conflicts(want_configs: List[Dict], have_vpc_pairs: List[Di ) if conflicts: - module.fail_json( + _raise_vpc_error( msg="Switch conflicts detected. A switch can only be part of one VPC pair at a time.", conflicts=conflicts ) @@ -1360,7 +1394,7 @@ def _validate_switches_exist_in_fabric( fabric_switches = nrm.module.params.get("_fabric_switches") if fabric_switches is None: - nrm.module.fail_json( + _raise_vpc_error( msg=( f"Switch validation failed for fabric '{fabric_name}': switch inventory " "was not loaded from query_all. Unable to validate requested vPC pair." @@ -1371,7 +1405,7 @@ def _validate_switches_exist_in_fabric( valid_switches = sorted(list(fabric_switches)) if not valid_switches: - nrm.module.fail_json( + _raise_vpc_error( msg=( f"Switch validation failed for fabric '{fabric_name}': no switches were " "discovered in fabric inventory. Cannot create/update vPC pairs without " @@ -1411,7 +1445,7 @@ def _validate_switches_exist_in_fabric( f"{len(valid_switches) - max_switches_in_error} more" ) - nrm.module.fail_json( + _raise_vpc_error( msg=error_msg, missing_switches=missing_switches, vpc_pair_key=nrm.current_identifier, @@ -1483,7 +1517,7 @@ def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pai # Validate response structure if not isinstance(response, dict): - module.fail_json( + _raise_vpc_error( msg=f"Expected dict response from vPC pair overview for {vpc_pair_key}, got {type(response).__name__}", response=response ) @@ -1491,7 +1525,7 @@ def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pai # Validate overlay data exists overlay = response.get(VpcFieldNames.OVERLAY) if not overlay: - module.fail_json( + _raise_vpc_error( msg=( f"vPC pair {vpc_pair_key} might not exist or overlay data unavailable. " f"Cannot safely validate deletion." @@ -1507,7 +1541,7 @@ def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pai try: count_int = int(count) if count_int != 0: - module.fail_json( + _raise_vpc_error( msg=( f"Cannot delete vPC pair {vpc_pair_key}. " f"{count_int} network(s) with status '{status}' still exist. " @@ -1535,7 +1569,7 @@ def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pai try: count_int = int(count) if count_int != 0: - module.fail_json( + _raise_vpc_error( msg=( f"Cannot delete vPC pair {vpc_pair_key}. " f"{count_int} VRF(s) with status '{status}' still exist. " @@ -1579,6 +1613,8 @@ def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pai f"Proceeding with deletion, but it may fail if vPC interfaces exist." ) + except VpcPairResourceError: + raise except NDModuleError as error: error_msg = str(error.msg).lower() if error.msg else "" status_code = error.status or 0 @@ -1930,13 +1966,15 @@ def custom_vpc_query_all(nrm) -> List[Dict]: # Preserve original API error message with different key to avoid conflict if 'msg' in error_dict: error_dict['api_error_msg'] = error_dict.pop('msg') - nrm.module.fail_json( + _raise_vpc_error( msg=f"Failed to query VPC pairs: {error.msg}", fabric=fabric_name, **error_dict ) + except VpcPairResourceError: + raise except Exception as e: - nrm.module.fail_json( + _raise_vpc_error( msg=f"Failed to query VPC pairs: {str(e)}", fabric=fabric_name, exception_type=type(e).__name__ @@ -2024,13 +2062,15 @@ def custom_vpc_create(nrm) -> Optional[Dict[str, Any]]: reason = _get_api_field_value( support_details, "reason", "pairing blocked by support checks" ) - nrm.module.fail_json( + _raise_vpc_error( msg=f"VPC pairing is not allowed for switch {switch_id}: {reason}", fabric=fabric_name, switch_id=switch_id, peer_switch_id=peer_switch_id, support_details=support_details, ) + except VpcPairResourceError: + raise except Exception as support_error: nrm.module.warn( f"Pairing support check failed for switch {switch_id}: " @@ -2073,7 +2113,7 @@ def custom_vpc_create(nrm) -> Optional[Dict[str, Any]]: # Preserve original API error message with different key to avoid conflict if 'msg' in error_dict: error_dict['api_error_msg'] = error_dict.pop('msg') - nrm.module.fail_json( + _raise_vpc_error( msg=f"Failed to create VPC pair {nrm.current_identifier}: {error.msg}", fabric=fabric_name, switch_id=switch_id, @@ -2081,8 +2121,10 @@ def custom_vpc_create(nrm) -> Optional[Dict[str, Any]]: path=path, **error_dict ) + except VpcPairResourceError: + raise except Exception as e: - nrm.module.fail_json( + _raise_vpc_error( msg=f"Failed to create VPC pair {nrm.current_identifier}: {str(e)}", fabric=fabric_name, switch_id=switch_id, @@ -2197,15 +2239,17 @@ def custom_vpc_update(nrm) -> Optional[Dict[str, Any]]: # Preserve original API error message with different key to avoid conflict if 'msg' in error_dict: error_dict['api_error_msg'] = error_dict.pop('msg') - nrm.module.fail_json( + _raise_vpc_error( msg=f"Failed to update VPC pair {nrm.current_identifier}: {error.msg}", fabric=fabric_name, switch_id=switch_id, path=path, **error_dict ) + except VpcPairResourceError: + raise except Exception as e: - nrm.module.fail_json( + _raise_vpc_error( msg=f"Failed to update VPC pair {nrm.current_identifier}: {str(e)}", fabric=fabric_name, switch_id=switch_id, @@ -2276,7 +2320,7 @@ def custom_vpc_delete(nrm) -> None: except (NDModuleError, Exception) as validation_error: # Validation failed - check if force deletion is enabled if not force_delete: - nrm.module.fail_json( + _raise_vpc_error( msg=( f"Pre-deletion validation failed for VPC pair {vpc_pair_key}. " f"Error: {str(validation_error)}. " @@ -2341,15 +2385,17 @@ def custom_vpc_delete(nrm) -> None: # Preserve original API error message with different key to avoid conflict if 'msg' in error_dict: error_dict['api_error_msg'] = error_dict.pop('msg') - nrm.module.fail_json( + _raise_vpc_error( msg=f"Failed to delete VPC pair {nrm.current_identifier}: {error.msg}", fabric=fabric_name, switch_id=switch_id, path=path, **error_dict ) + except VpcPairResourceError: + raise except Exception as e: - nrm.module.fail_json( + _raise_vpc_error( msg=f"Failed to delete VPC pair {nrm.current_identifier}: {str(e)}", fabric=fabric_name, switch_id=switch_id, @@ -2515,7 +2561,9 @@ def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: results.result_current = {"success": False, "changed": False} results.register_task_result() results.build_final_result() - nrm.module.fail_json(msg=f"Config save failed: {error.msg}", **results.final_result) + final_result = dict(results.final_result) + final_msg = final_result.pop("msg", f"Config save failed: {error.msg}") + _raise_vpc_error(msg=final_msg, **final_result) # Step 2: Deploy deploy_path = VpcPairEndpoints.fabric_config_deploy(fabric_name, force_show_run=True) @@ -2546,7 +2594,9 @@ def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: # Build final result and fail results.build_final_result() - nrm.module.fail_json(**results.final_result) + final_result = dict(results.final_result) + final_msg = final_result.pop("msg", "Fabric deployment failed") + _raise_vpc_error(msg=final_msg, **final_result) # Build final result results.build_final_result() @@ -2770,6 +2820,8 @@ def main(): module.exit_json(**result) + except VpcPairResourceError as e: + module.fail_json(msg=e.msg, **e.details) except Exception as e: module.fail_json(msg=str(e)) From 1ffc0f3cc61f1ceebc78b48a6fa7a2230bf919be Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 10 Mar 2026 00:00:36 +0530 Subject: [PATCH 05/39] Changes in imports and pydantic fixes --- .../module_utils/manage/vpc_pair/__init__.py | 4 +--- .../module_utils/manage/vpc_pair/mixins.py | 20 ++++++------------- .../vpc_pair/model_playbook_vpc_pair.py | 9 ++++++++- .../manage/vpc_pair/vpc_pair_endpoints.py | 6 +++++- plugins/modules/nd_vpc_pair.py | 6 +++--- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/plugins/module_utils/manage/vpc_pair/__init__.py b/plugins/module_utils/manage/vpc_pair/__init__.py index 5aff3c6e..eb57e943 100644 --- a/plugins/module_utils/manage/vpc_pair/__init__.py +++ b/plugins/module_utils/manage/vpc_pair/__init__.py @@ -102,6 +102,4 @@ except ImportError as e: # Pydantic not available - components will not be exposed # This allows the package to be imported without pydantic for basic functionality - import sys - print(f"Warning: Could not import VPC pair components: {e}", file=sys.stderr) - pass + _ = e diff --git a/plugins/module_utils/manage/vpc_pair/mixins.py b/plugins/module_utils/manage/vpc_pair/mixins.py index 92bb9b91..34f2ccb7 100644 --- a/plugins/module_utils/manage/vpc_pair/mixins.py +++ b/plugins/module_utils/manage/vpc_pair/mixins.py @@ -18,25 +18,17 @@ fields to endpoint models without duplication. """ -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, annotations, division, print_function __metaclass__ = type __author__ = "Sivakami Sivaraman" -from typing import TYPE_CHECKING, Optional +from typing import Optional -if TYPE_CHECKING: - from pydantic import BaseModel, Field -else: - try: - from pydantic import BaseModel, Field - except ImportError: - # Fallback for environments without pydantic - class BaseModel: - pass - - def Field(*args, **kwargs): - return None +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + Field, +) class FabricNameMixin(BaseModel): diff --git a/plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py b/plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py index ad53525f..939cc6d7 100644 --- a/plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py +++ b/plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py @@ -21,9 +21,16 @@ """ from abc import ABC, abstractmethod -from pydantic import BaseModel, ConfigDict, Field, BeforeValidator, field_validator, model_validator from typing import List, Dict, Any, Optional, Union, Tuple, ClassVar, Literal, Annotated from typing_extensions import Self +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + field_validator, + model_validator, +) # Import enums from centralized location from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.enums import ( diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py b/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py index 79252092..4ae341af 100644 --- a/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py +++ b/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py @@ -38,7 +38,11 @@ ViewMixin, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.pydantic_compat import BaseModel, ConfigDict, Field +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) # Common config for basic validation COMMON_CONFIG = ConfigDict(validate_assignment=True) diff --git a/plugins/modules/nd_vpc_pair.py b/plugins/modules/nd_vpc_pair.py index d2507f46..c9a189e6 100644 --- a/plugins/modules/nd_vpc_pair.py +++ b/plugins/modules/nd_vpc_pair.py @@ -272,7 +272,7 @@ import logging import sys import traceback -from typing import Any, Dict, List, Optional, Union +from typing import Any, ClassVar, Dict, List, Literal, Optional, Union from ansible.module_utils.basic import AnsibleModule, missing_required_lib @@ -662,8 +662,8 @@ class VpcPairModel(NDNestedModel): """ # Identifier configuration - identifiers = ["switch_id", "peer_switch_id"] - identifier_strategy = "composite" + identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] + identifier_strategy: ClassVar[Literal["composite"]] = "composite" # Fields (Ansible names -> API aliases) switch_id: str = Field( From 83a948c644e79436d75ab0f55af335e668cb0465 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 10 Mar 2026 00:30:31 +0530 Subject: [PATCH 06/39] ep to endpoints migration --- .../v1/manage_vpc_pair.py} | 0 plugins/module_utils/ep/__init__.py | 25 ---------------- plugins/module_utils/ep/v1/__init__.py | 30 ------------------- plugins/modules/nd_vpc_pair.py | 2 +- 4 files changed, 1 insertion(+), 56 deletions(-) rename plugins/module_utils/{ep/v1/ep_manage_vpc_pair.py => endpoints/v1/manage_vpc_pair.py} (100%) delete mode 100644 plugins/module_utils/ep/__init__.py delete mode 100644 plugins/module_utils/ep/v1/__init__.py diff --git a/plugins/module_utils/ep/v1/ep_manage_vpc_pair.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair.py similarity index 100% rename from plugins/module_utils/ep/v1/ep_manage_vpc_pair.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair.py diff --git a/plugins/module_utils/ep/__init__.py b/plugins/module_utils/ep/__init__.py deleted file mode 100644 index 139784f5..00000000 --- a/plugins/module_utils/ep/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -from ansible_collections.cisco.nd.plugins.module_utils.ep.v1 import ( - EpVpcPairConsistencyGet, - EpVpcPairGet, - EpVpcPairOverviewGet, - EpVpcPairPut, - EpVpcPairRecommendationGet, - EpVpcPairSupportGet, - EpVpcPairsListGet, - VpcPairBasePath, -) - -__all__ = [ - "VpcPairBasePath", - "EpVpcPairGet", - "EpVpcPairPut", - "EpVpcPairSupportGet", - "EpVpcPairOverviewGet", - "EpVpcPairRecommendationGet", - "EpVpcPairConsistencyGet", - "EpVpcPairsListGet", -] diff --git a/plugins/module_utils/ep/v1/__init__.py b/plugins/module_utils/ep/v1/__init__.py deleted file mode 100644 index b950c7cd..00000000 --- a/plugins/module_utils/ep/v1/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Sivakami S -# 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.ep.v1.ep_manage_vpc_pair import ( - EpVpcPairConsistencyGet, - EpVpcPairGet, - EpVpcPairOverviewGet, - EpVpcPairPut, - EpVpcPairRecommendationGet, - EpVpcPairSupportGet, - EpVpcPairsListGet, - VpcPairBasePath, -) - -__all__ = [ - "VpcPairBasePath", - "EpVpcPairGet", - "EpVpcPairPut", - "EpVpcPairSupportGet", - "EpVpcPairOverviewGet", - "EpVpcPairRecommendationGet", - "EpVpcPairConsistencyGet", - "EpVpcPairsListGet", -] diff --git a/plugins/modules/nd_vpc_pair.py b/plugins/modules/nd_vpc_pair.py index c9a189e6..9627724c 100644 --- a/plugins/modules/nd_vpc_pair.py +++ b/plugins/modules/nd_vpc_pair.py @@ -308,7 +308,7 @@ VpcActionEnum, VpcFieldNames, ) -from ansible_collections.cisco.nd.plugins.module_utils.ep.v1 import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair import ( EpVpcPairConsistencyGet, EpVpcPairGet, EpVpcPairPut, From bf13c773a4735e1c5630530cedf28065f083ad29 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 10 Mar 2026 10:10:21 +0530 Subject: [PATCH 07/39] Revert "nd42_smart_endpoints: apply smart endpoints branch changes" This reverts commit 565a069da9b3107d1c1e2893c7e95ca6ac73cf91. --- plugins/module_utils/endpoints/__init__.py | 0 plugins/module_utils/endpoints/base_path.py | 71 -- plugins/module_utils/endpoints/mixins.py | 89 -- .../module_utils/endpoints/query_params.py | 325 ------- plugins/module_utils/endpoints/v1/__init__.py | 0 .../endpoints/v1/base_paths_infra.py | 139 --- .../endpoints/v1/base_paths_manage.py | 115 --- .../module_utils/endpoints/v1/infra_aaa.py | 219 ----- .../endpoints/v1/infra_clusterhealth.py | 241 ----- .../module_utils/endpoints/v1/infra_login.py | 91 -- tests/config.yml | 3 - .../module_utils/endpoints/test_base_path.py | 444 --------- .../endpoints/test_base_paths_infra.py | 390 -------- .../endpoints/test_base_paths_manage.py | 309 ------- .../endpoints/test_endpoint_mixins.py | 560 ------------ .../test_endpoints_api_v1_infra_aaa.py | 437 --------- ...st_endpoints_api_v1_infra_clusterhealth.py | 479 ---------- .../test_endpoints_api_v1_infra_login.py | 68 -- .../endpoints/test_query_params.py | 845 ------------------ 19 files changed, 4825 deletions(-) delete mode 100644 plugins/module_utils/endpoints/__init__.py delete mode 100644 plugins/module_utils/endpoints/base_path.py delete mode 100644 plugins/module_utils/endpoints/mixins.py delete mode 100644 plugins/module_utils/endpoints/query_params.py delete mode 100644 plugins/module_utils/endpoints/v1/__init__.py delete mode 100644 plugins/module_utils/endpoints/v1/base_paths_infra.py delete mode 100644 plugins/module_utils/endpoints/v1/base_paths_manage.py delete mode 100644 plugins/module_utils/endpoints/v1/infra_aaa.py delete mode 100644 plugins/module_utils/endpoints/v1/infra_clusterhealth.py delete mode 100644 plugins/module_utils/endpoints/v1/infra_login.py delete mode 100644 tests/config.yml delete mode 100644 tests/unit/module_utils/endpoints/test_base_path.py delete mode 100644 tests/unit/module_utils/endpoints/test_base_paths_infra.py delete mode 100644 tests/unit/module_utils/endpoints/test_base_paths_manage.py delete mode 100644 tests/unit/module_utils/endpoints/test_endpoint_mixins.py delete mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa.py delete mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_clusterhealth.py delete mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_login.py delete mode 100644 tests/unit/module_utils/endpoints/test_query_params.py diff --git a/plugins/module_utils/endpoints/__init__.py b/plugins/module_utils/endpoints/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/plugins/module_utils/endpoints/base_path.py b/plugins/module_utils/endpoints/base_path.py deleted file mode 100644 index 9359a03b..00000000 --- a/plugins/module_utils/endpoints/base_path.py +++ /dev/null @@ -1,71 +0,0 @@ -# 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 API endpoints. - -This module provides a single location to manage all API base paths using -a type-safe Enum pattern, allowing easy modification when API paths change -and preventing invalid path usage through compile-time checking. - -## Usage - -```python -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ApiPath - -# Recommended: Use enum for type safety -base_url = ApiPath.INFRA.value - -# Type-safe function parameters -def build_endpoint(api_base: ApiPath, path: str) -> str: - return f"{api_base.value}/{path}" - -# IDE autocomplete works -endpoint = build_endpoint(ApiPath.INFRA, "aaa/localUsers") -``` - -## Backward Compatibility - -Legacy constants (ND_INFRA_API, etc.) are maintained for backward compatibility -but are deprecated. New code should use the ApiPath enum. -""" - -from __future__ import absolute_import, annotations, division, print_function - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from enum import Enum -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Final - - -class ApiPath(str, Enum): - """ - # Summary - - Base API path constants for ND REST API. - - ## Description - - String-based enum providing type-safe API base paths shared across - all endpoint versions (v1, v2, etc.). - - ## Raises - - None - """ - - ANALYZE = "/api/v1/analyze" - INFRA = "/api/v1/infra" - MANAGE = "/api/v1/manage" - ONEMANAGE = "/api/v1/onemanage" - - -ND_ANALYZE_API: Final = ApiPath.ANALYZE.value -ND_INFRA_API: Final = ApiPath.INFRA.value -ND_MANAGE_API: Final = ApiPath.MANAGE.value -ND_ONEMANAGE_API: Final = ApiPath.ONEMANAGE.value diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py deleted file mode 100644 index 78c0994a..00000000 --- a/plugins/module_utils/endpoints/mixins.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -Reusable mixin classes for endpoint models. - -This module provides mixin classes that can be composed to add common -fields to endpoint models without duplication. -""" - -from __future__ import absolute_import, annotations, division, print_function - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from typing import Optional - -from ansible_collections.cisco.nd.plugins.module_utils.enums import BooleanStringEnum -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - Field, -) - - -class ClusterNameMixin(BaseModel): - """Mixin for endpoints that require cluster_name parameter.""" - - cluster_name: Optional[str] = Field(default=None, min_length=1, description="Cluster name") - - -class FabricNameMixin(BaseModel): - """Mixin for endpoints that require fabric_name parameter.""" - - fabric_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="Fabric name") - - -class ForceShowRunMixin(BaseModel): - """Mixin for endpoints that require force_show_run parameter.""" - - force_show_run: BooleanStringEnum = Field(default=BooleanStringEnum.FALSE, description="Force show running config") - - -class HealthCategoryMixin(BaseModel): - """Mixin for endpoints that require health_category parameter.""" - - health_category: Optional[str] = Field(default=None, min_length=1, description="Health category") - - -class InclAllMsdSwitchesMixin(BaseModel): - """Mixin for endpoints that require incl_all_msd_switches parameter.""" - - incl_all_msd_switches: BooleanStringEnum = Field(default=BooleanStringEnum.FALSE, description="Include all MSD switches") - - -class LinkUuidMixin(BaseModel): - """Mixin for endpoints that require link_uuid parameter.""" - - link_uuid: Optional[str] = Field(default=None, min_length=1, description="Link UUID") - - -class LoginIdMixin(BaseModel): - """Mixin for endpoints that require login_id parameter.""" - - login_id: Optional[str] = Field(default=None, min_length=1, description="Login ID") - - -class NetworkNameMixin(BaseModel): - """Mixin for endpoints that require network_name parameter.""" - - network_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="Network name") - - -class NodeNameMixin(BaseModel): - """Mixin for endpoints that require node_name parameter.""" - - node_name: Optional[str] = Field(default=None, min_length=1, description="Node name") - - -class SwitchSerialNumberMixin(BaseModel): - """Mixin for endpoints that require switch_sn parameter.""" - - switch_sn: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") - - -class VrfNameMixin(BaseModel): - """Mixin for endpoints that require vrf_name parameter.""" - - vrf_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="VRF name") diff --git a/plugins/module_utils/endpoints/query_params.py b/plugins/module_utils/endpoints/query_params.py deleted file mode 100644 index 355d4bbb..00000000 --- a/plugins/module_utils/endpoints/query_params.py +++ /dev/null @@ -1,325 +0,0 @@ -# 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 - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from abc import ABC, abstractmethod -from enum import Enum -from typing import Optional, Union -from urllib.parse import quote - -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - Field, - field_validator, -) - - -class QueryParams(ABC): - """ - # Summary - - Abstract Base Class for Query Parameters - - ## Description - - Base class for all query parameter types. Subclasses implement - `to_query_string()` to convert their parameters to URL query string format. - - ## 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 - """ - - @abstractmethod - 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" - ``` - """ - - 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 - """ - return len(self.to_query_string()) == 0 - - -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 all(v is None for v in self.model_dump().values()) - - -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[Union[EndpointQueryParams, LuceneQueryParams]] = [] - - def add(self, params: Union[EndpointQueryParams, LuceneQueryParams]) -> "CompositeQueryParams": - """ - # Summary - - Add a query parameter group to the composite. - - ## Parameters - - - params: EndpointQueryParams or LuceneQueryParams instance - - ## 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 deleted file mode 100644 index e69de29b..00000000 diff --git a/plugins/module_utils/endpoints/v1/base_paths_infra.py b/plugins/module_utils/endpoints/v1/base_paths_infra.py deleted file mode 100644 index 3b7db8eb..00000000 --- a/plugins/module_utils/endpoints/v1/base_paths_infra.py +++ /dev/null @@ -1,139 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -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 - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Final - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ( - ApiPath, -) - - -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.base_paths_infra import BasePath - - # Get a complete base path for ND Infra - path = BasePath.nd_infra("aaa", "localUsers") - # Returns: /api/v1/infra/aaa/localUsers - - # Leverage a convenience method - path = BasePath.nd_infra_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 = ApiPath.INFRA.value - - @classmethod - def nd_infra(cls, *segments: str) -> str: - """ - # Summary - - Build ND infra API path. - - ## Parameters - - - segments: Path segments to append after /api/v1/infra - - ## Returns - - - Complete ND infra API path - - ## Example - - ```python - path = BasePath.nd_infra("aaa", "localUsers") - # Returns: /api/v1/infra/aaa/localUsers - ``` - """ - if not segments: - return cls.API - return f"{cls.API}/{'/'.join(segments)}" - - @classmethod - def nd_infra_aaa(cls, *segments: str) -> str: - """ - # Summary - - Build ND infra AAA API path. - - ## Parameters - - - segments: Path segments to append after aaa (e.g., "localUsers") - - ## Returns - - - Complete ND infra AAA path - - ## Example - - ```python - path = BasePath.nd_infra_aaa("localUsers") - # Returns: /api/v1/infra/aaa/localUsers - ``` - """ - return cls.nd_infra("aaa", *segments) - - @classmethod - def nd_infra_clusterhealth(cls, *segments: str) -> str: - """ - # Summary - - Build ND infra clusterhealth API path. - - ## Parameters - - - segments: Path segments to append after clusterhealth (e.g., "config", "status") - - ## Returns - - - Complete ND infra clusterhealth path - - ## Example - - ```python - path = BasePath.nd_infra_clusterhealth("config") - # Returns: /api/v1/infra/clusterhealth/config - ``` - """ - return cls.nd_infra("clusterhealth", *segments) diff --git a/plugins/module_utils/endpoints/v1/base_paths_manage.py b/plugins/module_utils/endpoints/v1/base_paths_manage.py deleted file mode 100644 index bb34fc59..00000000 --- a/plugins/module_utils/endpoints/v1/base_paths_manage.py +++ /dev/null @@ -1,115 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -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 - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Final - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ( - ApiPath, -) - - -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.base_paths_manage import BasePath - - # Get a complete base path for ND Manage - path = BasePath.nd_manage("inventory", "switches") - # Returns: /api/v1/manage/inventory/switches - - # Leverage a convenience method - path = BasePath.nd_manage_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 = ApiPath.MANAGE.value - - @classmethod - def nd_manage(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.nd_manage("inventory", "switches") - # Returns: /api/v1/manage/inventory/switches - ``` - """ - if not segments: - return cls.API - return f"{cls.API}/{'/'.join(segments)}" - - @classmethod - def nd_manage_inventory(cls, *segments: str) -> str: - """ - # Summary - - Build ND manage inventory API path. - - ## Parameters - - - segments: Path segments to append after inventory (e.g., "switches") - - ## Returns - - - Complete ND manage inventory path - - ## Example - - ```python - path = BasePath.nd_manage_inventory("switches") - # Returns: /api/v1/manage/inventory/switches - ``` - """ - return cls.nd_manage("inventory", *segments) diff --git a/plugins/module_utils/endpoints/v1/infra_aaa.py b/plugins/module_utils/endpoints/v1/infra_aaa.py deleted file mode 100644 index c47fab32..00000000 --- a/plugins/module_utils/endpoints/v1/infra_aaa.py +++ /dev/null @@ -1,219 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -ND Infra AAA endpoint models. - -This module contains endpoint definitions for AAA-related operations -in the ND Infra API. -""" - -from __future__ import absolute_import, annotations, division, print_function - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Literal - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( - LoginIdMixin, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_infra import ( - BasePath, -) -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, - Field, -) - -# Common config for basic validation -COMMON_CONFIG = ConfigDict(validate_assignment=True) - - -class _EpInfraAaaLocalUsersBase(LoginIdMixin, BaseModel): - """ - Base class for ND Infra AAA Local Users endpoints. - - Provides common functionality for all HTTP methods on the - /api/v1/infra/aaa/localUsers endpoint. - """ - - model_config = COMMON_CONFIG - - # Version metadata - 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 - def path(self) -> str: - """ - # Summary - - Build the endpoint path. - - ## Returns - - - Complete endpoint path string, optionally including login_id - """ - if self.login_id is not None: - return BasePath.nd_infra_aaa("localUsers", self.login_id) - return BasePath.nd_infra_aaa("localUsers") - - -class EpInfraAaaLocalUsersGet(_EpInfraAaaLocalUsersBase): - """ - # Summary - - ND Infra AAA Local Users GET Endpoint - - ## Description - - Endpoint to retrieve local users from the ND Infra AAA service. - Optionally retrieve a specific local user by login_id. - - ## Path - - - /api/v1/infra/aaa/localUsers - - /api/v1/infra/aaa/localUsers/{login_id} - - ## Verb - - - GET - - ## Usage - - ```python - # Get all local users - request = EpApiV1InfraAaaLocalUsersGet() - path = request.path - verb = request.verb - - # Get specific local user - request = EpApiV1InfraAaaLocalUsersGet() - request.login_id = "admin" - path = request.path - verb = request.verb - ``` - """ - - class_name: Literal["EpInfraAaaLocalUsersGet"] = Field(default="EpInfraAaaLocalUsersGet", description="Class name for backward compatibility") - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.GET - - -class EpInfraAaaLocalUsersPost(_EpInfraAaaLocalUsersBase): - """ - # Summary - - ND Infra AAA Local Users POST Endpoint - - ## Description - - Endpoint to create a local user in the ND Infra AAA service. - - ## Path - - - /api/v1/infra/aaa/localUsers - - ## Verb - - - POST - - ## Usage - - ```python - request = EpApiV1InfraAaaLocalUsersPost() - path = request.path - verb = request.verb - ``` - """ - - class_name: Literal["EpInfraAaaLocalUsersPost"] = Field(default="EpInfraAaaLocalUsersPost", description="Class name for backward compatibility") - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.POST - - -class EpInfraAaaLocalUsersPut(_EpInfraAaaLocalUsersBase): - """ - # Summary - - ND Infra AAA Local Users PUT Endpoint - - ## Description - - Endpoint to update a local user in the ND Infra AAA service. - - ## Path - - - /api/v1/infra/aaa/localUsers/{login_id} - - ## Verb - - - PUT - - ## Usage - - ```python - request = EpApiV1InfraAaaLocalUsersPut() - request.login_id = "admin" - path = request.path - verb = request.verb - ``` - """ - - class_name: Literal["EpInfraAaaLocalUsersPut"] = Field(default="EpInfraAaaLocalUsersPut", description="Class name for backward compatibility") - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.PUT - - -class EpInfraAaaLocalUsersDelete(_EpInfraAaaLocalUsersBase): - """ - # Summary - - ND Infra AAA Local Users DELETE Endpoint - - ## Description - - Endpoint to delete a local user from the ND Infra AAA service. - - ## Path - - - /api/v1/infra/aaa/localUsers/{login_id} - - ## Verb - - - DELETE - - ## Usage - - ```python - request = EpApiV1InfraAaaLocalUsersDelete() - request.login_id = "admin" - path = request.path - verb = request.verb - ``` - """ - - class_name: Literal["EpInfraAaaLocalUsersDelete"] = Field(default="EpInfraAaaLocalUsersDelete", 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_clusterhealth.py b/plugins/module_utils/endpoints/v1/infra_clusterhealth.py deleted file mode 100644 index 858502da..00000000 --- a/plugins/module_utils/endpoints/v1/infra_clusterhealth.py +++ /dev/null @@ -1,241 +0,0 @@ -# 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 - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from typing import Literal - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( - EndpointQueryParams, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_infra import ( - BasePath, -) -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, - Field, -) - -# Common config for basic validation -COMMON_CONFIG = ConfigDict(validate_assignment=True) - - -class ClusterHealthConfigEndpointParams(EndpointQueryParams): - """ - # Summary - - Endpoint-specific query parameters for cluster health config endpoint. - - ## Parameters - - - cluster_name: Cluster name (optional) - - ## Usage - - ```python - params = ClusterHealthConfigEndpointParams(cluster_name="my-cluster") - query_string = params.to_query_string() - # Returns: "clusterName=my-cluster" - ``` - """ - - cluster_name: Optional[str] = Field(default=None, min_length=1, description="Cluster name") - - -class ClusterHealthStatusEndpointParams(EndpointQueryParams): - """ - # Summary - - Endpoint-specific query parameters for cluster health status endpoint. - - ## Parameters - - - cluster_name: Cluster name (optional) - - health_category: Health category (optional) - - node_name: Node name (optional) - - ## 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" - ``` - """ - - cluster_name: Optional[str] = Field(default=None, min_length=1, description="Cluster name") - health_category: Optional[str] = Field(default=None, min_length=1, description="Health category") - node_name: Optional[str] = Field(default=None, min_length=1, description="Node name") - - -class EpInfraClusterhealthConfigGet(BaseModel): - """ - # 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 - ``` - """ - - model_config = COMMON_CONFIG - - # Version metadata - 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") - - class_name: Literal["EpInfraClusterhealthConfigGet"] = Field(default="EpInfraClusterhealthConfigGet", 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.nd_infra_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 - - -class EpInfraClusterhealthStatusGet(BaseModel): - """ - # 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 - ``` - """ - - model_config = COMMON_CONFIG - - # Version metadata - 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") - - class_name: Literal["EpInfraClusterhealthStatusGet"] = Field(default="EpInfraClusterhealthStatusGet", 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.nd_infra_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 deleted file mode 100644 index 9363d6eb..00000000 --- a/plugins/module_utils/endpoints/v1/infra_login.py +++ /dev/null @@ -1,91 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -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 - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Literal - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_infra import ( - BasePath, -) -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, - Field, -) - - -class EpInfraLoginPost(BaseModel): - """ - # 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 - """ - - model_config = ConfigDict(validate_assignment=True) - - 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") - class_name: Literal["EpInfraLoginPost"] = Field(default="EpInfraLoginPost", 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.nd_infra("login") - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.POST diff --git a/tests/config.yml b/tests/config.yml deleted file mode 100644 index 7cf024ab..00000000 --- a/tests/config.yml +++ /dev/null @@ -1,3 +0,0 @@ -modules: - # Limit Python version to control node Python versions - python_requires: controller diff --git a/tests/unit/module_utils/endpoints/test_base_path.py b/tests/unit/module_utils/endpoints/test_base_path.py deleted file mode 100644 index 3929d964..00000000 --- a/tests/unit/module_utils/endpoints/test_base_path.py +++ /dev/null @@ -1,444 +0,0 @@ -# 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_path.py - -Tests the root API path constants defined in base_path.py -""" - -from __future__ import absolute_import, annotations, division, print_function - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ( - LOGIN, - ND_ANALYZE_API, - ND_INFRA_API, - ND_MANAGE_API, - ND_MSO_API, - ND_ONEMANAGE_API, - NDFC_API, - ApiPath, -) -from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( - does_not_raise, -) - -# ============================================================================= -# Test: Root API Path Constants -# ============================================================================= - - -def test_base_path_00010(): - """ - # Summary - - Verify ND_ANALYZE_API constant value - - ## Test - - - ND_ANALYZE_API equals "/api/v1/analyze" - - ## Classes and Methods - - - base_path.ND_ANALYZE_API - """ - with does_not_raise(): - result = ND_ANALYZE_API - assert result == "/api/v1/analyze" - - -def test_base_path_00020(): - """ - # Summary - - Verify ND_INFRA_API constant value - - ## Test - - - ND_INFRA_API equals "/api/v1/infra" - - ## Classes and Methods - - - base_path.ND_INFRA_API - """ - with does_not_raise(): - result = ND_INFRA_API - assert result == "/api/v1/infra" - - -def test_base_path_00030(): - """ - # Summary - - Verify ND_MANAGE_API constant value - - ## Test - - - ND_MANAGE_API equals "/api/v1/manage" - - ## Classes and Methods - - - base_path.ND_MANAGE_API - """ - with does_not_raise(): - result = ND_MANAGE_API - assert result == "/api/v1/manage" - - -def test_base_path_00040(): - """ - # Summary - - Verify ND_ONEMANAGE_API constant value - - ## Test - - - ND_ONEMANAGE_API equals "/api/v1/onemanage" - - ## Classes and Methods - - - base_path.ND_ONEMANAGE_API - """ - with does_not_raise(): - result = ND_ONEMANAGE_API - assert result == "/api/v1/onemanage" - - -def test_base_path_00050(): - """ - # Summary - - Verify ND_MSO_API constant value - - ## Test - - - ND_MSO_API equals "/mso" - - ## Classes and Methods - - - base_path.ND_MSO_API - """ - with does_not_raise(): - result = ND_MSO_API - assert result == "/mso" - - -def test_base_path_00060(): - """ - # Summary - - Verify NDFC_API constant value - - ## Test - - - NDFC_API equals "/appcenter/cisco/ndfc/api" - - ## Classes and Methods - - - base_path.NDFC_API - """ - with does_not_raise(): - result = NDFC_API - assert result == "/appcenter/cisco/ndfc/api" - - -def test_base_path_00070(): - """ - # Summary - - Verify LOGIN constant value - - ## Test - - - LOGIN equals "/login" - - ## Classes and Methods - - - base_path.LOGIN - """ - with does_not_raise(): - result = LOGIN - assert result == "/login" - - -# ============================================================================= -# Test: Constant Immutability (Final types) -# ============================================================================= - - -def test_base_path_00100(): - """ - # Summary - - Verify constants are strings - - ## Test - - - All constants are string types - - This ensures they can be used in path building - - ## Classes and Methods - - - base_path.ND_ANALYZE_API - - base_path.ND_INFRA_API - - base_path.ND_MANAGE_API - - base_path.ND_ONEMANAGE_API - - base_path.ND_MSO_API - - base_path.NDFC_API - - base_path.LOGIN - """ - with does_not_raise(): - assert isinstance(ND_ANALYZE_API, str) - assert isinstance(ND_INFRA_API, str) - assert isinstance(ND_MANAGE_API, str) - assert isinstance(ND_ONEMANAGE_API, str) - assert isinstance(ND_MSO_API, str) - assert isinstance(NDFC_API, str) - assert isinstance(LOGIN, str) - - -def test_base_path_00110(): - """ - # Summary - - Verify all API paths start with forward slash - - ## Test - - - All API path constants start with "/" - - This ensures proper path concatenation - - ## Classes and Methods - - - base_path.ND_ANALYZE_API - - base_path.ND_INFRA_API - - base_path.ND_MANAGE_API - - base_path.ND_ONEMANAGE_API - - base_path.ND_MSO_API - - base_path.NDFC_API - - base_path.LOGIN - """ - with does_not_raise(): - assert ND_ANALYZE_API.startswith("/") - assert ND_INFRA_API.startswith("/") - assert ND_MANAGE_API.startswith("/") - assert ND_ONEMANAGE_API.startswith("/") - assert ND_MSO_API.startswith("/") - assert NDFC_API.startswith("/") - assert LOGIN.startswith("/") - - -def test_base_path_00120(): - """ - # Summary - - Verify no API paths end with trailing slash - - ## Test - - - No API path constants end with "/" - - This prevents double slashes when building paths - - ## Classes and Methods - - - base_path.ND_ANALYZE_API - - base_path.ND_INFRA_API - - base_path.ND_MANAGE_API - - base_path.ND_ONEMANAGE_API - - base_path.ND_MSO_API - - base_path.NDFC_API - - base_path.LOGIN - """ - with does_not_raise(): - assert not ND_ANALYZE_API.endswith("/") - assert not ND_INFRA_API.endswith("/") - assert not ND_MANAGE_API.endswith("/") - assert not ND_ONEMANAGE_API.endswith("/") - assert not ND_MSO_API.endswith("/") - assert not NDFC_API.endswith("/") - assert not LOGIN.endswith("/") - - -# ============================================================================= -# Test: ND API Path Structure -# ============================================================================= - - -def test_base_path_00200(): - """ - # Summary - - Verify ND API paths follow /api/v1/ pattern - - ## Test - - - ND_ANALYZE_API follows the pattern - - ND_INFRA_API follows the pattern - - ND_MANAGE_API follows the pattern - - ND_ONEMANAGE_API follows the pattern - - ## Classes and Methods - - - base_path.ND_ANALYZE_API - - base_path.ND_INFRA_API - - base_path.ND_MANAGE_API - - base_path.ND_ONEMANAGE_API - """ - with does_not_raise(): - assert ND_ANALYZE_API.startswith("/api/v1/") - assert ND_INFRA_API.startswith("/api/v1/") - assert ND_MANAGE_API.startswith("/api/v1/") - assert ND_ONEMANAGE_API.startswith("/api/v1/") - - -def test_base_path_00210(): - """ - # Summary - - Verify non-ND API paths have different structure - - ## Test - - - ND_MSO_API does not follow /api/v1/ pattern - - NDFC_API does not follow /api/v1/ pattern - - LOGIN does not follow /api/v1/ pattern - - ## Classes and Methods - - - base_path.ND_MSO_API - - base_path.NDFC_API - - base_path.LOGIN - """ - with does_not_raise(): - assert not ND_MSO_API.startswith("/api/v1/") - assert not NDFC_API.startswith("/api/v1/") - assert not LOGIN.startswith("/api/v1/") - - -# ============================================================================= -# Test: Path Uniqueness -# ============================================================================= - - -def test_base_path_00300(): - """ - # Summary - - Verify all API path constants are unique - - ## Test - - - Each constant has a different value - - No duplicate paths exist - - ## Classes and Methods - - - base_path.ND_ANALYZE_API - - base_path.ND_INFRA_API - - base_path.ND_MANAGE_API - - base_path.ND_ONEMANAGE_API - - base_path.ND_MSO_API - - base_path.NDFC_API - - base_path.LOGIN - """ - with does_not_raise(): - paths = [ - ND_ANALYZE_API, - ND_INFRA_API, - ND_MANAGE_API, - ND_ONEMANAGE_API, - ND_MSO_API, - NDFC_API, - LOGIN, - ] - # Convert to set and check length matches - assert len(paths) == len(set(paths)), "Duplicate paths found" - - -# ============================================================================= -# Test: ApiPath Enum -# ============================================================================= - - -def test_base_path_00400(): - """ - # Summary - - Verify ApiPath enum provides all expected members - - ## Test - - - All 7 API paths available as enum members - - Enum members have correct string values - - Enum is iterable - - ## Classes and Methods - - - base_path.ApiPath - """ - with does_not_raise(): - paths = list(ApiPath) - - assert len(paths) == 7 - assert ApiPath.ANALYZE in paths - assert ApiPath.INFRA in paths - assert ApiPath.MANAGE in paths - assert ApiPath.ONEMANAGE in paths - assert ApiPath.MSO in paths - assert ApiPath.NDFC in paths - assert ApiPath.LOGIN in paths - - -def test_base_path_00410(): - """ - # Summary - - Verify ApiPath enum values match backward compat constants - - ## Test - - - ApiPath.ANALYZE.value equals ND_ANALYZE_API - - ApiPath.INFRA.value equals ND_INFRA_API - - ApiPath.MANAGE.value equals ND_MANAGE_API - - All enum values match corresponding constants - - ## Classes and Methods - - - base_path.ApiPath - """ - with does_not_raise(): - assert ApiPath.ANALYZE.value == ND_ANALYZE_API - assert ApiPath.INFRA.value == ND_INFRA_API - assert ApiPath.MANAGE.value == ND_MANAGE_API - assert ApiPath.ONEMANAGE.value == ND_ONEMANAGE_API - assert ApiPath.MSO.value == ND_MSO_API - assert ApiPath.NDFC.value == NDFC_API - assert ApiPath.LOGIN.value == LOGIN - - -def test_base_path_00420(): - """ - # Summary - - Verify ApiPath enum members are strings - - ## Test - - - ApiPath enum extends str - - Enum members can be used directly in string operations - - String conversion works correctly - - ## Classes and Methods - - - base_path.ApiPath - """ - with does_not_raise(): - assert isinstance(ApiPath.INFRA, str) - assert isinstance(ApiPath.MANAGE, str) - assert ApiPath.INFRA == "/api/v1/infra" - assert ApiPath.MANAGE == "/api/v1/manage" diff --git a/tests/unit/module_utils/endpoints/test_base_paths_infra.py b/tests/unit/module_utils/endpoints/test_base_paths_infra.py deleted file mode 100644 index d089cfe6..00000000 --- a/tests/unit/module_utils/endpoints/test_base_paths_infra.py +++ /dev/null @@ -1,390 +0,0 @@ -# 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.base_path import ( - ND_INFRA_API, - ApiPath, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_infra 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 ND_INFRA_API and ApiPath.INFRA - - ## Test - - - BasePath.API equals "/api/v1/infra" - - BasePath.API uses ApiPath.INFRA.value - - Backward compat constant still works - - ## Classes and Methods - - - BasePath.API - - ApiPath.INFRA - """ - with does_not_raise(): - result = BasePath.API - assert result == ND_INFRA_API - assert result == ApiPath.INFRA.value - assert result == "/api/v1/infra" - - -# ============================================================================= -# Test: nd_infra() method -# ============================================================================= - - -def test_base_paths_infra_00100(): - """ - # Summary - - Verify nd_infra() with no segments returns API root - - ## Test - - - nd_infra() returns "/api/v1/infra" - - ## Classes and Methods - - - BasePath.nd_infra() - """ - with does_not_raise(): - result = BasePath.nd_infra() - assert result == "/api/v1/infra" - - -def test_base_paths_infra_00110(): - """ - # Summary - - Verify nd_infra() with single segment - - ## Test - - - nd_infra("aaa") returns "/api/v1/infra/aaa" - - ## Classes and Methods - - - BasePath.nd_infra() - """ - with does_not_raise(): - result = BasePath.nd_infra("aaa") - assert result == "/api/v1/infra/aaa" - - -def test_base_paths_infra_00120(): - """ - # Summary - - Verify nd_infra() with multiple segments - - ## Test - - - nd_infra("aaa", "localUsers") returns "/api/v1/infra/aaa/localUsers" - - ## Classes and Methods - - - BasePath.nd_infra() - """ - with does_not_raise(): - result = BasePath.nd_infra("aaa", "localUsers") - assert result == "/api/v1/infra/aaa/localUsers" - - -def test_base_paths_infra_00130(): - """ - # Summary - - Verify nd_infra() with three segments - - ## Test - - - nd_infra("aaa", "localUsers", "user1") returns correct path - - ## Classes and Methods - - - BasePath.nd_infra() - """ - with does_not_raise(): - result = BasePath.nd_infra("aaa", "localUsers", "user1") - assert result == "/api/v1/infra/aaa/localUsers/user1" - - -# ============================================================================= -# Test: nd_infra_aaa() method -# ============================================================================= - - -def test_base_paths_infra_00200(): - """ - # Summary - - Verify nd_infra_aaa() with no segments - - ## Test - - - nd_infra_aaa() returns "/api/v1/infra/aaa" - - ## Classes and Methods - - - BasePath.nd_infra_aaa() - """ - with does_not_raise(): - result = BasePath.nd_infra_aaa() - assert result == "/api/v1/infra/aaa" - - -def test_base_paths_infra_00210(): - """ - # Summary - - Verify nd_infra_aaa() with single segment - - ## Test - - - nd_infra_aaa("localUsers") returns "/api/v1/infra/aaa/localUsers" - - ## Classes and Methods - - - BasePath.nd_infra_aaa() - """ - with does_not_raise(): - result = BasePath.nd_infra_aaa("localUsers") - assert result == "/api/v1/infra/aaa/localUsers" - - -def test_base_paths_infra_00220(): - """ - # Summary - - Verify nd_infra_aaa() with multiple segments - - ## Test - - - nd_infra_aaa("localUsers", "user1") returns correct path - - ## Classes and Methods - - - BasePath.nd_infra_aaa() - """ - with does_not_raise(): - result = BasePath.nd_infra_aaa("localUsers", "user1") - assert result == "/api/v1/infra/aaa/localUsers/user1" - - -# ============================================================================= -# Test: nd_infra_clusterhealth() method -# ============================================================================= - - -def test_base_paths_infra_00300(): - """ - # Summary - - Verify nd_infra_clusterhealth() with no segments - - ## Test - - - nd_infra_clusterhealth() returns "/api/v1/infra/clusterhealth" - - ## Classes and Methods - - - BasePath.nd_infra_clusterhealth() - """ - with does_not_raise(): - result = BasePath.nd_infra_clusterhealth() - assert result == "/api/v1/infra/clusterhealth" - - -def test_base_paths_infra_00310(): - """ - # Summary - - Verify nd_infra_clusterhealth() with "config" segment - - ## Test - - - nd_infra_clusterhealth("config") returns "/api/v1/infra/clusterhealth/config" - - ## Classes and Methods - - - BasePath.nd_infra_clusterhealth() - """ - with does_not_raise(): - result = BasePath.nd_infra_clusterhealth("config") - assert result == "/api/v1/infra/clusterhealth/config" - - -def test_base_paths_infra_00320(): - """ - # Summary - - Verify nd_infra_clusterhealth() with "status" segment - - ## Test - - - nd_infra_clusterhealth("status") returns "/api/v1/infra/clusterhealth/status" - - ## Classes and Methods - - - BasePath.nd_infra_clusterhealth() - """ - with does_not_raise(): - result = BasePath.nd_infra_clusterhealth("status") - assert result == "/api/v1/infra/clusterhealth/status" - - -def test_base_paths_infra_00330(): - """ - # Summary - - Verify nd_infra_clusterhealth() with multiple segments - - ## Test - - - nd_infra_clusterhealth("config", "cluster1") returns correct path - - ## Classes and Methods - - - BasePath.nd_infra_clusterhealth() - """ - with does_not_raise(): - result = BasePath.nd_infra_clusterhealth("config", "cluster1") - assert result == "/api/v1/infra/clusterhealth/config/cluster1" - - -# ============================================================================= -# Test: Method composition -# ============================================================================= - - -def test_base_paths_infra_00400(): - """ - # Summary - - Verify nd_infra_aaa() uses nd_infra() internally - - ## Test - - - nd_infra_aaa("localUsers") equals nd_infra("aaa", "localUsers") - - ## Classes and Methods - - - BasePath.nd_infra() - - BasePath.nd_infra_aaa() - """ - with does_not_raise(): - result1 = BasePath.nd_infra_aaa("localUsers") - result2 = BasePath.nd_infra("aaa", "localUsers") - assert result1 == result2 - - -def test_base_paths_infra_00410(): - """ - # Summary - - Verify nd_infra_clusterhealth() uses nd_infra() internally - - ## Test - - - nd_infra_clusterhealth("config") equals nd_infra("clusterhealth", "config") - - ## Classes and Methods - - - BasePath.nd_infra() - - BasePath.nd_infra_clusterhealth() - """ - with does_not_raise(): - result1 = BasePath.nd_infra_clusterhealth("config") - result2 = BasePath.nd_infra("clusterhealth", "config") - assert result1 == result2 - - -# ============================================================================= -# Test: Edge cases -# ============================================================================= - - -def test_base_paths_infra_00500(): - """ - # Summary - - Verify empty string segment is handled - - ## Test - - - nd_infra("aaa", "", "localUsers") creates path with empty segment - - This creates double slashes (expected behavior) - - ## Classes and Methods - - - BasePath.nd_infra() - """ - with does_not_raise(): - result = BasePath.nd_infra("aaa", "", "localUsers") - assert result == "/api/v1/infra/aaa//localUsers" - - -def test_base_paths_infra_00510(): - """ - # Summary - - Verify segments with special characters - - ## Test - - - nd_infra_aaa("user-name_123") handles hyphens and underscores - - ## Classes and Methods - - - BasePath.nd_infra_aaa() - """ - with does_not_raise(): - result = BasePath.nd_infra_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.nd_infra() - """ - with does_not_raise(): - result = BasePath.nd_infra("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 deleted file mode 100644 index 9561714b..00000000 --- a/tests/unit/module_utils/endpoints/test_base_paths_manage.py +++ /dev/null @@ -1,309 +0,0 @@ -# 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.base_path import ( - ND_MANAGE_API, - ApiPath, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage 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 ND_MANAGE_API and ApiPath.MANAGE - - ## Test - - - BasePath.API equals "/api/v1/manage" - - BasePath.API uses ApiPath.MANAGE.value - - Backward compat constant still works - - ## Classes and Methods - - - BasePath.API - - ApiPath.MANAGE - """ - with does_not_raise(): - result = BasePath.API - assert result == ND_MANAGE_API - assert result == ApiPath.MANAGE.value - assert result == "/api/v1/manage" - - -# ============================================================================= -# Test: nd_manage() method -# ============================================================================= - - -def test_base_paths_manage_00100(): - """ - # Summary - - Verify nd_manage() with no segments returns API root - - ## Test - - - nd_manage() returns "/api/v1/manage" - - ## Classes and Methods - - - BasePath.nd_manage() - """ - with does_not_raise(): - result = BasePath.nd_manage() - assert result == "/api/v1/manage" - - -def test_base_paths_manage_00110(): - """ - # Summary - - Verify nd_manage() with single segment - - ## Test - - - nd_manage("inventory") returns "/api/v1/manage/inventory" - - ## Classes and Methods - - - BasePath.nd_manage() - """ - with does_not_raise(): - result = BasePath.nd_manage("inventory") - assert result == "/api/v1/manage/inventory" - - -def test_base_paths_manage_00120(): - """ - # Summary - - Verify nd_manage() with multiple segments - - ## Test - - - nd_manage("inventory", "switches") returns "/api/v1/manage/inventory/switches" - - ## Classes and Methods - - - BasePath.nd_manage() - """ - with does_not_raise(): - result = BasePath.nd_manage("inventory", "switches") - assert result == "/api/v1/manage/inventory/switches" - - -def test_base_paths_manage_00130(): - """ - # Summary - - Verify nd_manage() with three segments - - ## Test - - - nd_manage("inventory", "switches", "fabric1") returns correct path - - ## Classes and Methods - - - BasePath.nd_manage() - """ - with does_not_raise(): - result = BasePath.nd_manage("inventory", "switches", "fabric1") - assert result == "/api/v1/manage/inventory/switches/fabric1" - - -# ============================================================================= -# Test: nd_manage_inventory() method -# ============================================================================= - - -def test_base_paths_manage_00200(): - """ - # Summary - - Verify nd_manage_inventory() with no segments - - ## Test - - - nd_manage_inventory() returns "/api/v1/manage/inventory" - - ## Classes and Methods - - - BasePath.nd_manage_inventory() - """ - with does_not_raise(): - result = BasePath.nd_manage_inventory() - assert result == "/api/v1/manage/inventory" - - -def test_base_paths_manage_00210(): - """ - # Summary - - Verify nd_manage_inventory() with single segment - - ## Test - - - nd_manage_inventory("switches") returns "/api/v1/manage/inventory/switches" - - ## Classes and Methods - - - BasePath.nd_manage_inventory() - """ - with does_not_raise(): - result = BasePath.nd_manage_inventory("switches") - assert result == "/api/v1/manage/inventory/switches" - - -def test_base_paths_manage_00220(): - """ - # Summary - - Verify nd_manage_inventory() with multiple segments - - ## Test - - - nd_manage_inventory("switches", "fabric1") returns correct path - - ## Classes and Methods - - - BasePath.nd_manage_inventory() - """ - with does_not_raise(): - result = BasePath.nd_manage_inventory("switches", "fabric1") - assert result == "/api/v1/manage/inventory/switches/fabric1" - - -# ============================================================================= -# Test: Method composition -# ============================================================================= - - -def test_base_paths_manage_00300(): - """ - # Summary - - Verify nd_manage_inventory() uses nd_manage() internally - - ## Test - - - nd_manage_inventory("switches") equals nd_manage("inventory", "switches") - - ## Classes and Methods - - - BasePath.nd_manage() - - BasePath.nd_manage_inventory() - """ - with does_not_raise(): - result1 = BasePath.nd_manage_inventory("switches") - result2 = BasePath.nd_manage("inventory", "switches") - assert result1 == result2 - - -def test_base_paths_manage_00310(): - """ - # Summary - - Verify method composition with multiple segments - - ## Test - - - nd_manage_inventory("switches", "summary") equals nd_manage("inventory", "switches", "summary") - - ## Classes and Methods - - - BasePath.nd_manage() - - BasePath.nd_manage_inventory() - """ - with does_not_raise(): - result1 = BasePath.nd_manage_inventory("switches", "summary") - result2 = BasePath.nd_manage("inventory", "switches", "summary") - assert result1 == result2 - - -# ============================================================================= -# Test: Edge cases -# ============================================================================= - - -def test_base_paths_manage_00400(): - """ - # Summary - - Verify empty string segment is handled - - ## Test - - - nd_manage("inventory", "", "switches") creates path with empty segment - - This creates double slashes (expected behavior) - - ## Classes and Methods - - - BasePath.nd_manage() - """ - with does_not_raise(): - result = BasePath.nd_manage("inventory", "", "switches") - assert result == "/api/v1/manage/inventory//switches" - - -def test_base_paths_manage_00410(): - """ - # Summary - - Verify segments with special characters - - ## Test - - - nd_manage_inventory("fabric-name_123") handles hyphens and underscores - - ## Classes and Methods - - - BasePath.nd_manage_inventory() - """ - with does_not_raise(): - result = BasePath.nd_manage_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.nd_manage() - """ - with does_not_raise(): - result = BasePath.nd_manage("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 deleted file mode 100644 index c31674fb..00000000 --- a/tests/unit/module_utils/endpoints/test_endpoint_mixins.py +++ /dev/null @@ -1,560 +0,0 @@ -# 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 -""" - -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 ( - ClusterNameMixin, - FabricNameMixin, - ForceShowRunMixin, - HealthCategoryMixin, - InclAllMsdSwitchesMixin, - LinkUuidMixin, - LoginIdMixin, - NetworkNameMixin, - NodeNameMixin, - SwitchSerialNumberMixin, - VrfNameMixin, -) -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: ForceShowRunMixin -# ============================================================================= - - -def test_endpoint_mixins_00010(): - """ - # Summary - - Verify ForceShowRunMixin default value - - ## Test - - - force_show_run defaults to BooleanStringEnum.FALSE - - ## Classes and Methods - - - ForceShowRunMixin.force_show_run - """ - with does_not_raise(): - instance = ForceShowRunMixin() - assert instance.force_show_run == BooleanStringEnum.FALSE - assert instance.force_show_run.value == "false" - - -def test_endpoint_mixins_00020(): - """ - # Summary - - Verify ForceShowRunMixin can be set - - ## Test - - - force_show_run can be set to TRUE - - ## Classes and Methods - - - ForceShowRunMixin.force_show_run - """ - with does_not_raise(): - instance = ForceShowRunMixin(force_show_run=BooleanStringEnum.TRUE) - assert instance.force_show_run == BooleanStringEnum.TRUE - assert instance.force_show_run.value == "true" - - -# ============================================================================= -# Test: InclAllMsdSwitchesMixin -# ============================================================================= - - -def test_endpoint_mixins_00100(): - """ - # Summary - - Verify InclAllMsdSwitchesMixin default value - - ## Test - - - incl_all_msd_switches defaults to BooleanStringEnum.FALSE - - ## Classes and Methods - - - InclAllMsdSwitchesMixin.incl_all_msd_switches - """ - with does_not_raise(): - instance = InclAllMsdSwitchesMixin() - assert instance.incl_all_msd_switches == BooleanStringEnum.FALSE - assert instance.incl_all_msd_switches.value == "false" - - -def test_endpoint_mixins_00110(): - """ - # Summary - - Verify InclAllMsdSwitchesMixin can be set - - ## Test - - - incl_all_msd_switches can be set to TRUE - - ## Classes and Methods - - - InclAllMsdSwitchesMixin.incl_all_msd_switches - """ - with does_not_raise(): - instance = InclAllMsdSwitchesMixin(incl_all_msd_switches=BooleanStringEnum.TRUE) - assert instance.incl_all_msd_switches == BooleanStringEnum.TRUE - assert instance.incl_all_msd_switches.value == "true" - - -# ============================================================================= -# Test: FabricNameMixin -# ============================================================================= - - -def test_endpoint_mixins_00200(): - """ - # Summary - - Verify FabricNameMixin default value is None - - ## Test - - - fabric_name defaults to None - - ## Classes and Methods - - - FabricNameMixin.fabric_name - """ - with does_not_raise(): - instance = FabricNameMixin() - assert instance.fabric_name is None - - -def test_endpoint_mixins_00210(): - """ - # Summary - - Verify FabricNameMixin can be set - - ## Test - - - fabric_name can be set to a string value - - ## Classes and Methods - - - FabricNameMixin.fabric_name - """ - with does_not_raise(): - instance = FabricNameMixin(fabric_name="MyFabric") - assert instance.fabric_name == "MyFabric" - - -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: SwitchSerialNumberMixin -# ============================================================================= - - -def test_endpoint_mixins_00300(): - """ - # Summary - - Verify SwitchSerialNumberMixin default value is None - - ## Test - - - switch_sn defaults to None - - ## Classes and Methods - - - SwitchSerialNumberMixin.switch_sn - """ - with does_not_raise(): - instance = SwitchSerialNumberMixin() - assert instance.switch_sn is None - - -def test_endpoint_mixins_00310(): - """ - # Summary - - Verify SwitchSerialNumberMixin can be set - - ## Test - - - switch_sn can be set to a string value - - ## Classes and Methods - - - SwitchSerialNumberMixin.switch_sn - """ - with does_not_raise(): - instance = SwitchSerialNumberMixin(switch_sn="FDO12345678") - assert instance.switch_sn == "FDO12345678" - - -# ============================================================================= -# Test: NetworkNameMixin -# ============================================================================= - - -def test_endpoint_mixins_00400(): - """ - # Summary - - Verify NetworkNameMixin default value is None - - ## Test - - - network_name defaults to None - - ## Classes and Methods - - - NetworkNameMixin.network_name - """ - with does_not_raise(): - instance = NetworkNameMixin() - assert instance.network_name is None - - -def test_endpoint_mixins_00410(): - """ - # Summary - - Verify NetworkNameMixin can be set - - ## Test - - - network_name can be set to a string value - - ## Classes and Methods - - - NetworkNameMixin.network_name - """ - with does_not_raise(): - instance = NetworkNameMixin(network_name="MyNetwork") - assert instance.network_name == "MyNetwork" - - -# ============================================================================= -# Test: VrfNameMixin -# ============================================================================= - - -def test_endpoint_mixins_00500(): - """ - # Summary - - Verify VrfNameMixin default value is None - - ## Test - - - vrf_name defaults to None - - ## Classes and Methods - - - VrfNameMixin.vrf_name - """ - with does_not_raise(): - instance = VrfNameMixin() - assert instance.vrf_name is None - - -def test_endpoint_mixins_00510(): - """ - # Summary - - Verify VrfNameMixin can be set - - ## Test - - - vrf_name can be set to a string value - - ## Classes and Methods - - - VrfNameMixin.vrf_name - """ - with does_not_raise(): - instance = VrfNameMixin(vrf_name="MyVRF") - assert instance.vrf_name == "MyVRF" - - -# ============================================================================= -# Test: LinkUuidMixin -# ============================================================================= - - -def test_endpoint_mixins_00600(): - """ - # Summary - - Verify LinkUuidMixin default value is None - - ## Test - - - link_uuid defaults to None - - ## Classes and Methods - - - LinkUuidMixin.link_uuid - """ - with does_not_raise(): - instance = LinkUuidMixin() - assert instance.link_uuid is None - - -def test_endpoint_mixins_00610(): - """ - # Summary - - Verify LinkUuidMixin can be set - - ## Test - - - link_uuid can be set to a UUID string - - ## Classes and Methods - - - LinkUuidMixin.link_uuid - """ - with does_not_raise(): - instance = LinkUuidMixin(link_uuid="123e4567-e89b-12d3-a456-426614174000") - assert instance.link_uuid == "123e4567-e89b-12d3-a456-426614174000" - - -# ============================================================================= -# Test: LoginIdMixin -# ============================================================================= - - -def test_endpoint_mixins_00700(): - """ - # Summary - - Verify LoginIdMixin default value is None - - ## Test - - - login_id defaults to None - - ## Classes and Methods - - - LoginIdMixin.login_id - """ - with does_not_raise(): - instance = LoginIdMixin() - assert instance.login_id is None - - -def test_endpoint_mixins_00710(): - """ - # Summary - - Verify LoginIdMixin can be set - - ## Test - - - login_id can be set to a string value - - ## Classes and Methods - - - LoginIdMixin.login_id - """ - with does_not_raise(): - instance = LoginIdMixin(login_id="admin") - assert instance.login_id == "admin" - - -# ============================================================================= -# Test: ClusterNameMixin -# ============================================================================= - - -def test_endpoint_mixins_00800(): - """ - # Summary - - Verify ClusterNameMixin default value is None - - ## Test - - - cluster_name defaults to None - - ## Classes and Methods - - - ClusterNameMixin.cluster_name - """ - with does_not_raise(): - instance = ClusterNameMixin() - assert instance.cluster_name is None - - -def test_endpoint_mixins_00810(): - """ - # Summary - - Verify ClusterNameMixin can be set - - ## Test - - - cluster_name can be set to a string value - - ## Classes and Methods - - - ClusterNameMixin.cluster_name - """ - with does_not_raise(): - instance = ClusterNameMixin(cluster_name="my-cluster") - assert instance.cluster_name == "my-cluster" - - -# ============================================================================= -# Test: HealthCategoryMixin -# ============================================================================= - - -def test_endpoint_mixins_00900(): - """ - # Summary - - Verify HealthCategoryMixin default value is None - - ## Test - - - health_category defaults to None - - ## Classes and Methods - - - HealthCategoryMixin.health_category - """ - with does_not_raise(): - instance = HealthCategoryMixin() - assert instance.health_category is None - - -def test_endpoint_mixins_00910(): - """ - # Summary - - Verify HealthCategoryMixin can be set - - ## Test - - - health_category can be set to a string value - - ## Classes and Methods - - - HealthCategoryMixin.health_category - """ - with does_not_raise(): - instance = HealthCategoryMixin(health_category="cpu") - assert instance.health_category == "cpu" - - -# ============================================================================= -# Test: NodeNameMixin -# ============================================================================= - - -def test_endpoint_mixins_01000(): - """ - # Summary - - Verify NodeNameMixin default value is None - - ## Test - - - node_name defaults to None - - ## Classes and Methods - - - NodeNameMixin.node_name - """ - with does_not_raise(): - instance = NodeNameMixin() - assert instance.node_name is None - - -def test_endpoint_mixins_01010(): - """ - # Summary - - Verify NodeNameMixin can be set - - ## Test - - - node_name can be set to a string value - - ## Classes and Methods - - - NodeNameMixin.node_name - """ - with does_not_raise(): - instance = NodeNameMixin(node_name="node1") - assert instance.node_name == "node1" - - -# ============================================================================= -# 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.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa.py deleted file mode 100644 index 8c6621e6..00000000 --- a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_aaa.py +++ /dev/null @@ -1,437 +0,0 @@ -# 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_aaa.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 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 deleted file mode 100644 index 04033917..00000000 --- a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_clusterhealth.py +++ /dev/null @@ -1,479 +0,0 @@ -# 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 import ( - ClusterHealthConfigEndpointParams, - ClusterHealthStatusEndpointParams, - EpInfraClusterhealthConfigGet, - 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 result == "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 result == "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 - assert result == "/api/v1/infra/clusterhealth/status?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 - assert result == "/api/v1/infra/clusterhealth/status?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 deleted file mode 100644 index 83caaba8..00000000 --- a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_infra_login.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -""" -Unit tests for infra_login.py - -Tests the ND Infra Login endpoint class -""" - -from __future__ import absolute_import, annotations, division, print_function - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -import pytest # pylint: disable=unused-import -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.infra_login import ( - EpInfraLoginPost, -) -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( - does_not_raise, -) - - -def test_endpoints_api_v1_infra_login_00010(): - """ - # Summary - - Verify EpInfraLoginPost basic instantiation - - ## Test - - - Instance can be created - - class_name is set correctly - - verb is POST - - ## Classes and Methods - - - EpInfraLoginPost.__init__() - - EpInfraLoginPost.class_name - - EpInfraLoginPost.verb - """ - with does_not_raise(): - instance = EpInfraLoginPost() - assert instance.class_name == "EpInfraLoginPost" - assert instance.verb == HttpVerbEnum.POST - - -def test_endpoints_api_v1_infra_login_00020(): - """ - # Summary - - Verify EpInfraLoginPost path - - ## Test - - - path returns /api/v1/infra/login - - ## Classes and Methods - - - EpInfraLoginPost.path - """ - with does_not_raise(): - instance = EpInfraLoginPost() - result = instance.path - assert result == "/api/v1/infra/login" diff --git a/tests/unit/module_utils/endpoints/test_query_params.py b/tests/unit/module_utils/endpoints/test_query_params.py deleted file mode 100644 index 03500336..00000000 --- a/tests/unit/module_utils/endpoints/test_query_params.py +++ /dev/null @@ -1,845 +0,0 @@ -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -""" -Unit tests for query_params.py - -Tests the query parameter composition classes -""" - -# pylint: disable=protected-access - -from __future__ import absolute_import, annotations, division, print_function - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -import pytest -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( - CompositeQueryParams, - EndpointQueryParams, - LuceneQueryParams, -) -from ansible_collections.cisco.nd.plugins.module_utils.enums import BooleanStringEnum -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field -from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( - does_not_raise, -) - -# ============================================================================= -# Helper test class for EndpointQueryParams -# ============================================================================= - - -class SampleEndpointParams(EndpointQueryParams): - """Sample implementation of EndpointQueryParams for testing.""" - - force_show_run: BooleanStringEnum | None = Field(default=None) - fabric_name: str | None = Field(default=None) - switch_count: int | None = Field(default=None) - - -# ============================================================================= -# Test: EndpointQueryParams -# ============================================================================= - - -def test_query_params_00010(): - """ - # Summary - - Verify EndpointQueryParams default implementation - - ## Test - - - to_query_string() returns empty string when no params set - - ## Classes and Methods - - - EndpointQueryParams.to_query_string() - """ - with does_not_raise(): - params = SampleEndpointParams() - result = params.to_query_string() - # Only non-None, non-default values are included - assert result == "" - - -def test_query_params_00020(): - """ - # Summary - - Verify EndpointQueryParams snake_case to camelCase conversion - - ## Test - - - force_show_run converts to forceShowRun - - fabric_name converts to fabricName - - ## Classes and Methods - - - EndpointQueryParams.to_query_string() - - EndpointQueryParams._to_camel_case() - """ - with does_not_raise(): - params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE, fabric_name="Fabric1") - result = params.to_query_string() - assert "forceShowRun=" in result - assert "fabricName=" in result - # Verify no snake_case - assert "force_show_run" not in result - assert "fabric_name" not in result - - -def test_query_params_00030(): - """ - # Summary - - Verify EndpointQueryParams handles Enum values - - ## Test - - - BooleanStringEnum.TRUE converts to "true" - - BooleanStringEnum.FALSE converts to "false" - - ## Classes and Methods - - - EndpointQueryParams.to_query_string() - """ - with does_not_raise(): - params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE) - result = params.to_query_string() - assert "forceShowRun=true" in result - - -def test_query_params_00040(): - """ - # Summary - - Verify EndpointQueryParams handles integer values - - ## Test - - - Integer values are converted to strings - - ## Classes and Methods - - - EndpointQueryParams.to_query_string() - """ - with does_not_raise(): - params = SampleEndpointParams(switch_count=42) - result = params.to_query_string() - assert result == "switchCount=42" - - -def test_query_params_00050(): - """ - # Summary - - Verify EndpointQueryParams handles string values - - ## Test - - - String values are included as-is - - ## Classes and Methods - - - EndpointQueryParams.to_query_string() - """ - with does_not_raise(): - params = SampleEndpointParams(fabric_name="MyFabric") - result = params.to_query_string() - assert result == "fabricName=MyFabric" - - -def test_query_params_00060(): - """ - # Summary - - Verify EndpointQueryParams handles multiple params - - ## Test - - - Multiple parameters are joined with '&' - - ## Classes and Methods - - - EndpointQueryParams.to_query_string() - """ - with does_not_raise(): - params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE, fabric_name="Fabric1", switch_count=10) - result = params.to_query_string() - assert "forceShowRun=true" in result - assert "fabricName=Fabric1" in result - assert "switchCount=10" in result - assert result.count("&") == 2 - - -def test_query_params_00070(): - """ - # Summary - - Verify EndpointQueryParams is_empty() method - - ## Test - - - is_empty() returns True when no params set - - is_empty() returns False when params are set - - ## Classes and Methods - - - EndpointQueryParams.is_empty() - """ - with does_not_raise(): - params = SampleEndpointParams() - assert params.is_empty() is True - - params.fabric_name = "Fabric1" - assert params.is_empty() is False - - -def test_query_params_00080(): - """ - # Summary - - Verify EndpointQueryParams _to_camel_case() static method - - ## Test - - - Correctly converts various snake_case strings to camelCase - - ## Classes and Methods - - - EndpointQueryParams._to_camel_case() - """ - with does_not_raise(): - assert EndpointQueryParams._to_camel_case("simple") == "simple" - assert EndpointQueryParams._to_camel_case("snake_case") == "snakeCase" - assert EndpointQueryParams._to_camel_case("long_snake_case_name") == "longSnakeCaseName" - assert EndpointQueryParams._to_camel_case("single") == "single" - - -# ============================================================================= -# Test: LuceneQueryParams -# ============================================================================= - - -def test_query_params_00100(): - """ - # Summary - - Verify LuceneQueryParams default values - - ## Test - - - All parameters default to None - - ## Classes and Methods - - - LuceneQueryParams.__init__() - """ - with does_not_raise(): - params = LuceneQueryParams() - assert params.filter is None - assert params.max is None - assert params.offset is None - assert params.sort is None - assert params.fields is None - - -def test_query_params_00110(): - """ - # Summary - - Verify LuceneQueryParams filter parameter - - ## Test - - - filter can be set to a string value - - to_query_string() includes filter parameter - - ## Classes and Methods - - - LuceneQueryParams.__init__() - - LuceneQueryParams.to_query_string() - """ - with does_not_raise(): - params = LuceneQueryParams(filter="name:MyFabric") - result = params.to_query_string() - assert "filter=" in result - assert "name" in result - assert "MyFabric" in result - - -def test_query_params_00120(): - """ - # Summary - - Verify LuceneQueryParams max parameter - - ## Test - - - max can be set to an integer value - - to_query_string() includes max parameter - - ## Classes and Methods - - - LuceneQueryParams.__init__() - - LuceneQueryParams.to_query_string() - """ - with does_not_raise(): - params = LuceneQueryParams(max=100) - result = params.to_query_string() - assert result == "max=100" - - -def test_query_params_00130(): - """ - # Summary - - Verify LuceneQueryParams offset parameter - - ## Test - - - offset can be set to an integer value - - to_query_string() includes offset parameter - - ## Classes and Methods - - - LuceneQueryParams.__init__() - - LuceneQueryParams.to_query_string() - """ - with does_not_raise(): - params = LuceneQueryParams(offset=20) - result = params.to_query_string() - assert result == "offset=20" - - -def test_query_params_00140(): - """ - # Summary - - Verify LuceneQueryParams sort parameter - - ## Test - - - sort can be set to a valid string - - to_query_string() includes sort parameter - - ## Classes and Methods - - - LuceneQueryParams.__init__() - - LuceneQueryParams.to_query_string() - """ - with does_not_raise(): - params = LuceneQueryParams(sort="name:asc") - result = params.to_query_string() - assert "sort=" in result - assert "name" in result - - -def test_query_params_00150(): - """ - # Summary - - Verify LuceneQueryParams fields parameter - - ## Test - - - fields can be set to a comma-separated string - - to_query_string() includes fields parameter - - ## Classes and Methods - - - LuceneQueryParams.__init__() - - LuceneQueryParams.to_query_string() - """ - with does_not_raise(): - params = LuceneQueryParams(fields="name,id,status") - result = params.to_query_string() - assert "fields=" in result - - -def test_query_params_00160(): - """ - # Summary - - Verify LuceneQueryParams URL encoding - - ## Test - - - Special characters in filter are URL-encoded by default - - ## Classes and Methods - - - LuceneQueryParams.to_query_string() - """ - with does_not_raise(): - params = LuceneQueryParams(filter="name:Fabric* AND status:active") - result = params.to_query_string(url_encode=True) - # Check for URL-encoded characters - assert "filter=" in result - # Space should be encoded - assert "%20" in result or "+" in result - - -def test_query_params_00170(): - """ - # Summary - - Verify LuceneQueryParams URL encoding can be disabled - - ## Test - - - url_encode=False preserves special characters - - ## Classes and Methods - - - LuceneQueryParams.to_query_string() - """ - with does_not_raise(): - params = LuceneQueryParams(filter="name:Fabric* AND status:active") - result = params.to_query_string(url_encode=False) - assert result == "filter=name:Fabric* AND status:active" - - -def test_query_params_00180(): - """ - # Summary - - Verify LuceneQueryParams is_empty() method - - ## Test - - - is_empty() returns True when no params set - - is_empty() returns False when params are set - - ## Classes and Methods - - - LuceneQueryParams.is_empty() - """ - with does_not_raise(): - params = LuceneQueryParams() - assert params.is_empty() is True - - params.max = 100 - assert params.is_empty() is False - - -def test_query_params_00190(): - """ - # Summary - - Verify LuceneQueryParams multiple parameters - - ## Test - - - Multiple parameters are joined with '&' - - Parameters appear in expected order - - ## Classes and Methods - - - LuceneQueryParams.to_query_string() - """ - with does_not_raise(): - params = LuceneQueryParams(filter="name:*", max=50, offset=10, sort="name:asc") - result = params.to_query_string(url_encode=False) - assert "filter=name:*" in result - assert "max=50" in result - assert "offset=10" in result - assert "sort=name:asc" in result - - -# ============================================================================= -# Test: LuceneQueryParams validation -# ============================================================================= - - -def test_query_params_00200(): - """ - # Summary - - Verify LuceneQueryParams validates max range - - ## Test - - - max must be >= 1 - - max must be <= 10000 - - ## Classes and Methods - - - LuceneQueryParams.__init__() - """ - # Valid values - with does_not_raise(): - LuceneQueryParams(max=1) - LuceneQueryParams(max=10000) - LuceneQueryParams(max=500) - - # Invalid values - with pytest.raises(ValueError): - LuceneQueryParams(max=0) - - with pytest.raises(ValueError): - LuceneQueryParams(max=10001) - - -def test_query_params_00210(): - """ - # Summary - - Verify LuceneQueryParams validates offset range - - ## Test - - - offset must be >= 0 - - ## Classes and Methods - - - LuceneQueryParams.__init__() - """ - # Valid values - with does_not_raise(): - LuceneQueryParams(offset=0) - LuceneQueryParams(offset=100) - - # Invalid values - with pytest.raises(ValueError): - LuceneQueryParams(offset=-1) - - -def test_query_params_00220(): - """ - # Summary - - Verify LuceneQueryParams validates sort format - - ## Test - - - sort direction must be 'asc' or 'desc' - - Invalid directions are rejected - - ## Classes and Methods - - - LuceneQueryParams.validate_sort() - """ - # Valid values - with does_not_raise(): - LuceneQueryParams(sort="name:asc") - LuceneQueryParams(sort="name:desc") - LuceneQueryParams(sort="name:ASC") - LuceneQueryParams(sort="name:DESC") - - # Invalid direction - with pytest.raises(ValueError, match="Sort direction must be"): - LuceneQueryParams(sort="name:invalid") - - -def test_query_params_00230(): - """ - # Summary - - Verify LuceneQueryParams allows sort without direction - - ## Test - - - sort can be set without ':' separator - - Validation only applies when ':' is present - - ## Classes and Methods - - - LuceneQueryParams.validate_sort() - """ - with does_not_raise(): - params = LuceneQueryParams(sort="name") - result = params.to_query_string(url_encode=False) - assert result == "sort=name" - - -# ============================================================================= -# Test: CompositeQueryParams -# ============================================================================= - - -def test_query_params_00300(): - """ - # Summary - - Verify CompositeQueryParams basic instantiation - - ## Test - - - Instance can be created - - Starts with empty parameter groups - - ## Classes and Methods - - - CompositeQueryParams.__init__() - """ - with does_not_raise(): - composite = CompositeQueryParams() - assert composite.is_empty() is True - - -def test_query_params_00310(): - """ - # Summary - - Verify CompositeQueryParams add() method - - ## Test - - - Can add EndpointQueryParams - - Returns self for method chaining - - ## Classes and Methods - - - CompositeQueryParams.add() - """ - with does_not_raise(): - composite = CompositeQueryParams() - endpoint_params = SampleEndpointParams(fabric_name="Fabric1") - result = composite.add(endpoint_params) - assert result is composite - assert composite.is_empty() is False - - -def test_query_params_00320(): - """ - # Summary - - Verify CompositeQueryParams add() with LuceneQueryParams - - ## Test - - - Can add LuceneQueryParams - - Parameters are combined correctly - - ## Classes and Methods - - - CompositeQueryParams.add() - - CompositeQueryParams.to_query_string() - """ - with does_not_raise(): - composite = CompositeQueryParams() - lucene_params = LuceneQueryParams(max=100) - composite.add(lucene_params) - result = composite.to_query_string() - assert result == "max=100" - - -def test_query_params_00330(): - """ - # Summary - - Verify CompositeQueryParams method chaining - - ## Test - - - Multiple add() calls can be chained - - All parameters are included in final query string - - ## Classes and Methods - - - CompositeQueryParams.add() - - CompositeQueryParams.to_query_string() - """ - with does_not_raise(): - endpoint_params = SampleEndpointParams(fabric_name="Fabric1") - lucene_params = LuceneQueryParams(max=50) - - composite = CompositeQueryParams() - composite.add(endpoint_params).add(lucene_params) - - result = composite.to_query_string() - assert "fabricName=Fabric1" in result - assert "max=50" in result - - -def test_query_params_00340(): - """ - # Summary - - Verify CompositeQueryParams parameter ordering - - ## Test - - - Parameters appear in order they were added - - EndpointQueryParams before LuceneQueryParams - - ## Classes and Methods - - - CompositeQueryParams.to_query_string() - """ - with does_not_raise(): - endpoint_params = SampleEndpointParams(fabric_name="Fabric1") - lucene_params = LuceneQueryParams(max=50) - - composite = CompositeQueryParams() - composite.add(endpoint_params).add(lucene_params) - - result = composite.to_query_string() - - # fabricName should appear before max - fabric_pos = result.index("fabricName") - max_pos = result.index("max") - assert fabric_pos < max_pos - - -def test_query_params_00350(): - """ - # Summary - - Verify CompositeQueryParams is_empty() method - - ## Test - - - is_empty() returns True when all groups are empty - - is_empty() returns False when any group has params - - ## Classes and Methods - - - CompositeQueryParams.is_empty() - """ - with does_not_raise(): - composite = CompositeQueryParams() - assert composite.is_empty() is True - - # Add empty parameter group - empty_params = SampleEndpointParams() - composite.add(empty_params) - assert composite.is_empty() is True - - # Add non-empty parameter group - endpoint_params = SampleEndpointParams(fabric_name="Fabric1") - composite.add(endpoint_params) - assert composite.is_empty() is False - - -def test_query_params_00360(): - """ - # Summary - - Verify CompositeQueryParams clear() method - - ## Test - - - clear() removes all parameter groups - - is_empty() returns True after clear() - - ## Classes and Methods - - - CompositeQueryParams.clear() - - CompositeQueryParams.is_empty() - """ - with does_not_raise(): - composite = CompositeQueryParams() - endpoint_params = SampleEndpointParams(fabric_name="Fabric1") - composite.add(endpoint_params) - - assert composite.is_empty() is False - - composite.clear() - assert composite.is_empty() is True - - -def test_query_params_00370(): - """ - # Summary - - Verify CompositeQueryParams URL encoding propagation - - ## Test - - - url_encode parameter is passed to LuceneQueryParams - - EndpointQueryParams not affected (no url_encode parameter) - - ## Classes and Methods - - - CompositeQueryParams.to_query_string() - """ - with does_not_raise(): - endpoint_params = SampleEndpointParams(fabric_name="My Fabric") - lucene_params = LuceneQueryParams(filter="name:Test Value") - - composite = CompositeQueryParams() - composite.add(endpoint_params).add(lucene_params) - - # With URL encoding - result_encoded = composite.to_query_string(url_encode=True) - assert "filter=" in result_encoded - - # Without URL encoding - result_plain = composite.to_query_string(url_encode=False) - assert "filter=name:Test Value" in result_plain - - -def test_query_params_00380(): - """ - # Summary - - Verify CompositeQueryParams with empty groups - - ## Test - - - Empty parameter groups are skipped in query string - - Only non-empty groups contribute to query string - - ## Classes and Methods - - - CompositeQueryParams.to_query_string() - """ - with does_not_raise(): - empty_endpoint = SampleEndpointParams() - non_empty_lucene = LuceneQueryParams(max=100) - - composite = CompositeQueryParams() - composite.add(empty_endpoint).add(non_empty_lucene) - - result = composite.to_query_string() - - # Should only contain the Lucene params - assert result == "max=100" - - -# ============================================================================= -# Test: Integration scenarios -# ============================================================================= - - -def test_query_params_00400(): - """ - # Summary - - Verify complex query string composition - - ## Test - - - Combine multiple EndpointQueryParams with LuceneQueryParams - - All parameters are correctly formatted and encoded - - ## Classes and Methods - - - CompositeQueryParams.add() - - CompositeQueryParams.to_query_string() - """ - with does_not_raise(): - endpoint_params = SampleEndpointParams(force_show_run=BooleanStringEnum.TRUE, fabric_name="Production", switch_count=5) - - lucene_params = LuceneQueryParams(filter="status:active AND role:leaf", max=100, offset=0, sort="name:asc") - - composite = CompositeQueryParams() - composite.add(endpoint_params).add(lucene_params) - - result = composite.to_query_string(url_encode=False) - - # Verify all parameters present - assert "forceShowRun=true" in result - assert "fabricName=Production" in result - assert "switchCount=5" in result - assert "filter=status:active AND role:leaf" in result - assert "max=100" in result - assert "offset=0" in result - assert "sort=name:asc" in result From 8b59b5fb23ad174db8e21b5169097661e793a871 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 10 Mar 2026 10:10:21 +0530 Subject: [PATCH 08/39] Revert "nd42_rest_send: apply rest_send branch changes" This reverts commit e14537fe378a228ae210c187524d753a01202749. --- plugins/module_utils/__init__.py | 0 plugins/module_utils/common/__init__.py | 0 plugins/module_utils/common/exceptions.py | 146 -- .../module_utils/common/pydantic_compat.py | 243 --- plugins/module_utils/enums.py | 158 -- plugins/module_utils/nd_v2.py | 317 ---- plugins/module_utils/rest/__init__.py | 0 .../module_utils/rest/protocols/__init__.py | 0 .../rest/protocols/response_handler.py | 138 -- .../rest/protocols/response_validation.py | 193 --- plugins/module_utils/rest/protocols/sender.py | 103 -- .../module_utils/rest/response_handler_nd.py | 409 ----- .../rest/response_strategies/__init__.py | 0 .../response_strategies/nd_v1_strategy.py | 246 --- plugins/module_utils/rest/rest_send.py | 797 --------- plugins/module_utils/rest/results.py | 1019 ----------- plugins/module_utils/rest/sender_nd.py | 322 ---- tests/sanity/requirements.txt | 9 +- tests/unit/__init__.py | 0 tests/unit/module_utils/__init__.py | 0 tests/unit/module_utils/common_utils.py | 75 - .../fixtures/fixture_data/test_rest_send.json | 244 --- .../module_utils/fixtures/load_fixture.py | 46 - .../unit/module_utils/mock_ansible_module.py | 95 -- tests/unit/module_utils/response_generator.py | 60 - tests/unit/module_utils/sender_file.py | 293 ---- .../module_utils/test_response_handler_nd.py | 1496 ----------------- tests/unit/module_utils/test_rest_send.py | 1445 ---------------- tests/unit/module_utils/test_sender_nd.py | 906 ---------- 29 files changed, 3 insertions(+), 8757 deletions(-) delete mode 100644 plugins/module_utils/__init__.py delete mode 100644 plugins/module_utils/common/__init__.py delete mode 100644 plugins/module_utils/common/exceptions.py delete mode 100644 plugins/module_utils/common/pydantic_compat.py delete mode 100644 plugins/module_utils/enums.py delete mode 100644 plugins/module_utils/nd_v2.py delete mode 100644 plugins/module_utils/rest/__init__.py delete mode 100644 plugins/module_utils/rest/protocols/__init__.py delete mode 100644 plugins/module_utils/rest/protocols/response_handler.py delete mode 100644 plugins/module_utils/rest/protocols/response_validation.py delete mode 100644 plugins/module_utils/rest/protocols/sender.py delete mode 100644 plugins/module_utils/rest/response_handler_nd.py delete mode 100644 plugins/module_utils/rest/response_strategies/__init__.py delete mode 100644 plugins/module_utils/rest/response_strategies/nd_v1_strategy.py delete mode 100644 plugins/module_utils/rest/rest_send.py delete mode 100644 plugins/module_utils/rest/results.py delete mode 100644 plugins/module_utils/rest/sender_nd.py delete mode 100644 tests/unit/__init__.py delete mode 100644 tests/unit/module_utils/__init__.py delete mode 100644 tests/unit/module_utils/common_utils.py delete mode 100644 tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json delete mode 100644 tests/unit/module_utils/fixtures/load_fixture.py delete mode 100644 tests/unit/module_utils/mock_ansible_module.py delete mode 100644 tests/unit/module_utils/response_generator.py delete mode 100644 tests/unit/module_utils/sender_file.py delete mode 100644 tests/unit/module_utils/test_response_handler_nd.py delete mode 100644 tests/unit/module_utils/test_rest_send.py delete mode 100644 tests/unit/module_utils/test_sender_nd.py diff --git a/plugins/module_utils/__init__.py b/plugins/module_utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/plugins/module_utils/common/__init__.py b/plugins/module_utils/common/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/plugins/module_utils/common/exceptions.py b/plugins/module_utils/common/exceptions.py deleted file mode 100644 index 16e31ac6..00000000 --- a/plugins/module_utils/common/exceptions.py +++ /dev/null @@ -1,146 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -# exceptions.py - -Exception classes for the cisco.nd Ansible collection. -""" - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from typing import Any, Optional - -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, -) - - -class NDErrorData(BaseModel): - """ - # Summary - - Pydantic model for structured error data from NDModule requests. - - This model provides type-safe error information that can be serialized - to a dict for use with Ansible's fail_json. - - ## Attributes - - - msg: Human-readable error message (required) - - status: HTTP status code as integer (optional) - - request_payload: Request payload that was sent (optional) - - response_payload: Response payload from controller (optional) - - raw: Raw response content for non-JSON responses (optional) - - ## Raises - - - None - """ - - model_config = ConfigDict(extra="forbid") - - msg: str - status: Optional[int] = None - request_payload: Optional[dict[str, Any]] = None - response_payload: Optional[dict[str, Any]] = None - raw: Optional[Any] = None - - -class NDModuleError(Exception): - """ - # Summary - - Exception raised by NDModule when a request fails. - - This exception wraps an NDErrorData Pydantic model, providing structured - error information that can be used by callers to build appropriate error - responses (e.g., Ansible fail_json). - - ## Usage Example - - ```python - try: - data = nd.request("/api/v1/endpoint", HttpVerbEnum.POST, payload) - except NDModuleError as e: - print(f"Error: {e.msg}") - print(f"Status: {e.status}") - if e.response_payload: - print(f"Response: {e.response_payload}") - # Use to_dict() for fail_json - module.fail_json(**e.to_dict()) - ``` - - ## Raises - - - None - """ - - # pylint: disable=too-many-arguments - def __init__( - self, - msg: str, - status: Optional[int] = None, - request_payload: Optional[dict[str, Any]] = None, - response_payload: Optional[dict[str, Any]] = None, - raw: Optional[Any] = None, - ) -> None: - self.error_data = NDErrorData( - msg=msg, - status=status, - request_payload=request_payload, - response_payload=response_payload, - raw=raw, - ) - super().__init__(msg) - - @property - def msg(self) -> str: - """Human-readable error message.""" - return self.error_data.msg - - @property - def status(self) -> Optional[int]: - """HTTP status code.""" - return self.error_data.status - - @property - def request_payload(self) -> Optional[dict[str, Any]]: - """Request payload that was sent.""" - return self.error_data.request_payload - - @property - def response_payload(self) -> Optional[dict[str, Any]]: - """Response payload from controller.""" - return self.error_data.response_payload - - @property - def raw(self) -> Optional[Any]: - """Raw response content for non-JSON responses.""" - return self.error_data.raw - - def to_dict(self) -> dict[str, Any]: - """ - # Summary - - Convert exception attributes to a dict for use with fail_json. - - Returns a dict containing only non-None attributes. - - ## Raises - - - None - """ - return self.error_data.model_dump(exclude_none=True) diff --git a/plugins/module_utils/common/pydantic_compat.py b/plugins/module_utils/common/pydantic_compat.py deleted file mode 100644 index e1550a18..00000000 --- a/plugins/module_utils/common/pydantic_compat.py +++ /dev/null @@ -1,243 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -# pylint: disable=too-few-public-methods -""" -# Summary - -Pydantic compatibility layer. - -This module provides a single location for Pydantic imports with fallback -implementations when Pydantic is not available. This ensures consistent -behavior across all modules and follows the DRY principle. - -## Usage - -### Importing - -Rather than importing directly from pydantic, import from this module: - -```python -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel -``` - -This ensure that Ansible sanity tests will not fail due to missing Pydantic dependencies. -""" - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -import traceback -from typing import TYPE_CHECKING, Any, Callable, Union - -if TYPE_CHECKING: - # Type checkers always see the real Pydantic types - from pydantic import ( - AfterValidator, - BaseModel, - BeforeValidator, - ConfigDict, - Field, - PydanticExperimentalWarning, - StrictBool, - ValidationError, - field_serializer, - field_validator, - model_validator, - validator, - ) - - HAS_PYDANTIC = True # pylint: disable=invalid-name - PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name -else: - # Runtime: try to import, with fallback - try: - from pydantic import ( - AfterValidator, - BaseModel, - BeforeValidator, - ConfigDict, - Field, - PydanticExperimentalWarning, - StrictBool, - ValidationError, - field_serializer, - field_validator, - model_validator, - validator, - ) - except ImportError: - HAS_PYDANTIC = False # pylint: disable=invalid-name - PYDANTIC_IMPORT_ERROR: Union[str, None] = traceback.format_exc() # pylint: disable=invalid-name - - # Fallback: Minimal BaseModel replacement - class BaseModel: - """Fallback BaseModel when pydantic is not available.""" - - model_config = {"validate_assignment": False, "use_enum_values": False} - - def __init__(self, **kwargs): - """Accept keyword arguments and set them as attributes.""" - for key, value in kwargs.items(): - setattr(self, key, value) - - def model_dump(self, exclude_none: bool = False, exclude_defaults: bool = False) -> dict: # pylint: disable=unused-argument - """Return a dictionary of field names and values. - - Args: - exclude_none: If True, exclude fields with None values - exclude_defaults: Accepted for API compatibility but not implemented in fallback - """ - result = {} - for key, value in self.__dict__.items(): - if exclude_none and value is None: - continue - result[key] = value - return result - - # Fallback: ConfigDict that does nothing - def ConfigDict(**kwargs) -> dict: # pylint: disable=unused-argument,invalid-name - """Pydantic ConfigDict fallback when pydantic is not available.""" - return kwargs - - # Fallback: Field that does nothing - def Field(**kwargs) -> Any: # pylint: disable=unused-argument,invalid-name - """Pydantic Field fallback when pydantic is not available.""" - if "default_factory" in kwargs: - return kwargs["default_factory"]() - return kwargs.get("default") - - # Fallback: field_serializer decorator that does nothing - def field_serializer(*args, **kwargs): # pylint: disable=unused-argument - """Pydantic field_serializer fallback when pydantic is not available.""" - - def decorator(func): - return func - - return decorator - - # Fallback: field_validator decorator that does nothing - def field_validator(*args, **kwargs) -> Callable[..., Any]: # pylint: disable=unused-argument,invalid-name - """Pydantic field_validator fallback when pydantic is not available.""" - - def decorator(func): - return func - - return decorator - - # Fallback: AfterValidator that returns the function unchanged - def AfterValidator(func): # pylint: disable=invalid-name - """Pydantic AfterValidator fallback when pydantic is not available.""" - return func - - # Fallback: BeforeValidator that returns the function unchanged - def BeforeValidator(func): # pylint: disable=invalid-name - """Pydantic BeforeValidator fallback when pydantic is not available.""" - return func - - # Fallback: PydanticExperimentalWarning - PydanticExperimentalWarning = Warning - - # Fallback: StrictBool - StrictBool = bool - - # Fallback: ValidationError - class ValidationError(Exception): - """ - Pydantic ValidationError fallback when pydantic is not available. - """ - - def __init__(self, message="A custom error occurred."): - self.message = message - super().__init__(self.message) - - def __str__(self): - return f"ValidationError: {self.message}" - - # Fallback: model_validator decorator that does nothing - def model_validator(*args, **kwargs): # pylint: disable=unused-argument - """Pydantic model_validator fallback when pydantic is not available.""" - - def decorator(func): - return func - - return decorator - - # Fallback: validator decorator that does nothing - def validator(*args, **kwargs): # pylint: disable=unused-argument - """Pydantic validator fallback when pydantic is not available.""" - - def decorator(func): - return func - - return decorator - - else: - HAS_PYDANTIC = True # pylint: disable=invalid-name - PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name - - -def require_pydantic(module) -> None: - """ - # Summary - - Call `module.fail_json` if pydantic is not installed. - - Intended to be called once at the top of a module's `main()` function, - immediately after `AnsibleModule` is instantiated, to provide a clear - error message when pydantic is a required dependency. - - ## Example - - ```python - from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic - - def main(): - module = AnsibleModule(argument_spec=...) - require_pydantic(module) - ``` - - ## Raises - - None - - ## Notes - - - Does nothing if pydantic is installed. - - Uses Ansible's `missing_required_lib` to produce a standardized error - message that includes installation instructions. - """ - if not HAS_PYDANTIC: - from ansible.module_utils.basic import missing_required_lib # pylint: disable=import-outside-toplevel - - module.fail_json(msg=missing_required_lib("pydantic"), exception=PYDANTIC_IMPORT_ERROR) - - -__all__ = [ - "AfterValidator", - "BaseModel", - "BeforeValidator", - "ConfigDict", - "Field", - "HAS_PYDANTIC", - "PYDANTIC_IMPORT_ERROR", - "PydanticExperimentalWarning", - "StrictBool", - "ValidationError", - "field_serializer", - "field_validator", - "model_validator", - "require_pydantic", - "validator", -] diff --git a/plugins/module_utils/enums.py b/plugins/module_utils/enums.py deleted file mode 100644 index 55d1f1ac..00000000 --- a/plugins/module_utils/enums.py +++ /dev/null @@ -1,158 +0,0 @@ -# -*- coding: utf-8 -*- -# pylint: disable=wrong-import-position -# pylint: disable=missing-module-docstring -# Copyright: (c) 2026, Allen Robel (@allenrobel) -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -# Summary - -Enum definitions for Nexus Dashboard Ansible modules. - -## Enums - -- HttpVerbEnum: Enum for HTTP verb values used in endpoints. -- OperationType: Enum for operation types used by Results to determine if changes have occurred. -""" - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from enum import Enum - - -class BooleanStringEnum(str, Enum): - """ - # Summary - - Enum for boolean string values used in query parameters. - - ## Members - - - TRUE: Represents the string "true". - - FALSE: Represents the string "false". - """ - - TRUE = "true" - FALSE = "false" - - -class HttpVerbEnum(str, Enum): - """ - # Summary - - Enum for HTTP verb values used in endpoints. - - ## Members - - - GET: Represents the HTTP GET method. - - POST: Represents the HTTP POST method. - - PUT: Represents the HTTP PUT method. - - DELETE: Represents the HTTP DELETE method. - - PATCH: Represents the HTTP PATCH method. - """ - - GET = "GET" - POST = "POST" - PUT = "PUT" - DELETE = "DELETE" - PATCH = "PATCH" - - @classmethod - def values(cls) -> list[str]: - """ - # Summary - - Returns a list of all enum values. - - ## Returns - - - A list of string values representing the enum members. - """ - return sorted([member.value for member in cls]) - - -class OperationType(Enum): - """ - # Summary - - Enumeration for operation types. - - Used by Results to determine if changes have occurred based on the operation type. - - - QUERY: Represents a query operation which does not change state. - - CREATE: Represents a create operation which adds new resources. - - UPDATE: Represents an update operation which modifies existing resources. - - DELETE: Represents a delete operation which removes resources. - - # Usage - - ```python - from plugins.module_utils.enums import OperationType - class MyModule: - def __init__(self): - self.operation_type = OperationType.QUERY - ``` - - The above informs the Results class that the current operation is a query, and thus - no changes should be expected. - - Specifically, Results._determine_if_changed() will return False for QUERY operations, - while it will evaluate CREATE, UPDATE, and DELETE operations in more detail to - determine if any changes have occurred. - """ - - QUERY = "query" - CREATE = "create" - UPDATE = "update" - DELETE = "delete" - - def changes_state(self) -> bool: - """ - # Summary - - Return True if this operation type can change controller state. - - ## Returns - - - `bool`: True if operation can change state, False otherwise - - ## Examples - - ```python - OperationType.QUERY.changes_state() # Returns False - OperationType.CREATE.changes_state() # Returns True - OperationType.DELETE.changes_state() # Returns True - ``` - """ - return self in ( - OperationType.CREATE, - OperationType.UPDATE, - OperationType.DELETE, - ) - - def is_read_only(self) -> bool: - """ - # Summary - - Return True if this operation type is read-only. - - ## Returns - - - `bool`: True if operation is read-only, False otherwise - - ## Examples - - ```python - OperationType.QUERY.is_read_only() # Returns True - OperationType.CREATE.is_read_only() # Returns False - ``` - """ - return self == OperationType.QUERY diff --git a/plugins/module_utils/nd_v2.py b/plugins/module_utils/nd_v2.py deleted file mode 100644 index 0a3fe61a..00000000 --- a/plugins/module_utils/nd_v2.py +++ /dev/null @@ -1,317 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -# nd_v2.py - -Simplified NDModule using RestSend infrastructure with exception-based error handling. - -This module provides a streamlined interface for interacting with Nexus Dashboard -controllers. Unlike the original nd.py which uses Ansible's fail_json/exit_json, -this module raises Python exceptions, making it: - -- Easier to unit test -- Reusable with non-Ansible code (e.g., raw Python Requests) -- More Pythonic in error handling - -## Usage Example - -```python -from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( - NDModule, - NDModuleError, - nd_argument_spec, -) -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum - -def main(): - argument_spec = nd_argument_spec() - module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) - - nd = NDModule(module) - - try: - data = nd.request("/api/v1/some/endpoint", HttpVerbEnum.GET) - module.exit_json(changed=False, data=data) - except NDModuleError as e: - module.fail_json(msg=e.msg, status=e.status, response_payload=e.response_payload) -``` -""" - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -import logging -from typing import Any, Optional - -from ansible.module_utils.basic import env_fallback -from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.response_handler import ResponseHandlerProtocol -from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.sender import SenderProtocol -from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler -from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend -from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender - - -def nd_argument_spec() -> dict[str, Any]: - """ - Return the common argument spec for ND modules. - - This function provides the standard arguments that all ND modules - should accept for connection and authentication. - """ - return dict( - host=dict(type="str", required=False, aliases=["hostname"], fallback=(env_fallback, ["ND_HOST"])), - port=dict(type="int", required=False, fallback=(env_fallback, ["ND_PORT"])), - username=dict(type="str", fallback=(env_fallback, ["ND_USERNAME", "ANSIBLE_NET_USERNAME"])), - password=dict(type="str", required=False, no_log=True, fallback=(env_fallback, ["ND_PASSWORD", "ANSIBLE_NET_PASSWORD"])), - output_level=dict(type="str", default="normal", choices=["debug", "info", "normal"], fallback=(env_fallback, ["ND_OUTPUT_LEVEL"])), - timeout=dict(type="int", default=30, fallback=(env_fallback, ["ND_TIMEOUT"])), - use_proxy=dict(type="bool", fallback=(env_fallback, ["ND_USE_PROXY"])), - use_ssl=dict(type="bool", fallback=(env_fallback, ["ND_USE_SSL"])), - validate_certs=dict(type="bool", fallback=(env_fallback, ["ND_VALIDATE_CERTS"])), - login_domain=dict(type="str", fallback=(env_fallback, ["ND_LOGIN_DOMAIN"])), - ) - - -class NDModule: - """ - # Summary - - Simplified NDModule using RestSend infrastructure with exception-based error handling. - - This class provides a clean interface for making REST API requests to Nexus Dashboard - controllers. It uses the RestSend/Sender/ResponseHandler infrastructure for - separation of concerns and testability. - - ## Key Differences from nd.py NDModule - - 1. Uses exceptions (NDModuleError) instead of fail_json/exit_json - 2. No Connection class dependency - uses Sender for HTTP operations - 3. Minimal state - only tracks request/response metadata - 4. request() leverages RestSend -> Sender -> ResponseHandler - - ## Usage Example - - ```python - from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule, NDModuleError - from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum - - nd = NDModule(module) - - try: - # GET request - data = nd.request("/api/v1/endpoint") - - # POST request with payload - result = nd.request("/api/v1/endpoint", HttpVerbEnum.POST, {"key": "value"}) - except NDModuleError as e: - module.fail_json(**e.to_dict()) - ``` - - ## Raises - - - NDModuleError: When a request fails (replaces fail_json) - - ValueError: When RestSend encounters configuration errors - - TypeError: When invalid types are passed to RestSend - """ - - def __init__(self, module) -> None: - """ - Initialize NDModule with an AnsibleModule instance. - - Args: - module: AnsibleModule instance (or compatible mock for testing) - """ - self.class_name = self.__class__.__name__ - self.module = module - self.params: dict[str, Any] = module.params - - self.log = logging.getLogger(f"nd.{self.class_name}") - - # Request/response state (for debugging and error reporting) - self.method: Optional[str] = None - self.path: Optional[str] = None - self.response: Optional[str] = None - self.status: Optional[int] = None - self.url: Optional[str] = None - - # RestSend infrastructure (lazy initialized) - self._rest_send: Optional[RestSend] = None - self._sender: Optional[SenderProtocol] = None - self._response_handler: Optional[ResponseHandlerProtocol] = None - - if self.module._debug: - self.module.warn("Enable debug output because ANSIBLE_DEBUG was set.") - self.params["output_level"] = "debug" - - def _get_rest_send(self) -> RestSend: - """ - # Summary - - Lazy initialization of RestSend and its dependencies. - - ## Returns - - - RestSend: Configured RestSend instance ready for use. - """ - method_name = "_get_rest_send" - params = {} - if self._rest_send is None: - params = { - "check_mode": self.module.check_mode, - "state": self.params.get("state"), - } - self._sender = Sender() - self._sender.ansible_module = self.module - self._response_handler = ResponseHandler() - self._rest_send = RestSend(params) - self._rest_send.sender = self._sender - self._rest_send.response_handler = self._response_handler - - msg = f"{self.class_name}.{method_name}: " - msg += "Initialized RestSend instance with params: " - msg += f"{params}" - self.log.debug(msg) - return self._rest_send - - @property - def rest_send(self) -> RestSend: - """ - # Summary - - Access to the RestSend instance used by this NDModule. - - ## Returns - - - RestSend: The RestSend instance. - - ## Raises - - - `ValueError`: If accessed before `request()` has been called. - - ## Usage - - ```python - nd = NDModule(module) - data = nd.request("/api/v1/endpoint") - - # Access RestSend response/result - response = nd.rest_send.response_current - result = nd.rest_send.result_current - ``` - """ - if self._rest_send is None: - msg = f"{self.class_name}.rest_send: " - msg += "rest_send must be initialized before accessing. " - msg += "Call request() first." - raise ValueError(msg) - return self._rest_send - - def request( - self, - path: str, - verb: HttpVerbEnum = HttpVerbEnum.GET, - data: Optional[dict[str, Any]] = None, - ) -> dict[str, Any]: - """ - # Summary - - Make a REST API request to the Nexus Dashboard controller. - - This method uses the RestSend infrastructure for improved separation - of concerns and testability. - - ## Args - - - path: The fully-formed API endpoint path including query string - (e.g., "/appcenter/cisco/ndfc/api/v1/endpoint?param=value") - - verb: HTTP verb as HttpVerbEnum (default: HttpVerbEnum.GET) - - data: Optional request payload as a dict - - ## Returns - - The response DATA from the controller (parsed JSON body). - - For full response metadata (status, message, etc.), access - `rest_send.response_current` and `rest_send.result_current` - after calling this method. - - ## Raises - - - `NDModuleError`: If the request fails (with status, payload, etc.) - - `ValueError`: If RestSend encounters configuration errors - - `TypeError`: If invalid types are passed - """ - method_name = "request" - # If PATCH with empty data, return early (existing behavior) - if verb == HttpVerbEnum.PATCH and not data: - return {} - - rest_send = self._get_rest_send() - - # Send the request - try: - rest_send.path = path - rest_send.verb = verb # type: ignore[assignment] - msg = f"{self.class_name}.{method_name}: " - msg += "Sending request " - msg += f"verb: {verb}, " - msg += f"path: {path}" - if data: - rest_send.payload = data - msg += f", data: {data}" - self.log.debug(msg) - rest_send.commit() - except (TypeError, ValueError) as error: - raise ValueError(f"Error in request: {error}") from error - - # Get response and result from RestSend - response = rest_send.response_current - result = rest_send.result_current - - # Update state for debugging/error reporting - self.method = verb.value - self.path = path - self.response = response.get("MESSAGE") - self.status = response.get("RETURN_CODE", -1) - self.url = response.get("REQUEST_PATH") - - # Handle errors based on result - if not result.get("success", False): - response_data = response.get("DATA") - - # Get error message from ResponseHandler - error_msg = self._response_handler.error_message if self._response_handler else "Unknown error" - - # Build exception with available context - raw = None - payload = None - - if isinstance(response_data, dict): - if "raw_response" in response_data: - raw = response_data["raw_response"] - else: - payload = response_data - - raise NDModuleError( - msg=error_msg if error_msg else "Unknown error", - status=self.status, - request_payload=data, - response_payload=payload, - raw=raw, - ) - - # Return the response data on success - return response.get("DATA", {}) diff --git a/plugins/module_utils/rest/__init__.py b/plugins/module_utils/rest/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/plugins/module_utils/rest/protocols/__init__.py b/plugins/module_utils/rest/protocols/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/plugins/module_utils/rest/protocols/response_handler.py b/plugins/module_utils/rest/protocols/response_handler.py deleted file mode 100644 index 487e12cf..00000000 --- a/plugins/module_utils/rest/protocols/response_handler.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- coding: utf-8 -*- -# pylint: disable=missing-module-docstring -# pylint: disable=unnecessary-ellipsis -# pylint: disable=wrong-import-position -# Copyright: (c) 2026, Allen Robel (@arobel) -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -""" -Protocol definition for ResponseHandler classes. -""" - -try: - from typing import Protocol, runtime_checkable -except ImportError: - try: - from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] - except ImportError: - - class Protocol: # type: ignore[no-redef] - """Stub for Python < 3.8 without typing_extensions.""" - - def runtime_checkable(cls): # type: ignore[no-redef] - return cls - - -from typing import Optional - -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum - - -@runtime_checkable -class ResponseHandlerProtocol(Protocol): - """ - # Summary - - Protocol defining the interface for response handlers in RestSend. - - Any class implementing this protocol must provide: - - - `response` property (getter/setter): The controller response dict. - - `result` property (getter): The calculated result based on response and verb. - - `verb` property (getter/setter): The HTTP method (GET, POST, PUT, DELETE, etc.). - - `commit()` method: Parses response and sets result. - - ## Notes - - - Getters for `response`, `result`, and `verb` should raise `ValueError` if - accessed before being set. - - ## Example Implementations - - - `ResponseHandler` in `response_handler_nd.py`: Handles Nexus Dashboard responses. - - Future: `ResponseHandlerApic` for APIC controller responses. - """ - - @property - def response(self) -> dict: - """ - # Summary - - The controller response. - - ## Raises - - - ValueError: If accessed before being set. - """ - ... - - @response.setter - def response(self, value: dict) -> None: - pass - - @property - def result(self) -> dict: - """ - # Summary - - The calculated result based on response and verb. - - ## Raises - - - ValueError: If accessed before commit() is called. - """ - ... - - @property - def verb(self) -> HttpVerbEnum: - """ - # Summary - - HTTP method for the request. - - ## Raises - - - ValueError: If accessed before being set. - """ - ... - - @verb.setter - def verb(self, value: HttpVerbEnum) -> None: - pass - - def commit(self) -> None: - """ - # Summary - - Parse the response and set the result. - - ## Raises - - - ValueError: If response or verb is not set. - """ - ... - - @property - def error_message(self) -> Optional[str]: - """ - # Summary - - Human-readable error message extracted from response. - - ## Returns - - - str: Error message if an error occurred. - - None: If the request was successful or commit() not called. - """ - ... diff --git a/plugins/module_utils/rest/protocols/response_validation.py b/plugins/module_utils/rest/protocols/response_validation.py deleted file mode 100644 index bb627196..00000000 --- a/plugins/module_utils/rest/protocols/response_validation.py +++ /dev/null @@ -1,193 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -# Summary - -Protocol definition for version-specific response validation strategies. - -## Description - -This module defines the ResponseValidationStrategy protocol which specifies -the interface for handling version-specific differences in ND API responses, -including status code validation and error message extraction. - -When ND API v2 is released with different status codes or response formats, -implementing a new strategy class allows clean separation of v1 and v2 logic. -""" - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -try: - from typing import Protocol, runtime_checkable -except ImportError: - try: - from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] - except ImportError: - - class Protocol: # type: ignore[no-redef] - """Stub for Python < 3.8 without typing_extensions.""" - - def runtime_checkable(cls): # type: ignore[no-redef] - return cls - - -from typing import Optional - -# pylint: disable=unnecessary-ellipsis - - -@runtime_checkable -class ResponseValidationStrategy(Protocol): - """ - # Summary - - Protocol for version-specific response validation. - - ## Description - - This protocol defines the interface for handling version-specific - differences in ND API responses, including status code validation - and error message extraction. - - Implementations of this protocol enable injecting version-specific - behavior into ResponseHandler without modifying the handler itself. - - ## Methods - - See property and method definitions below. - - ## Raises - - None - implementations may raise exceptions per their logic - """ - - @property - def success_codes(self) -> set[int]: - """ - # Summary - - Return set of HTTP status codes considered successful. - - ## Returns - - - Set of integers representing success status codes - """ - ... - - @property - def not_found_code(self) -> int: - """ - # Summary - - Return HTTP status code for resource not found. - - ## Returns - - - Integer representing not-found status code (typically 404) - """ - ... - - @property - def error_codes(self) -> set[int]: - """ - # Summary - - Return set of HTTP status codes considered errors. - - ## Returns - - - Set of integers representing error status codes - """ - ... - - def is_success(self, return_code: int) -> bool: - """ - # Summary - - Check if return code indicates success. - - ## Parameters - - - return_code: HTTP status code to check - - ## Returns - - - True if code is in success_codes, 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_error(self, return_code: int) -> bool: - """ - # Summary - - Check if return code indicates error. - - ## Parameters - - - return_code: HTTP status code to check - - ## Returns - - - True if code is in error_codes, False otherwise - - ## 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 deleted file mode 100644 index 5e55047c..00000000 --- a/plugins/module_utils/rest/protocols/sender.py +++ /dev/null @@ -1,103 +0,0 @@ -# pylint: disable=wrong-import-position -# pylint: disable=missing-module-docstring -# pylint: disable=unnecessary-ellipsis -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -try: - from typing import Protocol, runtime_checkable -except ImportError: - try: - from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] - except ImportError: - - class Protocol: # type: ignore[no-redef] - """Stub for Python < 3.8 without typing_extensions.""" - - def runtime_checkable(cls): # type: ignore[no-redef] - return cls - - -from typing import Optional - -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum - - -@runtime_checkable -class SenderProtocol(Protocol): - """ - # Summary - - Protocol defining the sender interface for RestSend. - - Any class implementing this protocol must provide: - - - `path` property (getter/setter): The endpoint path for the REST request. - - `verb` property (getter/setter): The HTTP method (GET, POST, PUT, DELETE, etc.). - - `payload` property (getter/setter): Optional request payload as a dict. - - `response` property (getter): The response from the controller. - - `commit()` method: Sends the request to the controller. - - ## Example Implementations - - - `Sender` in `sender_nd.py`: Uses Ansible HttpApi plugin. - - `Sender` in `sender_file.py`: Reads responses from files (for testing). - """ - - @property - def path(self) -> str: - """Endpoint path for the REST request.""" - ... - - @path.setter - def path(self, value: str) -> None: - """Set the endpoint path for the REST request.""" - ... - - @property - def verb(self) -> HttpVerbEnum: - """HTTP method for the REST request.""" - ... - - @verb.setter - def verb(self, value: HttpVerbEnum) -> None: - """Set the HTTP method for the REST request.""" - ... - - @property - def payload(self) -> Optional[dict]: - """Optional payload to send to the controller.""" - ... - - @payload.setter - def payload(self, value: dict) -> None: - """Set the optional payload for the REST request.""" - ... - - @property - def response(self) -> dict: - """The response from the controller.""" - ... - - def commit(self) -> None: - """ - Send the request to the controller. - - Raises: - ConnectionError: If there is an error with the connection. - """ - ... diff --git a/plugins/module_utils/rest/response_handler_nd.py b/plugins/module_utils/rest/response_handler_nd.py deleted file mode 100644 index ed5a12fe..00000000 --- a/plugins/module_utils/rest/response_handler_nd.py +++ /dev/null @@ -1,409 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -# response_handler_nd.py - -Implements the ResponseHandler interface for handling Nexus Dashboard controller responses. - -## Version Compatibility - -This handler is designed for ND API v1 responses (ND 4.2+). - -### Status Code Assumptions - -Status codes are defined by the injected `ResponseValidationStrategy`, defaulting -to `NdV1Strategy` (ND 4.2+): - -- Success: 200, 201, 202, 204, 207 -- Not Found: 404 (treated as success for GET) -- Error: 405, 409 - -If ND API v2 uses different codes, inject a new strategy via the -`validation_strategy` property rather than modifying this class. - -### Response Format - -Expects ND HttpApi plugin to provide responses with these keys: - -- RETURN_CODE (int): HTTP status code (e.g., 200, 404, 500) -- MESSAGE (str): HTTP reason phrase (e.g., "OK", "Not Found") -- DATA (dict): Parsed JSON body or dict with raw_response if parsing failed -- REQUEST_PATH (str): The request URL path -- METHOD (str): The HTTP method used (GET, POST, PUT, DELETE, PATCH) - -### Supported Error Formats - -The error_message property handles multiple ND API v1 error response formats: - -1. code/message dict: {"code": , "message": } -2. messages array: {"messages": [{"code": , "severity": , "message": }]} -3. errors array: {"errors": [, ...]} -4. raw_response: {"raw_response": } for non-JSON responses - -If ND API v2 changes error response structures, error extraction logic will need updates. - -## Future v2 Considerations - -If ND API v2 changes response format or status codes, implement a new strategy -class (e.g. `NdV2Strategy`) conforming to `ResponseValidationStrategy` and inject -it via `response_handler.validation_strategy = NdV2Strategy()`. - -TODO: Should response be converted to a Pydantic model by this class? -""" - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -import copy -import logging -from typing import Any, Optional - -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.response_validation import ResponseValidationStrategy -from ansible_collections.cisco.nd.plugins.module_utils.rest.response_strategies.nd_v1_strategy import NdV1Strategy - - -class ResponseHandler: - """ - # Summary - - Implement the response handler interface for injection into RestSend(). - - ## Raises - - - `TypeError` if: - - `response` is not a dict. - - `ValueError` if: - - `response` is missing any fields required by the handler - to calculate the result. - - Required fields: - - `RETURN_CODE` - - `MESSAGE` - - `response` is not set prior to calling `commit()`. - - `verb` is not set prior to calling `commit()`. - - ## Interface specification - - - `response` setter property - - Accepts a dict containing the controller response. - - Raises `TypeError` if: - - `response` is not a dict. - - Raises `ValueError` if: - - `response` is missing any fields required by the handler - to calculate the result, for example `RETURN_CODE` and - `MESSAGE`. - - `result` getter property - - Returns a dict containing the calculated result based on the - controller response and the request verb. - - Raises `ValueError` if `result` is accessed before calling - `commit()`. - - `result` setter property - - Set internally by the handler based on the response and verb. - - `verb` setter property - - Accepts an HttpVerbEnum enum defining the request verb. - - Valid verb: One of "DELETE", "GET", "POST", "PUT". - - e.g. HttpVerbEnum.GET, HttpVerbEnum.POST, etc. - - Raises `ValueError` if verb is not set prior to calling `commit()`. - - `commit()` method - - Parse `response` and set `result`. - - Raise `ValueError` if: - - `response` is not set. - - `verb` is not set. - - ## Usage example - - ```python - # import and instantiate the class - from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import \ - ResponseHandler - response_handler = ResponseHandler() - - try: - # Set the response from the controller - response_handler.response = controller_response - - # Set the request verb - response_handler.verb = HttpVerbEnum.GET - - # Call commit to parse the response - response_handler.commit() - - # Access the result - result = response_handler.result - except (TypeError, ValueError) as error: - handle_error(error) - ``` - - """ - - def __init__(self) -> None: - self.class_name = self.__class__.__name__ - method_name = "__init__" - - self.log = logging.getLogger(f"nd.{self.class_name}") - - self._response: Optional[dict[str, Any]] = None - self._result: Optional[dict[str, Any]] = None - self._strategy: ResponseValidationStrategy = NdV1Strategy() - self._verb: Optional[HttpVerbEnum] = None - - msg = f"ENTERED {self.class_name}.{method_name}" - self.log.debug(msg) - - def _handle_response(self) -> None: - """ - # Summary - - Call the appropriate handler for response based on the value of self.verb - """ - if self.verb == HttpVerbEnum.GET: - self._handle_get_response() - else: - self._handle_post_put_delete_response() - - def _handle_get_response(self) -> None: - """ - # Summary - - Handle GET responses from the controller and set self.result. - - - self.result is a dict containing: - - found: - - False if RETURN_CODE == 404 - - True otherwise (when successful) - - success: - - True if RETURN_CODE in (200, 201, 202, 204, 207, 404) - - False otherwise (error status codes) - """ - result = {} - return_code = self.response.get("RETURN_CODE") - - # 404 Not Found - resource doesn't exist, but request was successful - if self._strategy.is_not_found(return_code): - result["found"] = False - result["success"] = True - # Success codes - resource found - elif self._strategy.is_success(return_code): - 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 = {} - return_code = self.response.get("RETURN_CODE") - - # Check for explicit error in response - if self.response.get("ERROR") is not None: - result["success"] = False - result["changed"] = False - # Check for error in response data (ND error format) - elif self.response.get("DATA", {}).get("error") is not None: - result["success"] = False - result["changed"] = False - # Success codes indicate the operation completed - elif self._strategy.is_success(return_code): - result["success"] = True - result["changed"] = True - # Any other status code is an error - 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 deleted file mode 100644 index e69de29b..00000000 diff --git a/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py b/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py deleted file mode 100644 index a591a36d..00000000 --- a/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py +++ /dev/null @@ -1,246 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -# Summary - -ND API v1 response validation strategy. - -## Description - -Implements status code validation and error message extraction for ND API v1 -responses (ND 4.2). - -This strategy encapsulates the response handling logic previously hardcoded -in ResponseHandler, enabling version-specific behavior to be injected. -""" - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -from typing import Any, Optional - - -class NdV1Strategy: - """ - # Summary - - Response validation strategy for ND API v1. - - ## Description - - Implements status code validation and error message extraction - for ND API v1 (ND 4.2+). - - ## Status Codes - - - Success: 200, 201, 202, 204, 207 - - Not Found: 404 (treated as success for GET) - - Error: 405, 409 - - ## 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: DATA.messages[0].{code, severity, message} - 4. errors array: DATA.errors[0] - 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 - - @property - def error_codes(self) -> set[int]: - """ - # Summary - - Return v1 error codes. - - ## Returns - - - Set of integers: {405, 409} - - ## Raises - - None - """ - return {405, 409} - - def is_success(self, return_code: int) -> bool: - """ - # Summary - - Check if return code indicates success (v1). - - ## Parameters - - - return_code: HTTP status code to check - - ## Returns - - - True if code is in success_codes, False otherwise - - ## Raises - - None - """ - return return_code in self.success_codes - - 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_error(self, return_code: int) -> bool: - """ - # Summary - - Check if return code indicates error (v1). - - ## Parameters - - - return_code: HTTP status code to check - - ## Returns - - - True if code is in error_codes, False otherwise - - ## Raises - - None - """ - return return_code in self.error_codes - - 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 - 5. errors array - 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: - first_msg = data_dict["messages"][0] - if all(k in first_msg for k in ("code", "severity", "message")): - msg = f"ND Error {first_msg['code']} ({first_msg['severity']}): {first_msg['message']}" - - # errors array format - if msg is None and "errors" in data_dict and len(data_dict.get("errors", [])) > 0: - msg = f"ND Error: {data_dict['errors'][0]}" - - # 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 deleted file mode 100644 index 4e903fb0..00000000 --- a/plugins/module_utils/rest/rest_send.py +++ /dev/null @@ -1,797 +0,0 @@ -# -*- coding: utf-8 -*- -# pylint: disable=wrong-import-position -# pylint: disable=missing-module-docstring -# Copyright: (c) 2026, Allen Robel (@arobel) -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -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` - - `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 `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 - response = rest_send.response - # dict containing the current controller response - response_current = rest_send.response_current - # list of results from the controller for this session - result = rest_send.result - # 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._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` - - self.response raises `TypeError` - - self.result 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) - 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._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 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 response(self) -> list[dict]: - """ - # Summary - - The aggregated list of responses from the controller. - - `commit()` must be called first. - - ## Raises - - - setter: `TypeError` if value is not a `dict` - - """ - 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.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 result(self) -> list[dict]: - """ - # Summary - - The aggregated list of results from the controller. - - `commit()` must be called first. - - ## Raises - - - setter: `TypeError` if: - - value is not a `dict`. - - """ - return copy.deepcopy(self._result) - - @result.setter - def result(self, value: dict): - method_name = "result" - 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._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 deleted file mode 100644 index 140ec8c5..00000000 --- a/plugins/module_utils/rest/results.py +++ /dev/null @@ -1,1019 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -# pylint: disable=too-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 - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -import copy -import logging -from typing import Any, Optional - -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, - Field, - ValidationError, -) -from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType - - -class TaskResultData(BaseModel): - """ - # Summary - - Pydantic model for a single task result. - - Represents all data for one task including its response, result, diff, - and metadata. Immutable after creation to prevent accidental modification - of registered tasks. - - ## Raises - - - `ValidationError`: if field validation fails during instantiation - - ## Attributes - - - `sequence_number`: Unique sequence number for this task (required, >= 1) - - `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) - response: dict[str, Any] - result: dict[str, Any] - diff: dict[str, Any] - metadata: dict[str, Any] - changed: bool - failed: bool - - -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) - - `response`: List of all response dicts (default empty list) - - `result`: List of all result dicts (default empty list) - - `metadata`: List of all metadata dicts (default empty list) - """ - - model_config = ConfigDict(extra="forbid") - - changed: bool - failed: bool - diff: list[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) - metadata: list[dict[str, Any]] = Field(default_factory=list) - - -class CurrentTaskData(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 `TaskResultData`. - 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) - """ - - 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 - - -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. `CurrentTaskData` - Mutable staging area for building the current task - 2. `TaskResultData` - Immutable registered task 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_task_result()` - - Converts current task to immutable `TaskResultData` - - 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_task_result()`. - - 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_task_result() 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_task_result() determines changed/failed automatically - self._results.register_task_result() - ... - - @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[TaskResultData] = [] - - # Current task being built (mutable) - self._current: CurrentTaskData = CurrentTaskData() - - # Aggregated state (derived from tasks) - self._changed: set[bool] = set() - self._failed: set[bool] = set() - - # Final result (built on demand) - self._final_result: Optional[FinalResultData] = None - - # Legacy: response_data list for backward compatibility - self._response_data: list[dict[str, Any]] = [] - - msg = f"ENTERED {self.class_name}():" - self.log.debug(msg) - - def add_response_data(self, value: dict[str, Any]) -> None: - """ - # Summary - - Add a dict to the response_data list. - - ## Raises - - - `TypeError`: if value is not a dict - - ## See also - - `@response_data` property - """ - method_name: str = "add_response_data" - if not isinstance(value, dict): - msg = f"{self.class_name}.{method_name}: " - msg += f"instance.add_response_data must be a dict. Got {value}" - raise TypeError(msg) - self._response_data.append(copy.deepcopy(value)) - - 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_task_result(self) -> None: - """ - # Summary - - Register the current task result. - - Converts `CurrentTaskData` to immutable `TaskResultData`, 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 `TaskResultData` with validation - 7. Register the task and update aggregated changed/failed sets - 8. Reset current task for next registration - """ - method_name: str = "register_task_result" - - 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 TaskResultData with validation - try: - task_data = TaskResultData( - sequence_number=self.task_sequence_number, - 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) - self._changed.add(changed) - self._failed.add(failed) - - # Reset current task for next task - self._current = CurrentTaskData() - - # 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] - response_list = [task.response for task in self._tasks] - result_list = [task.result for task in self._tasks] - metadata_list = [task.metadata 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, - response=response_list, - result=result_list, - metadata=metadata_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. - - ## Raises - - None - - ## Returns - - - A set() of boolean values indicating whether any tasks changed - - ## See also - - - `register_task_result()` method to register tasks and update the changed set. - """ - return self._changed - - @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 diff(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 - - - If the set contains True, at least one task failed. - - If the set contains only False all tasks succeeded. - - ## Raises - - None - - ## See also - - - `register_task_result()` method to register tasks and update the failed set. - """ - return self._failed - - @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 response(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 response_data(self) -> list[dict[str, Any]]: - """ - # Summary - - Return a `list` of `dict`, where each `dict` contains the contents of the DATA key - within the responses that have been added. - - ## Raises - - None - - ## See also - - `add_response_data()` method to add to the response_data list. - """ - return self._response_data - - @property - def result(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 diff --git a/plugins/module_utils/rest/sender_nd.py b/plugins/module_utils/rest/sender_nd.py deleted file mode 100644 index ae333dd0..00000000 --- a/plugins/module_utils/rest/sender_nd.py +++ /dev/null @@ -1,322 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -""" -Sender module conforming to SenderProtocol. - -See plugins/module_utils/protocol_sender.py for the protocol definition. -""" - -# isort: off -# fmt: off -from __future__ import (absolute_import, division, print_function) -from __future__ import annotations -# fmt: on -# isort: on - -# pylint: disable=invalid-name -__metaclass__ = type -# pylint: enable=invalid-name - -import copy -import inspect -import json -import logging -from typing import Any, Optional - -from ansible.module_utils.basic import AnsibleModule # type: ignore -from ansible.module_utils.connection import Connection # type: ignore -from ansible.module_utils.connection import ConnectionError as AnsibleConnectionError -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum - - -class Sender: - """ - # Summary - - An injected dependency for `RestSend` which implements the - `sender` interface. Responses are retrieved using the Ansible HttpApi plugin. - - For the `sender` interface definition, see `plugins/module_utils/protocol_sender.py`. - - ## Raises - - - `ValueError` if: - - `ansible_module` is not set. - - `path` is not set. - - `verb` is not set. - - `TypeError` if: - - `ansible_module` is not an instance of AnsibleModule. - - `payload` is not a `dict`. - - `response` is not a `dict`. - - ## Usage - - `ansible_module` is an instance of `AnsibleModule`. - - ```python - sender = Sender() - try: - sender.ansible_module = ansible_module - rest_send = RestSend() - rest_send.sender = sender - except (TypeError, ValueError) as error: - handle_error(error) - # etc... - # See rest_send.py for RestSend() usage. - ``` - """ - - def __init__( - self, - ansible_module: Optional[AnsibleModule] = None, - verb: Optional[HttpVerbEnum] = None, - path: Optional[str] = None, - payload: Optional[dict[str, Any]] = None, - ) -> None: - self.class_name = self.__class__.__name__ - - self.log = logging.getLogger(f"nd.{self.class_name}") - - self._ansible_module: Optional[AnsibleModule] = ansible_module - self._connection: Optional[Connection] = None - - self._path: Optional[str] = path - self._payload: Optional[dict[str, Any]] = payload - self._response: Optional[dict[str, Any]] = None - self._verb: Optional[HttpVerbEnum] = verb - - msg = "ENTERED Sender(): " - self.log.debug(msg) - - def _get_caller_name(self) -> str: - """ - # Summary - - Get the name of the method that called the current method. - - ## Raises - - None - - ## Returns - - - `str`: The name of the calling method - """ - return inspect.stack()[2][3] - - def commit(self) -> None: - """ - # Summary - - Send the request to the controller - - ## Raises - - - `ValueError` if there is an error with the connection to the controller. - - ## Properties read - - - `verb`: HTTP verb e.g. GET, POST, PATCH, PUT, DELETE - - `path`: HTTP path e.g. /api/v1/some_endpoint - - `payload`: Optional HTTP payload - - ## Properties written - - - `response`: raw response from the controller - """ - method_name = "commit" - caller = self._get_caller_name() - - if self._connection is None: - self._connection = Connection(self.ansible_module._socket_path) # pylint: disable=protected-access - self._connection.set_params(self.ansible_module.params) - - msg = f"{self.class_name}.{method_name}: " - msg += f"caller: {caller}. " - msg += "Calling Connection().send_request: " - msg += f"verb {self.verb.value}, path {self.path}" - try: - if self.payload is None: - self.log.debug(msg) - response = self._connection.send_request(self.verb.value, self.path) - else: - msg += ", payload: " - msg += f"{json.dumps(self.payload, indent=4, sort_keys=True)}" - self.log.debug(msg) - response = self._connection.send_request( - self.verb.value, - self.path, - json.dumps(self.payload), - ) - # Normalize response: if JSON parsing failed, DATA will be None - # and raw content will be in the "raw" key. Convert to consistent format. - response = self._normalize_response(response) - self.response = response - except AnsibleConnectionError as error: - msg = f"{self.class_name}.{method_name}: " - msg += f"ConnectionError occurred: {error}" - self.log.error(msg) - raise ValueError(msg) from error - except Exception as error: - msg = f"{self.class_name}.{method_name}: " - msg += f"Unexpected error occurred: {error}" - self.log.error(msg) - raise ValueError(msg) from error - - def _normalize_response(self, response: dict) -> dict: - """ - # Summary - - Normalize the HttpApi response to ensure consistent format. - - If the HttpApi plugin failed to parse the response as JSON, the - `DATA` key will be None and the raw response content will be in - the `raw` key. This method converts such responses to a consistent - format where `DATA` contains a dict with the raw content. - - ## Parameters - - - `response`: The response dict from the HttpApi plugin. - - ## Returns - - The normalized response dict. - """ - if response.get("DATA") is None and response.get("raw") is not None: - response["DATA"] = {"raw_response": response.get("raw")} - # If MESSAGE is just the HTTP reason phrase, enhance it - if response.get("MESSAGE") in ("OK", None): - response["MESSAGE"] = "Response could not be parsed as JSON" - return response - - @property - def ansible_module(self) -> AnsibleModule: - """ - # Summary - - The AnsibleModule instance to use for this sender. - - ## Raises - - - `ValueError` if ansible_module is not set. - """ - if self._ansible_module is None: - msg = f"{self.class_name}.ansible_module: " - msg += "ansible_module must be set before accessing ansible_module." - raise ValueError(msg) - return self._ansible_module - - @ansible_module.setter - def ansible_module(self, value: AnsibleModule): - self._ansible_module = value - - @property - def path(self) -> str: - """ - # Summary - - Endpoint path for the REST request. - - ## Raises - - - getter: `ValueError` if `path` is not set before accessing. - - ## Example - - ``/appcenter/cisco/ndfc/api/v1/...etc...`` - """ - if self._path is None: - msg = f"{self.class_name}.path: " - msg += "path must be set before accessing path." - raise ValueError(msg) - return self._path - - @path.setter - def path(self, value: str): - self._path = value - - @property - def payload(self) -> Optional[dict[str, Any]]: - """ - # Summary - - Return the payload to send to the controller - - ## Raises - - `TypeError` if value is not a `dict`. - """ - return self._payload - - @payload.setter - def payload(self, value: dict): - method_name = "payload" - if not isinstance(value, dict): - msg = f"{self.class_name}.{method_name}: " - msg += f"{method_name} must be a dict. " - msg += f"Got type {type(value).__name__}, " - msg += f"value {value}." - raise TypeError(msg) - self._payload = value - - @property - def response(self) -> dict: - """ - # Summary - - The response from the controller. - - - getter: Return a deepcopy of `response` - - setter: Set `response` - - ## Raises - - - getter: `ValueError` if response is not set. - - setter: `TypeError` if value is not a `dict`. - """ - if self._response is None: - msg = f"{self.class_name}.response: " - msg += "response must be set before accessing response." - raise ValueError(msg) - return copy.deepcopy(self._response) - - @response.setter - def response(self, value: dict): - method_name = "response" - if not isinstance(value, dict): - msg = f"{self.class_name}.{method_name}: " - msg += f"{method_name} must be a dict. " - msg += f"Got type {type(value).__name__}, " - msg += f"value {value}." - raise TypeError(msg) - self._response = value - - @property - def verb(self) -> HttpVerbEnum: - """ - # Summary - - HTTP method for the REST request. - - ## Raises - - - getter: `ValueError` if verb is not set. - - setter: `TypeError` if value is not a `HttpVerbEnum`. - """ - if self._verb is None: - msg = f"{self.class_name}.verb: " - msg += "verb must be set before accessing verb." - raise ValueError(msg) - return self._verb - - @verb.setter - def verb(self, value: HttpVerbEnum): - method_name = "verb" - if value not in HttpVerbEnum.values(): - msg = f"{self.class_name}.{method_name}: " - msg += f"{method_name} must be one of {HttpVerbEnum.values()}. " - msg += f"Got {value}." - raise TypeError(msg) - self._verb = value diff --git a/tests/sanity/requirements.txt b/tests/sanity/requirements.txt index f19a09fb..8ea87eb9 100644 --- a/tests/sanity/requirements.txt +++ b/tests/sanity/requirements.txt @@ -1,7 +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+ +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 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/module_utils/__init__.py b/tests/unit/module_utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/module_utils/common_utils.py b/tests/unit/module_utils/common_utils.py deleted file mode 100644 index bc64b0d6..00000000 --- a/tests/unit/module_utils/common_utils.py +++ /dev/null @@ -1,75 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -""" -Common utilities used by unit tests. -""" - -from __future__ import absolute_import, annotations, division, print_function - -__metaclass__ = type # pylint: disable=invalid-name - -from contextlib import contextmanager - -import pytest -from ansible_collections.cisco.nd.plugins.module_utils.log import Log -from ansible_collections.cisco.nd.tests.unit.module_utils.fixtures.load_fixture import load_fixture -from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator -from ansible_collections.cisco.nd.tests.unit.module_utils.sender_file import Sender as SenderFile - -params = { - "state": "merged", - "config": {"switches": [{"ip_address": "172.22.150.105"}]}, - "check_mode": False, -} - - -# See the following for explanation of why fixtures are explicitely named -# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html -# @pytest.fixture(name="controller_version") -# def controller_version_fixture(): -# """ -# return ControllerVersion instance. -# """ -# return ControllerVersion() -@pytest.fixture(name="sender_file") -def sender_file_fixture(): - """ - return Send() imported from sender_file.py - """ - - def responses(): - yield {} - - instance = SenderFile() - instance.gen = ResponseGenerator(responses()) - return instance - - -@pytest.fixture(name="log") -def log_fixture(): - """ - return Log instance - """ - return Log() - - -@contextmanager -def does_not_raise(): - """ - A context manager that does not raise an exception. - """ - yield - - -def responses_sender_file(key: str) -> dict[str, str]: - """ - Return data in responses_SenderFile.json - """ - response_file = "responses_SenderFile" - response = load_fixture(response_file).get(key) - print(f"responses_sender_file: {key} : {response}") - return response diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json b/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json deleted file mode 100644 index 88aa460a..00000000 --- a/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json +++ /dev/null @@ -1,244 +0,0 @@ -{ - "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 deleted file mode 100644 index ec5a84d3..00000000 --- a/tests/unit/module_utils/fixtures/load_fixture.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -""" -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 deleted file mode 100644 index d58397df..00000000 --- a/tests/unit/module_utils/mock_ansible_module.py +++ /dev/null @@ -1,95 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -""" -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 deleted file mode 100644 index e96aad70..00000000 --- a/tests/unit/module_utils/response_generator.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -""" -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 deleted file mode 100644 index 7060e8c0..00000000 --- a/tests/unit/module_utils/sender_file.py +++ /dev/null @@ -1,293 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -""" -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 deleted file mode 100644 index f3250dbc..00000000 --- a/tests/unit/module_utils/test_response_handler_nd.py +++ /dev/null @@ -1,1496 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -""" -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 deleted file mode 100644 index ab1c499c..00000000 --- a/tests/unit/module_utils/test_rest_send.py +++ /dev/null @@ -1,1445 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -""" -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.response) == 1 - assert len(instance.result) == 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.response) == 1 - assert len(instance.result) == 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.response) == 2 - assert len(instance.result) == 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.response) == 3 - assert len(instance.result) == 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.response - result_copy = instance.result - 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() diff --git a/tests/unit/module_utils/test_sender_nd.py b/tests/unit/module_utils/test_sender_nd.py deleted file mode 100644 index 5edd102f..00000000 --- a/tests/unit/module_utils/test_sender_nd.py +++ /dev/null @@ -1,906 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Allen Robel (@arobel) - -# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - -""" -Unit tests for sender_nd.py - -Tests the Sender class for sending REST requests via the Ansible HttpApi plugin. -""" - -# pylint: disable=unused-import -# pylint: disable=redefined-outer-name -# pylint: disable=protected-access -# pylint: disable=unused-argument -# pylint: disable=unused-variable -# pylint: disable=invalid-name -# pylint: disable=line-too-long -# pylint: disable=too-many-lines - -from __future__ import absolute_import, annotations, division, print_function - -__metaclass__ = type # pylint: disable=invalid-name - -from unittest.mock import MagicMock, patch - -import pytest -from ansible.module_utils.connection import ConnectionError as AnsibleConnectionError -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender -from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise - -# ============================================================================= -# Test: Sender initialization -# ============================================================================= - - -def test_sender_nd_00010(): - """ - # Summary - - Verify Sender initialization with default values. - - ## Test - - - Instance can be created with no arguments - - All attributes default to None - - ## Classes and Methods - - - Sender.__init__() - """ - with does_not_raise(): - instance = Sender() - assert instance._ansible_module is None - assert instance._connection is None - assert instance._path is None - assert instance._payload is None - assert instance._response is None - assert instance._verb is None - - -def test_sender_nd_00020(): - """ - # Summary - - Verify Sender initialization with all parameters. - - ## Test - - - Instance can be created with all optional constructor arguments - - ## Classes and Methods - - - Sender.__init__() - """ - mock_module = MagicMock() - with does_not_raise(): - instance = Sender( - ansible_module=mock_module, - verb=HttpVerbEnum.GET, - path="/api/v1/test", - payload={"key": "value"}, - ) - assert instance._ansible_module is mock_module - assert instance._path == "/api/v1/test" - assert instance._payload == {"key": "value"} - assert instance._verb == HttpVerbEnum.GET - - -# ============================================================================= -# Test: Sender.ansible_module property -# ============================================================================= - - -def test_sender_nd_00100(): - """ - # Summary - - Verify ansible_module getter raises ValueError when not set. - - ## Test - - - Accessing ansible_module before setting raises ValueError - - ## Classes and Methods - - - Sender.ansible_module (getter) - """ - instance = Sender() - match = r"Sender\.ansible_module:.*must be set before accessing" - with pytest.raises(ValueError, match=match): - result = instance.ansible_module - - -def test_sender_nd_00110(): - """ - # Summary - - Verify ansible_module setter/getter. - - ## Test - - - ansible_module can be set and retrieved - - ## Classes and Methods - - - Sender.ansible_module (setter/getter) - """ - instance = Sender() - mock_module = MagicMock() - with does_not_raise(): - instance.ansible_module = mock_module - result = instance.ansible_module - assert result is mock_module - - -# ============================================================================= -# Test: Sender.path property -# ============================================================================= - - -def test_sender_nd_00200(): - """ - # Summary - - Verify path getter raises ValueError when not set. - - ## Test - - - Accessing path before setting raises ValueError - - ## Classes and Methods - - - Sender.path (getter) - """ - instance = Sender() - match = r"Sender\.path:.*must be set before accessing" - with pytest.raises(ValueError, match=match): - result = instance.path - - -def test_sender_nd_00210(): - """ - # Summary - - Verify path setter/getter. - - ## Test - - - path can be set and retrieved - - ## Classes and Methods - - - Sender.path (setter/getter) - """ - instance = Sender() - with does_not_raise(): - instance.path = "/api/v1/test/endpoint" - result = instance.path - assert result == "/api/v1/test/endpoint" - - -# ============================================================================= -# Test: Sender.verb property -# ============================================================================= - - -def test_sender_nd_00300(): - """ - # Summary - - Verify verb getter raises ValueError when not set. - - ## Test - - - Accessing verb before setting raises ValueError - - ## Classes and Methods - - - Sender.verb (getter) - """ - instance = Sender() - match = r"Sender\.verb:.*must be set before accessing" - with pytest.raises(ValueError, match=match): - result = instance.verb - - -def test_sender_nd_00310(): - """ - # Summary - - Verify verb setter/getter with valid HttpVerbEnum. - - ## Test - - - verb can be set and retrieved with all HttpVerbEnum values - - ## Classes and Methods - - - Sender.verb (setter/getter) - """ - instance = Sender() - for verb in (HttpVerbEnum.GET, HttpVerbEnum.POST, HttpVerbEnum.PUT, HttpVerbEnum.DELETE): - with does_not_raise(): - instance.verb = verb - result = instance.verb - assert result == verb - - -def test_sender_nd_00320(): - """ - # Summary - - Verify verb setter raises TypeError for invalid value. - - ## Test - - - Setting verb to a value not in HttpVerbEnum.values() raises TypeError - - ## Classes and Methods - - - Sender.verb (setter) - """ - instance = Sender() - match = r"Sender\.verb:.*must be one of" - with pytest.raises(TypeError, match=match): - instance.verb = "INVALID" # type: ignore[assignment] - - -# ============================================================================= -# Test: Sender.payload property -# ============================================================================= - - -def test_sender_nd_00400(): - """ - # Summary - - Verify payload defaults to None. - - ## Test - - - payload is None by default - - ## Classes and Methods - - - Sender.payload (getter) - """ - instance = Sender() - with does_not_raise(): - result = instance.payload - assert result is None - - -def test_sender_nd_00410(): - """ - # Summary - - Verify payload setter/getter with valid dict. - - ## Test - - - payload can be set and retrieved - - ## Classes and Methods - - - Sender.payload (setter/getter) - """ - instance = Sender() - with does_not_raise(): - instance.payload = {"name": "test", "config": {"key": "value"}} - result = instance.payload - assert result == {"name": "test", "config": {"key": "value"}} - - -def test_sender_nd_00420(): - """ - # Summary - - Verify payload setter raises TypeError for non-dict. - - ## Test - - - Setting payload to a non-dict raises TypeError - - ## Classes and Methods - - - Sender.payload (setter) - """ - instance = Sender() - match = r"Sender\.payload:.*must be a dict" - with pytest.raises(TypeError, match=match): - instance.payload = "not a dict" # type: ignore[assignment] - - -def test_sender_nd_00430(): - """ - # Summary - - Verify payload setter raises TypeError for list. - - ## Test - - - Setting payload to a list raises TypeError - - ## Classes and Methods - - - Sender.payload (setter) - """ - instance = Sender() - match = r"Sender\.payload:.*must be a dict" - with pytest.raises(TypeError, match=match): - instance.payload = [1, 2, 3] # type: ignore[assignment] - - -# ============================================================================= -# Test: Sender.response property -# ============================================================================= - - -def test_sender_nd_00500(): - """ - # Summary - - Verify response getter raises ValueError when not set. - - ## Test - - - Accessing response before commit raises ValueError - - ## Classes and Methods - - - Sender.response (getter) - """ - instance = Sender() - match = r"Sender\.response:.*must be set before accessing" - with pytest.raises(ValueError, match=match): - result = instance.response - - -def test_sender_nd_00510(): - """ - # Summary - - Verify response getter returns deepcopy. - - ## Test - - - response getter returns a deepcopy of the internal response - - ## Classes and Methods - - - Sender.response (getter) - """ - instance = Sender() - instance._response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"key": "value"}} - result = instance.response - # Modify the copy - result["MODIFIED"] = True - # Verify original is unchanged - assert "MODIFIED" not in instance._response - - -def test_sender_nd_00520(): - """ - # Summary - - Verify response setter raises TypeError for non-dict. - - ## Test - - - Setting response to a non-dict raises TypeError - - ## Classes and Methods - - - Sender.response (setter) - """ - instance = Sender() - match = r"Sender\.response:.*must be a dict" - with pytest.raises(TypeError, match=match): - instance.response = "not a dict" # type: ignore[assignment] - - -def test_sender_nd_00530(): - """ - # Summary - - Verify response setter accepts valid dict. - - ## Test - - - response can be set with a valid dict - - ## Classes and Methods - - - Sender.response (setter/getter) - """ - instance = Sender() - response = {"RETURN_CODE": 200, "MESSAGE": "OK"} - with does_not_raise(): - instance.response = response - result = instance.response - assert result["RETURN_CODE"] == 200 - assert result["MESSAGE"] == "OK" - - -# ============================================================================= -# Test: Sender._normalize_response() -# ============================================================================= - - -def test_sender_nd_00600(): - """ - # Summary - - Verify _normalize_response with normal JSON response. - - ## Test - - - Response with valid DATA passes through unchanged - - ## Classes and Methods - - - Sender._normalize_response() - """ - instance = Sender() - response = { - "RETURN_CODE": 200, - "MESSAGE": "OK", - "DATA": {"status": "success"}, - } - result = instance._normalize_response(response) - assert result["DATA"] == {"status": "success"} - assert result["MESSAGE"] == "OK" - - -def test_sender_nd_00610(): - """ - # Summary - - Verify _normalize_response when DATA is None and raw is present. - - ## Test - - - When DATA is None and raw is present, DATA is populated with raw_response - - MESSAGE is set to indicate JSON parsing failure - - ## Classes and Methods - - - Sender._normalize_response() - """ - instance = Sender() - response = { - "RETURN_CODE": 200, - "MESSAGE": "OK", - "DATA": None, - "raw": "Not JSON", - } - result = instance._normalize_response(response) - assert result["DATA"] == {"raw_response": "Not JSON"} - assert result["MESSAGE"] == "Response could not be parsed as JSON" - - -def test_sender_nd_00620(): - """ - # Summary - - Verify _normalize_response when DATA is None, raw is present, - and MESSAGE is None. - - ## Test - - - When MESSAGE is None, it is set to indicate JSON parsing failure - - ## Classes and Methods - - - Sender._normalize_response() - """ - instance = Sender() - response = { - "RETURN_CODE": 200, - "MESSAGE": None, - "DATA": None, - "raw": "raw content", - } - result = instance._normalize_response(response) - assert result["DATA"] == {"raw_response": "raw content"} - assert result["MESSAGE"] == "Response could not be parsed as JSON" - - -def test_sender_nd_00630(): - """ - # Summary - - Verify _normalize_response when DATA is None and raw is also None. - - ## Test - - - When both DATA and raw are None, response is not modified - - ## Classes and Methods - - - Sender._normalize_response() - """ - instance = Sender() - response = { - "RETURN_CODE": 500, - "MESSAGE": "Internal Server Error", - "DATA": None, - } - result = instance._normalize_response(response) - assert result["DATA"] is None - assert result["MESSAGE"] == "Internal Server Error" - - -def test_sender_nd_00640(): - """ - # Summary - - Verify _normalize_response preserves non-OK MESSAGE when raw is present. - - ## Test - - - When DATA is None and raw is present, MESSAGE is only overwritten - if it was "OK" or None - - ## Classes and Methods - - - Sender._normalize_response() - """ - instance = Sender() - response = { - "RETURN_CODE": 500, - "MESSAGE": "Internal Server Error", - "DATA": None, - "raw": "raw error content", - } - result = instance._normalize_response(response) - assert result["DATA"] == {"raw_response": "raw error content"} - # MESSAGE is NOT overwritten because it's not "OK" or None - assert result["MESSAGE"] == "Internal Server Error" - - -# ============================================================================= -# Test: Sender.commit() with mocked Connection -# ============================================================================= - - -def test_sender_nd_00700(): - """ - # Summary - - Verify commit() with successful GET request (no payload). - - ## Test - - - commit() calls Connection.send_request with verb and path - - response is populated from the Connection response - - ## Classes and Methods - - - Sender.commit() - """ - mock_module = MagicMock() - mock_module._socket_path = "/tmp/test_socket" - mock_module.params = {"config": {}} - - mock_connection = MagicMock() - mock_connection.send_request.return_value = { - "RETURN_CODE": 200, - "MESSAGE": "OK", - "DATA": {"status": "success"}, - } - - instance = Sender() - instance.ansible_module = mock_module - instance.path = "/api/v1/test" - instance.verb = HttpVerbEnum.GET - - with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", - return_value=mock_connection, - ): - with does_not_raise(): - instance.commit() - - assert instance.response["RETURN_CODE"] == 200 - assert instance.response["DATA"]["status"] == "success" - mock_connection.send_request.assert_called_once_with("GET", "/api/v1/test") - - -def test_sender_nd_00710(): - """ - # Summary - - Verify commit() with POST request including payload. - - ## Test - - - commit() calls Connection.send_request with verb, path, and JSON payload - - ## Classes and Methods - - - Sender.commit() - """ - mock_module = MagicMock() - mock_module._socket_path = "/tmp/test_socket" - mock_module.params = {"config": {}} - - mock_connection = MagicMock() - mock_connection.send_request.return_value = { - "RETURN_CODE": 200, - "MESSAGE": "OK", - "DATA": {"status": "created"}, - } - - instance = Sender() - instance.ansible_module = mock_module - instance.path = "/api/v1/test/create" - instance.verb = HttpVerbEnum.POST - instance.payload = {"name": "test"} - - with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", - return_value=mock_connection, - ): - with does_not_raise(): - instance.commit() - - assert instance.response["RETURN_CODE"] == 200 - assert instance.response["DATA"]["status"] == "created" - mock_connection.send_request.assert_called_once_with( - "POST", - "/api/v1/test/create", - '{"name": "test"}', - ) - - -def test_sender_nd_00720(): - """ - # Summary - - Verify commit() raises ValueError on connection failure. - - ## Test - - - When Connection.send_request raises AnsibleConnectionError, - commit() re-raises as ValueError - - ## Classes and Methods - - - Sender.commit() - """ - mock_module = MagicMock() - mock_module._socket_path = "/tmp/test_socket" - mock_module.params = {"config": {}} - - mock_connection = MagicMock() - mock_connection.send_request.side_effect = AnsibleConnectionError("Connection refused") - - instance = Sender() - instance.ansible_module = mock_module - instance.path = "/api/v1/test" - instance.verb = HttpVerbEnum.GET - - with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", - return_value=mock_connection, - ): - match = r"Sender\.commit:.*ConnectionError occurred" - with pytest.raises(ValueError, match=match): - instance.commit() - - -def test_sender_nd_00730(): - """ - # Summary - - Verify commit() raises ValueError on unexpected exception. - - ## Test - - - When Connection.send_request raises an unexpected Exception, - commit() wraps it in ValueError - - ## Classes and Methods - - - Sender.commit() - """ - mock_module = MagicMock() - mock_module._socket_path = "/tmp/test_socket" - mock_module.params = {"config": {}} - - mock_connection = MagicMock() - mock_connection.send_request.side_effect = RuntimeError("Unexpected error") - - instance = Sender() - instance.ansible_module = mock_module - instance.path = "/api/v1/test" - instance.verb = HttpVerbEnum.GET - - with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", - return_value=mock_connection, - ): - match = r"Sender\.commit:.*Unexpected error occurred" - with pytest.raises(ValueError, match=match): - instance.commit() - - -def test_sender_nd_00740(): - """ - # Summary - - Verify commit() reuses existing connection on second call. - - ## Test - - - First commit creates a new Connection - - Second commit reuses the existing connection - - Connection constructor is called only once - - ## Classes and Methods - - - Sender.commit() - """ - mock_module = MagicMock() - mock_module._socket_path = "/tmp/test_socket" - mock_module.params = {"config": {}} - - mock_connection = MagicMock() - mock_connection.send_request.return_value = { - "RETURN_CODE": 200, - "MESSAGE": "OK", - "DATA": {}, - } - - with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", - return_value=mock_connection, - ) as mock_conn_class: - instance = Sender() - instance.ansible_module = mock_module - instance.path = "/api/v1/test" - instance.verb = HttpVerbEnum.GET - - instance.commit() - instance.commit() - - # Connection constructor should only be called once - mock_conn_class.assert_called_once() - # send_request should be called twice - assert mock_connection.send_request.call_count == 2 - - -def test_sender_nd_00750(): - """ - # Summary - - Verify commit() normalizes non-JSON responses. - - ## Test - - - When Connection returns DATA=None with raw content, - commit() normalizes the response - - ## Classes and Methods - - - Sender.commit() - - Sender._normalize_response() - """ - mock_module = MagicMock() - mock_module._socket_path = "/tmp/test_socket" - mock_module.params = {"config": {}} - - mock_connection = MagicMock() - mock_connection.send_request.return_value = { - "RETURN_CODE": 200, - "MESSAGE": "OK", - "DATA": None, - "raw": "Non-JSON response", - } - - instance = Sender() - instance.ansible_module = mock_module - instance.path = "/api/v1/test" - instance.verb = HttpVerbEnum.GET - - with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", - return_value=mock_connection, - ): - with does_not_raise(): - instance.commit() - - assert instance.response["DATA"] == {"raw_response": "Non-JSON response"} - assert instance.response["MESSAGE"] == "Response could not be parsed as JSON" - - -def test_sender_nd_00760(): - """ - # Summary - - Verify commit() with PUT request including payload. - - ## Test - - - commit() calls Connection.send_request with PUT verb, path, and JSON payload - - ## Classes and Methods - - - Sender.commit() - """ - mock_module = MagicMock() - mock_module._socket_path = "/tmp/test_socket" - mock_module.params = {"config": {}} - - mock_connection = MagicMock() - mock_connection.send_request.return_value = { - "RETURN_CODE": 200, - "MESSAGE": "OK", - "DATA": {"status": "updated"}, - } - - instance = Sender() - instance.ansible_module = mock_module - instance.path = "/api/v1/test/update/12345" - instance.verb = HttpVerbEnum.PUT - instance.payload = {"status": "active"} - - with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", - return_value=mock_connection, - ): - with does_not_raise(): - instance.commit() - - assert instance.response["RETURN_CODE"] == 200 - mock_connection.send_request.assert_called_once_with( - "PUT", - "/api/v1/test/update/12345", - '{"status": "active"}', - ) - - -def test_sender_nd_00770(): - """ - # Summary - - Verify commit() with DELETE request (no payload). - - ## Test - - - commit() calls Connection.send_request with DELETE verb and path - - ## Classes and Methods - - - Sender.commit() - """ - mock_module = MagicMock() - mock_module._socket_path = "/tmp/test_socket" - mock_module.params = {"config": {}} - - mock_connection = MagicMock() - mock_connection.send_request.return_value = { - "RETURN_CODE": 200, - "MESSAGE": "OK", - "DATA": {"status": "deleted"}, - } - - instance = Sender() - instance.ansible_module = mock_module - instance.path = "/api/v1/test/delete/12345" - instance.verb = HttpVerbEnum.DELETE - - with patch( - "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", - return_value=mock_connection, - ): - with does_not_raise(): - instance.commit() - - assert instance.response["RETURN_CODE"] == 200 - mock_connection.send_request.assert_called_once_with("DELETE", "/api/v1/test/delete/12345") From 6dd000160118b6ba7deee1229ce5ddf0ee8c195d Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 10 Mar 2026 12:53:28 +0530 Subject: [PATCH 09/39] Renaming folders --- .../{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/__init__.py | 0 .../vpc_pair => endpoints/v1/manage_vpc_pair}/base_paths.py | 0 .../{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/enums.py | 0 .../{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/mixins.py | 0 .../v1/manage_vpc_pair}/model_playbook_vpc_pair.py | 0 .../v1/manage_vpc_pair}/vpc_pair_endpoints.py | 0 .../v1/manage_vpc_pair}/vpc_pair_resources.py | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/__init__.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/base_paths.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/enums.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/mixins.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/model_playbook_vpc_pair.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/vpc_pair_endpoints.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/vpc_pair_resources.py (100%) diff --git a/plugins/module_utils/manage/vpc_pair/__init__.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/__init__.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py diff --git a/plugins/module_utils/manage/vpc_pair/base_paths.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/base_paths.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py diff --git a/plugins/module_utils/manage/vpc_pair/enums.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/enums.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/enums.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/enums.py diff --git a/plugins/module_utils/manage/vpc_pair/mixins.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/mixins.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py diff --git a/plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/model_playbook_vpc_pair.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/model_playbook_vpc_pair.py diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py From 5798e80ab86df20bd152b9fd3f5ed060562822e8 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 10 Mar 2026 12:53:28 +0530 Subject: [PATCH 10/39] Renaming folders --- .../manage/vpc_pair => models}/model_playbook_vpc_pair.py | 0 .../{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/__init__.py | 0 .../vpc_pair => endpoints/v1/manage_vpc_pair}/base_paths.py | 0 .../{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/enums.py | 0 .../{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/mixins.py | 0 .../v1/manage_vpc_pair}/vpc_pair_endpoints.py | 0 .../v1/manage_vpc_pair}/vpc_pair_resources.py | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename plugins/{module_utils/manage/vpc_pair => models}/model_playbook_vpc_pair.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/__init__.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/base_paths.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/enums.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/mixins.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/vpc_pair_endpoints.py (100%) rename plugins/module_utils/{manage/vpc_pair => endpoints/v1/manage_vpc_pair}/vpc_pair_resources.py (100%) diff --git a/plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py b/plugins/models/model_playbook_vpc_pair.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/model_playbook_vpc_pair.py rename to plugins/models/model_playbook_vpc_pair.py diff --git a/plugins/module_utils/manage/vpc_pair/__init__.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/__init__.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py diff --git a/plugins/module_utils/manage/vpc_pair/base_paths.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/base_paths.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py diff --git a/plugins/module_utils/manage/vpc_pair/enums.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/enums.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/enums.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/enums.py diff --git a/plugins/module_utils/manage/vpc_pair/mixins.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/mixins.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/vpc_pair_endpoints.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py similarity index 100% rename from plugins/module_utils/manage/vpc_pair/vpc_pair_resources.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py From e44c3eb550d34478faf0155db7dbcc96ccb137c6 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 10 Mar 2026 20:08:45 +0530 Subject: [PATCH 11/39] Intermediate changes for Ep, model and utils --- plugins/models/__init__.py | 6 + plugins/models/base.py | 142 +++ plugins/models/nested.py | 32 + plugins/models/vpc_pair_models.py | 803 +++++++++++++++ .../endpoints/v1/manage_vpc_pair/__init__.py | 12 +- .../model_playbook_vpc_pair.py | 940 +----------------- .../v1/manage_vpc_pair/vpc_pair_endpoints.py | 6 +- .../v1/manage_vpc_pair/vpc_pair_resources.py | 25 +- plugins/module_utils/manage/__init__.py | 3 + .../module_utils/manage/vpc_pair/__init__.py | 3 + .../manage/vpc_pair/vpc_pair_details.py | 123 +++ plugins/modules/nd_vpc_pair.py | 301 ++++-- 12 files changed, 1372 insertions(+), 1024 deletions(-) create mode 100644 plugins/models/__init__.py create mode 100644 plugins/models/base.py create mode 100644 plugins/models/nested.py create mode 100644 plugins/models/vpc_pair_models.py create mode 100644 plugins/module_utils/manage/__init__.py create mode 100644 plugins/module_utils/manage/vpc_pair/__init__.py create mode 100644 plugins/module_utils/manage/vpc_pair/vpc_pair_details.py diff --git a/plugins/models/__init__.py b/plugins/models/__init__.py new file mode 100644 index 00000000..abb1dedf --- /dev/null +++ b/plugins/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + diff --git a/plugins/models/base.py b/plugins/models/base.py new file mode 100644 index 00000000..de71b919 --- /dev/null +++ b/plugins/models/base.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from abc import ABC, abstractmethod +from typing import Any, ClassVar, Dict, List, Literal, Tuple, Union, Annotated +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + BeforeValidator, + ConfigDict, +) +from typing_extensions import Self + + +def coerce_str_to_int(data): + """Convert string to int, handle None.""" + if data is None: + return None + if isinstance(data, str): + if data.strip() and data.lstrip("-").isdigit(): + return int(data) + raise ValueError(f"Cannot convert '{data}' to int") + return int(data) + + +def coerce_to_bool(data): + """Convert various formats to bool.""" + if data is None: + return None + if isinstance(data, str): + return data.lower() in ("true", "1", "yes", "on") + return bool(data) + + +def coerce_list_of_str(data): + """Ensure data is a list of strings.""" + if data is None: + return None + if isinstance(data, str): + return [item.strip() for item in data.split(",") if item.strip()] + if isinstance(data, list): + return [str(item) for item in data] + return data + + +FlexibleInt = Annotated[int, BeforeValidator(coerce_str_to_int)] +FlexibleBool = Annotated[bool, BeforeValidator(coerce_to_bool)] +FlexibleListStr = Annotated[List[str], BeforeValidator(coerce_list_of_str)] + + +class NDVpcPairBaseModel(BaseModel, ABC): + """ + Base model for VPC pair objects with identifiers. + + Similar to NDBaseModel from base.py but specific to VPC pair resources. + """ + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + extra="ignore", + ) + + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" + exclude_from_diff: ClassVar[List[str]] = [] + + @abstractmethod + def to_payload(self) -> Dict[str, Any]: + """Convert model to API payload format.""" + pass + + @classmethod + @abstractmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create model instance from API response.""" + pass + + def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: + """Extract identifier value(s) from this instance.""" + if not self.identifiers: + raise ValueError(f"{self.__class__.__name__} has no identifiers defined") + + if self.identifier_strategy == "single": + value = getattr(self, self.identifiers[0], None) + if value is None: + raise ValueError(f"Single identifier field '{self.identifiers[0]}' is None") + return value + + if self.identifier_strategy == "composite": + values = [] + missing = [] + + for field in self.identifiers: + value = getattr(self, field, None) + if value is None: + missing.append(field) + values.append(value) + + if missing: + raise ValueError( + f"Composite identifier fields {missing} are None. All required: {self.identifiers}" + ) + + return tuple(values) + + if self.identifier_strategy == "hierarchical": + for field in self.identifiers: + value = getattr(self, field, None) + if value is not None: + return (field, value) + + raise ValueError(f"No non-None value in hierarchical fields {self.identifiers}") + + raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") + + def get_switch_pair_key(self) -> str: + """Generate a unique key for VPC pair (sorted switch IDs).""" + if self.identifier_strategy != "composite" or len(self.identifiers) != 2: + raise ValueError( + "get_switch_pair_key only works with composite strategy and 2 identifiers" + ) + + values = self.get_identifier_value() + sorted_ids = sorted([str(v) for v in values]) + return f"{sorted_ids[0]}-{sorted_ids[1]}" + + def to_diff_dict(self) -> Dict[str, Any]: + """Export for diff comparison (excludes sensitive fields).""" + return self.model_dump( + by_alias=True, + exclude_none=True, + exclude=set(self.exclude_from_diff), + ) diff --git a/plugins/models/nested.py b/plugins/models/nested.py new file mode 100644 index 00000000..558da3c5 --- /dev/null +++ b/plugins/models/nested.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from typing import Any, Dict, List, ClassVar +from typing_extensions import Self +from ansible_collections.cisco.nd.plugins.models.base import NDVpcPairBaseModel + + +class NDVpcPairNestedModel(NDVpcPairBaseModel): + """ + Base for nested VPC pair models without identifiers. + + Similar to NDNestedModel from PR172 split pattern. + """ + + identifiers: ClassVar[List[str]] = [] + + def to_payload(self) -> Dict[str, Any]: + """Convert model to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create model instance from API response.""" + return cls.model_validate(response) diff --git a/plugins/models/vpc_pair_models.py b/plugins/models/vpc_pair_models.py new file mode 100644 index 00000000..58735fce --- /dev/null +++ b/plugins/models/vpc_pair_models.py @@ -0,0 +1,803 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Sivakami Sivaraman + +# 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 + +""" +Pydantic models for VPC pair management in Nexus Dashboard 4.x API. + +This module provides comprehensive models covering all 34 OpenAPI schemas +organized into functional domains: +- Configuration Domain: VPC pairing and lifecycle management +- Inventory Domain: VPC pair listing and discovery +- Monitoring Domain: Health, status, and operational metrics +- Consistency Domain: Configuration consistency validation +- Validation Domain: Support checks and peer recommendations +""" + +from typing import List, Dict, Any, Optional, Union, ClassVar, Literal +from typing_extensions import Self +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_validator, + model_validator, +) +from ansible_collections.cisco.nd.plugins.models.base import ( + FlexibleBool, + FlexibleInt, + FlexibleListStr, + NDVpcPairBaseModel, +) +from ansible_collections.cisco.nd.plugins.models.nested import NDVpcPairNestedModel + +# Import enums from centralized location +try: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + VpcActionEnum, + VpcPairTypeEnum, + KeepAliveVrfEnum, + PoModeEnum, + PortChannelDuplexEnum, + VpcRoleEnum, + MaintenanceModeEnum, + ComponentTypeOverviewEnum, + ComponentTypeSupportEnum, + VpcPairViewEnum, + VpcFieldNames, + ) +except Exception: + from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.enums import ( + VpcActionEnum, + VpcPairTypeEnum, + KeepAliveVrfEnum, + PoModeEnum, + PortChannelDuplexEnum, + VpcRoleEnum, + MaintenanceModeEnum, + ComponentTypeOverviewEnum, + ComponentTypeSupportEnum, + VpcPairViewEnum, + VpcFieldNames, + ) + +# ============================================================================ +# NESTED MODELS (No Identifiers) +# ============================================================================ + + +class SwitchInfo(NDVpcPairNestedModel): + """Generic switch information for both peers.""" + + switch: str = Field(alias="switch", description="Switch value") + peer_switch: str = Field(alias="peerSwitch", description="Peer switch value") + + +class SwitchIntInfo(NDVpcPairNestedModel): + """Generic switch integer information for both peers.""" + + switch: FlexibleInt = Field(alias="switch", description="Switch value") + peer_switch: FlexibleInt = Field(alias="peerSwitch", description="Peer switch value") + + +class SwitchBoolInfo(NDVpcPairNestedModel): + """Generic switch boolean information for both peers.""" + + switch: FlexibleBool = Field(alias="switch", description="Switch value") + peer_switch: FlexibleBool = Field(alias="peerSwitch", description="Peer switch value") + + +class SyncCounts(NDVpcPairNestedModel): + """Sync status counts.""" + + in_sync: FlexibleInt = Field(default=0, alias="inSync", description="In-sync items") + pending: FlexibleInt = Field(default=0, alias="pending", description="Pending items") + out_of_sync: FlexibleInt = Field(default=0, alias="outOfSync", description="Out-of-sync items") + in_progress: FlexibleInt = Field(default=0, alias="inProgress", description="In-progress items") + + +class AnomaliesCount(NDVpcPairNestedModel): + """Anomaly counts by severity.""" + + critical: FlexibleInt = Field(default=0, alias="critical", description="Critical anomalies") + major: FlexibleInt = Field(default=0, alias="major", description="Major anomalies") + minor: FlexibleInt = Field(default=0, alias="minor", description="Minor anomalies") + warning: FlexibleInt = Field(default=0, alias="warning", description="Warning anomalies") + + +class HealthMetrics(NDVpcPairNestedModel): + """Health metrics for both switches.""" + + switch: str = Field(alias="switch", description="Switch health status") + peer_switch: str = Field(alias="peerSwitch", description="Peer switch health status") + + +class ResourceMetrics(NDVpcPairNestedModel): + """Resource utilization metrics.""" + + switch: FlexibleInt = Field(alias="switch", description="Switch metric value") + peer_switch: FlexibleInt = Field(alias="peerSwitch", description="Peer switch metric value") + + +class InterfaceStatusCounts(NDVpcPairNestedModel): + """Interface status counts.""" + + up: FlexibleInt = Field(alias="up", description="Interfaces in up state") + down: FlexibleInt = Field(alias="down", description="Interfaces in down state") + + +class LogicalInterfaceCounts(NDVpcPairNestedModel): + """Logical interface type counts.""" + + port_channel: FlexibleInt = Field(alias="portChannel", description="Port channel interfaces") + loopback: FlexibleInt = Field(alias="loopback", description="Loopback interfaces") + vpc: FlexibleInt = Field(alias="vPC", description="VPC interfaces") + vlan: FlexibleInt = Field(alias="vlan", description="VLAN interfaces") + nve: FlexibleInt = Field(alias="nve", description="NVE interfaces") + + +class ResponseCounts(NDVpcPairNestedModel): + """Response metadata counts.""" + + total: FlexibleInt = Field(alias="total", description="Total count") + remaining: FlexibleInt = Field(alias="remaining", description="Remaining count") + + +# ============================================================================ +# VPC PAIR DETAILS MODELS (Nested Template Configuration) +# ============================================================================ + + +class VpcPairDetailsDefault(NDVpcPairNestedModel): + """ + Default template VPC pair configuration. + + OpenAPI: vpcPairDetailsDefault + """ + + type: Literal["default"] = Field(default="default", alias="type", description="Template type") + domain_id: Optional[FlexibleInt] = Field(default=None, alias="domainId", description="VPC domain ID") + switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="switchKeepAliveLocalIp", description="Peer-1 keep-alive IP") + peer_switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="peerSwitchKeepAliveLocalIp", description="Peer-2 keep-alive IP") + keep_alive_vrf: Optional[KeepAliveVrfEnum] = Field(default=None, alias="keepAliveVrf", description="Keep-alive VRF") + keep_alive_hold_timeout: Optional[FlexibleInt] = Field(default=3, alias="keepAliveHoldTimeout", description="Keep-alive hold timeout") + enable_mirror_config: Optional[FlexibleBool] = Field(default=False, alias="enableMirrorConfig", description="Enable config mirroring") + is_vpc_plus: Optional[FlexibleBool] = Field(default=False, alias="isVpcPlus", description="VPC+ topology") + fabric_path_switch_id: Optional[FlexibleInt] = Field(default=None, alias="fabricPathSwitchId", description="FabricPath switch ID") + is_vteps: Optional[FlexibleBool] = Field(default=False, alias="isVteps", description="Configure NVE source loopback") + nve_interface: Optional[FlexibleInt] = Field(default=1, alias="nveInterface", description="NVE interface") + switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="switchSourceLoopback", description="Peer-1 source loopback") + peer_switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchSourceLoopback", description="Peer-2 source loopback") + switch_primary_ip: Optional[str] = Field(default=None, alias="switchPrimaryIp", description="Peer-1 primary IP") + peer_switch_primary_ip: Optional[str] = Field(default=None, alias="peerSwitchPrimaryIp", description="Peer-2 primary IP") + loopback_secondary_ip: Optional[str] = Field(default=None, alias="loopbackSecondaryIp", description="Secondary loopback IP") + switch_domain_config: Optional[str] = Field(default=None, alias="switchDomainConfig", description="Peer-1 domain config CLI") + peer_switch_domain_config: Optional[str] = Field(default=None, alias="peerSwitchDomainConfig", description="Peer-2 domain config CLI") + switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="switchPoId", description="Peer-1 port-channel ID") + peer_switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchPoId", description="Peer-2 port-channel ID") + switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="switchMemberInterfaces", description="Peer-1 member interfaces") + peer_switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="peerSwitchMemberInterfaces", description="Peer-2 member interfaces") + po_mode: Optional[str] = Field(default="active", alias="poMode", description="Port-channel mode") + switch_po_description: Optional[str] = Field(default=None, alias="switchPoDescription", description="Peer-1 port-channel description") + peer_switch_po_description: Optional[str] = Field(default=None, alias="peerSwitchPoDescription", description="Peer-2 port-channel description") + admin_state: Optional[FlexibleBool] = Field(default=True, alias="adminState", description="Admin state") + allowed_vlans: Optional[str] = Field(default="all", alias="allowedVlans", description="Allowed VLANs") + switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="switchNativeVlan", description="Peer-1 native VLAN") + peer_switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchNativeVlan", description="Peer-2 native VLAN") + switch_po_config: Optional[str] = Field(default=None, alias="switchPoConfig", description="Peer-1 port-channel freeform config") + peer_switch_po_config: Optional[str] = Field(default=None, alias="peerSwitchPoConfig", description="Peer-2 port-channel freeform config") + fabric_name: Optional[str] = Field(default=None, alias="fabricName", description="Fabric name") + + +class VpcPairDetailsCustom(NDVpcPairNestedModel): + """ + Custom template VPC pair configuration. + + OpenAPI: vpcPairDetailsCustom + """ + + type: Literal["custom"] = Field(default="custom", alias="type", description="Template type") + template_name: str = Field(alias="templateName", description="Name of the custom template") + template_config: Dict[str, Any] = Field(alias="templateConfig", description="Free-form configuration") + + +# ============================================================================ +# CONFIGURATION DOMAIN MODELS +# ============================================================================ + + +class VpcPairBase(NDVpcPairBaseModel): + """ + Base schema for VPC pairing with common properties. + + Identifier: (switch_id, peer_switch_id) - composite + OpenAPI: vpcPairBase + + Note: The nd_vpc_pair module uses a separate VpcPairModel class (not this one) because: + - Module needs use_virtual_peer_link=True as default (this uses False per API spec) + - Module uses NDBaseModel base class for framework integration + - Module needs strict bool types, this uses FlexibleBool for API flexibility + See plugins/modules/nd_vpc_pair.py VpcPairModel for the module-specific implementation. + """ + + # Identifier configuration + identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" + + # Fields with validation constraints + switch_id: str = Field( + alias="switchId", + description="Switch serial number (Peer-1)", + min_length=3, + max_length=64 + ) + peer_switch_id: str = Field( + alias="peerSwitchId", + description="Peer switch serial number (Peer-2)", + min_length=3, + max_length=64 + ) + use_virtual_peer_link: FlexibleBool = Field(default=False, alias="useVirtualPeerLink", description="Virtual peer link present") + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, discriminator="type", alias="vpcPairDetails", description="VPC pair configuration details" + ) + + @field_validator("switch_id", "peer_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Switch ID value + + Returns: + Stripped switch ID + + Raises: + ValueError: If switch ID is empty or whitespace + """ + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> Self: + """ + Ensure switch_id and peer_switch_id are different. + + Returns: + Validated model instance + + Raises: + ValueError: If switch_id equals peer_switch_id + """ + if self.switch_id == self.peer_switch_id: + raise ValueError( + f"switch_id and peer_switch_id must be different: {self.switch_id}" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create instance from API response.""" + return cls.model_validate(response) + + +class VpcPairingRequest(NDVpcPairBaseModel): + """ + Request schema for pairing VPC switches. + + Identifier: (switch_id, peer_switch_id) - composite + OpenAPI: vpcPairingRequest + """ + + # Identifier configuration + identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] + identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" + + # Fields with validation constraints + vpc_action: VpcActionEnum = Field(default=VpcActionEnum.PAIR, alias="vpcAction", description="Action to pair") + switch_id: str = Field( + alias="switchId", + description="Switch serial number (Peer-1)", + min_length=3, + max_length=64 + ) + peer_switch_id: str = Field( + alias="peerSwitchId", + description="Peer switch serial number (Peer-2)", + min_length=3, + max_length=64 + ) + use_virtual_peer_link: FlexibleBool = Field(default=False, alias="useVirtualPeerLink", description="Virtual peer link present") + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, discriminator="type", alias="vpcPairDetails", description="VPC pair configuration details" + ) + + @field_validator("switch_id", "peer_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Switch ID value + + Returns: + Stripped switch ID + + Raises: + ValueError: If switch ID is empty or whitespace + """ + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> Self: + """ + Ensure switch_id and peer_switch_id are different. + + Returns: + Validated model instance + + Raises: + ValueError: If switch_id equals peer_switch_id + """ + if self.switch_id == self.peer_switch_id: + raise ValueError( + f"switch_id and peer_switch_id must be different: {self.switch_id}" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create instance from API response.""" + return cls.model_validate(response) + + +class VpcUnpairingRequest(NDVpcPairBaseModel): + """ + Request schema for unpairing VPC switches. + + Identifier: N/A (no specific switch IDs in unpair request) + OpenAPI: vpcUnpairingRequest + """ + + # No identifiers for unpair request + identifiers: ClassVar[List[str]] = [] + + # Fields + vpc_action: VpcActionEnum = Field(default=VpcActionEnum.UNPAIR, alias="vpcAction", description="Action to unpair") + + def get_identifier_value(self) -> str: + """Override - unpair doesn't have identifiers.""" + return "unpair" + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create instance from API response.""" + return cls.model_validate(response) + + +# ============================================================================ +# MONITORING DOMAIN MODELS +# ============================================================================ + + +class VpcPairsInfoBase(NDVpcPairNestedModel): + """ + VPC pair information base. + + OpenAPI: vpcPairsInfoBase + """ + + switch_name: SwitchInfo = Field(alias="switchName", description="Switch name") + ip_address: SwitchInfo = Field(alias="ipAddress", description="IP address") + fabric_name: str = Field(alias="fabricName", description="Fabric name") + connectivity_status: SwitchInfo = Field(alias="connectivityStatus", description="Connectivity status") + maintenance_mode: SwitchInfo = Field(alias="maintenanceMode", description="Maintenance mode") + uptime: SwitchInfo = Field(alias="uptime", description="Switch uptime") + switch_id: SwitchInfo = Field(alias="switchId", description="Switch serial number") + model: SwitchInfo = Field(alias="model", description="Switch model") + switch_role: SwitchInfo = Field(alias="switchRole", description="Switch role") + is_consistent: SwitchBoolInfo = Field(alias="isConsistent", description="Consistency status") + domain_id: SwitchIntInfo = Field(alias="domainId", description="Domain ID") + platform_type: SwitchInfo = Field(alias="platformType", description="Platform type") + + +class VpcPairHealthBase(NDVpcPairNestedModel): + """ + VPC pair health information. + + OpenAPI: vpcPairHealthBase + """ + + switch_id: str = Field(alias="switchId", description="Switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer switch serial number") + health: HealthMetrics = Field(alias="health", description="Health status") + cpu: ResourceMetrics = Field(alias="cpu", description="CPU utilization") + memory: ResourceMetrics = Field(alias="memory", description="Memory utilization") + temperature: ResourceMetrics = Field(alias="temperature", description="Temperature in Celsius") + + +class VpcPairsVxlanBase(NDVpcPairNestedModel): + """ + VPC pairs VXLAN details. + + OpenAPI: vpcPairsVxlanBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + routing_loopback: SwitchInfo = Field(alias="routingLoopback", description="Routing loopback") + routing_loopback_status: SwitchInfo = Field(alias="routingLoopbackStatus", description="Routing loopback status") + routing_loopback_primary_ip: SwitchInfo = Field(alias="routingLoopbackPrimaryIp", description="Routing loopback primary IP") + routing_loopback_secondary_ip: Optional[SwitchInfo] = Field(default=None, alias="routingLoopbackSecondaryIp", description="Routing loopback secondary IP") + vtep_loopback: SwitchInfo = Field(alias="vtepLoopback", description="VTEP loopback") + vtep_loopback_status: SwitchInfo = Field(alias="vtepLoopbackStatus", description="VTEP loopback status") + vtep_loopback_primary_ip: SwitchInfo = Field(alias="vtepLoopbackPrimaryIp", description="VTEP loopback primary IP") + vtep_loopback_secondary_ip: Optional[SwitchInfo] = Field(default=None, alias="vtepLoopbackSecondaryIp", description="VTEP loopback secondary IP") + nve_interface: SwitchInfo = Field(alias="nveInterface", description="NVE interface") + nve_status: SwitchInfo = Field(alias="nveStatus", description="NVE status") + multisite_loopback: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopback", description="Multisite loopback") + multisite_loopback_status: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopbackStatus", description="Multisite loopback status") + multisite_loopback_primary_ip: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopbackPrimaryIp", description="Multisite loopback primary IP") + + +class VpcPairsOverlayBase(NDVpcPairNestedModel): + """ + VPC pairs overlay base. + + OpenAPI: vpcPairsOverlayBase + """ + + network_count: SyncCounts = Field(alias="networkCount", description="Network count") + vrf_count: SyncCounts = Field(alias="vrfCount", description="VRF count") + + +class VpcPairsInventoryBase(NDVpcPairNestedModel): + """ + VPC pair inventory base. + + OpenAPI: vpcPairsInventoryBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + admin_status: InterfaceStatusCounts = Field(alias="adminStatus", description="Admin status") + operational_status: InterfaceStatusCounts = Field(alias="operationalStatus", description="Operational status") + sync_status: Dict[str, FlexibleInt] = Field(alias="syncStatus", description="Sync status") + logical_interfaces: LogicalInterfaceCounts = Field(alias="logicalInterfaces", description="Logical interfaces") + + +class VpcPairsModuleBase(NDVpcPairNestedModel): + """ + VPC pair module base. + + OpenAPI: vpcPairsModuleBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + module_information: Dict[str, str] = Field(default_factory=dict, alias="moduleInformation", description="VPC pair module information") + fex_details: Dict[str, str] = Field(default_factory=dict, alias="fexDetails", description="Fex details name-value pair(s)") + + +class VpcPairAnomaliesBase(NDVpcPairNestedModel): + """ + VPC pair anomalies information. + + OpenAPI: vpcPairAnomaliesBase + """ + + switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") + anomalies_count: AnomaliesCount = Field(alias="anomaliesCount", description="Anomaly counts by severity") + + +# ============================================================================ +# CONSISTENCY DOMAIN MODELS +# ============================================================================ + + +class CommonVpcConsistencyParams(NDVpcPairNestedModel): + """ + Common consistency parameters for VPC domain. + + OpenAPI: commonVpcConsistencyParams + """ + + # Basic identifiers + switch_name: str = Field(alias="switchName", description="Switch name") + ip_address: str = Field(alias="ipAddress", description="IP address") + domain_id: FlexibleInt = Field(alias="domainId", description="Domain ID") + + # Port channel info + peer_link_port_channel: FlexibleInt = Field(alias="peerLinkPortChannel", description="Port channel peer link") + port_channel_name: Optional[str] = Field(default=None, alias="portChannelName", description="Port channel name") + description: Optional[str] = Field(default=None, alias="description", description="Port channel description") + + # VPC system parameters + system_mac_address: str = Field(alias="systemMacAddress", description="System MAC address") + system_priority: FlexibleInt = Field(alias="systemPriority", description="System priority") + udp_port: FlexibleInt = Field(alias="udpPort", description="UDP port") + interval: FlexibleInt = Field(alias="interval", description="Interval") + timeout: FlexibleInt = Field(alias="timeout", description="Timeout") + + # Additional fields (simplified - add as needed) + # NOTE: OpenAPI has many more fields - add them as required + + +class VpcPairConsistency(NDVpcPairNestedModel): + """ + VPC pair consistency check results. + + OpenAPI: vpcPairConsistency + """ + + switch_id: str = Field(alias="switchId", description="Primary switch serial number") + peer_switch_id: str = Field(alias="peerSwitchId", description="Secondary switch serial number") + type2_consistency: FlexibleBool = Field(alias="type2Consistency", description="Type-2 consistency status") + type2_consistency_reason: str = Field(alias="type2ConsistencyReason", description="Consistency reason") + timestamp: Optional[FlexibleInt] = Field(default=None, alias="timestamp", description="Timestamp of check") + primary_parameters: CommonVpcConsistencyParams = Field(alias="primaryParameters", description="Primary switch consistency parameters") + secondary_parameters: CommonVpcConsistencyParams = Field(alias="secondaryParameters", description="Secondary switch consistency parameters") + is_consistent: Optional[FlexibleBool] = Field(default=None, alias="isConsistent", description="Overall consistency") + is_discovered: Optional[FlexibleBool] = Field(default=None, alias="isDiscovered", description="Whether pair is discovered") + + +# ============================================================================ +# VALIDATION DOMAIN MODELS +# ============================================================================ + + +class VpcPairRecommendation(NDVpcPairNestedModel): + """ + Recommendation information for a switch. + + OpenAPI: vpcPairRecommendation + """ + + hostname: str = Field(alias="hostname", description="Logical name of switch") + ip_address: str = Field(alias="ipAddress", description="IP address of switch") + switch_id: str = Field(alias="switchId", description="Serial number of the switch") + software_version: str = Field(alias="softwareVersion", description="NXOS version of switch") + fabric_name: str = Field(alias="fabricName", description="Fabric name") + recommendation_reason: str = Field(alias="recommendationReason", description="Recommendation message") + block_selection: FlexibleBool = Field(alias="blockSelection", description="Block selection") + platform_type: str = Field(alias="platformType", description="Platform type of switch") + use_virtual_peer_link: FlexibleBool = Field(alias="useVirtualPeerLink", description="Virtual peer link available") + is_current_peer: FlexibleBool = Field(alias="isCurrentPeer", description="Device is current peer") + is_recommended: FlexibleBool = Field(alias="isRecommended", description="Recommended device") + + +# ============================================================================ +# INVENTORY DOMAIN MODELS +# ============================================================================ + + +class VpcPairBaseSwitchDetails(NDVpcPairNestedModel): + """ + Base fields for VPC pair records. + + OpenAPI: vpcPairBaseSwitchDetails + """ + + domain_id: FlexibleInt = Field(alias="domainId", description="Domain ID of the VPC") + switch_id: str = Field(alias="switchId", description="Serial number of the switch") + switch_name: str = Field(alias="switchName", description="Hostname of the switch") + peer_switch_id: str = Field(alias="peerSwitchId", description="Serial number of the peer switch") + peer_switch_name: str = Field(alias="peerSwitchName", description="Hostname of the peer switch") + + +class VpcPairIntended(VpcPairBaseSwitchDetails): + """ + Intended VPC pair record. + + OpenAPI: vpcPairIntended + """ + + type: Literal["intendedPairs"] = Field(default="intendedPairs", alias="type", description="Type identifier") + + +class VpcPairDiscovered(VpcPairBaseSwitchDetails): + """ + Discovered VPC pair record. + + OpenAPI: vpcPairDiscovered + """ + + type: Literal["discoveredPairs"] = Field(default="discoveredPairs", alias="type", description="Type identifier") + switch_vpc_role: VpcRoleEnum = Field(alias="switchVpcRole", description="VPC role of the switch") + peer_switch_vpc_role: VpcRoleEnum = Field(alias="peerSwitchVpcRole", description="VPC role of the peer switch") + intended_peer_name: str = Field(alias="intendedPeerName", description="Name of the intended peer switch") + description: str = Field(alias="description", description="Description of any discrepancies or issues") + + +class Metadata(NDVpcPairNestedModel): + """ + Metadata for pagination and links. + + OpenAPI: Metadata + """ + + counts: ResponseCounts = Field(alias="counts", description="Count information") + links: Optional[Dict[str, str]] = Field(default=None, alias="links", description="Pagination links (next, previous)") + + +class VpcPairsResponse(NDVpcPairNestedModel): + """ + Response schema for listing VPC pairs. + + OpenAPI: vpcPairsResponse + """ + + vpc_pairs: List[Union[VpcPairIntended, VpcPairDiscovered]] = Field(alias="vpcPairs", description="List of VPC pairs") + meta: Metadata = Field(alias="meta", description="Response metadata") + + +# ============================================================================ +# WRAPPER MODELS WITH COMPONENT TYPE +# ============================================================================ + + +class VpcPairsInfo(NDVpcPairNestedModel): + """VPC pairs information wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.PAIRS_INFO, alias="componentType", description="Type of the component") + info: VpcPairsInfoBase = Field(alias="info", description="VPC pair info") + + +class VpcPairHealth(NDVpcPairNestedModel): + """VPC pair health wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.HEALTH, alias="componentType", description="Type of the component") + health: VpcPairHealthBase = Field(alias="health", description="Health details") + + +class VpcPairsModule(NDVpcPairNestedModel): + """VPC pairs module wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.MODULE, alias="componentType", description="Type of the component") + module: VpcPairsModuleBase = Field(alias="module", description="Module details") + + +class VpcPairAnomalies(NDVpcPairNestedModel): + """VPC pair anomalies wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.ANOMALIES, alias="componentType", description="Type of the component") + anomalies: VpcPairAnomaliesBase = Field(alias="anomalies", description="Anomalies details") + + +class VpcPairsVxlan(NDVpcPairNestedModel): + """VPC pairs VXLAN wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.VXLAN, alias="componentType", description="Type of the component") + vxlan: VpcPairsVxlanBase = Field(alias="vxlan", description="VXLAN details") + + +class VpcPairsOverlay(NDVpcPairNestedModel): + """VPC overlay details wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.OVERLAY, alias="componentType", description="Type of the component") + overlay: VpcPairsOverlayBase = Field(alias="overlay", description="Overlay details") + + +class VpcPairsInventory(NDVpcPairNestedModel): + """VPC pairs inventory details wrapper.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.INVENTORY, alias="componentType", description="Type of the component") + inventory: VpcPairsInventoryBase = Field(alias="inventory", description="Inventory details") + + +class FullOverview(NDVpcPairNestedModel): + """Full VPC overview response.""" + + component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.FULL, alias="componentType", description="Type of the component") + anomalies: VpcPairAnomaliesBase = Field(alias="anomalies", description="VPC pair anomalies") + health: VpcPairHealthBase = Field(alias="health", description="VPC pair health") + module: VpcPairsModuleBase = Field(alias="module", description="VPC pair module") + vxlan: VpcPairsVxlanBase = Field(alias="vxlan", description="VPC pair VXLAN") + overlay: VpcPairsOverlayBase = Field(alias="overlay", description="VPC pair overlay") + pairs_info: VpcPairsInfoBase = Field(alias="pairsInfo", description="VPC pair info") + inventory: VpcPairsInventoryBase = Field(alias="inventory", description="VPC pair inventory") + + +# ============================================================================ +# BACKWARD COMPATIBILITY CONTAINER (NdVpcPairSchema) +# ============================================================================ + + +class NdVpcPairSchema: + """ + Backward compatibility container for all VPC pair schemas. + + This provides a namespace similar to the old structure where models + were nested inside a container class. Allows imports like: + + from model_playbook_vpc_pair_nested import NdVpcPairSchema + vpc_pair = NdVpcPairSchema.VpcPairBase(**data) + """ + + # Base classes + VpcPairBaseModel = NDVpcPairBaseModel + VpcPairNestedModel = NDVpcPairNestedModel + + # Enumerations (these are class variable type hints, not assignments) + # VpcRole = VpcRoleEnum # Commented out - not needed + # TemplateType = VpcPairTypeEnum # Commented out - not needed + # KeepAliveVrf = KeepAliveVrfEnum # Commented out - not needed + # VpcAction = VpcActionEnum # Commented out - not needed + # ComponentType = ComponentTypeOverviewEnum # Commented out - not needed + + # Nested helper models + SwitchInfo = SwitchInfo + SwitchIntInfo = SwitchIntInfo + SwitchBoolInfo = SwitchBoolInfo + SyncCounts = SyncCounts + AnomaliesCount = AnomaliesCount + HealthMetrics = HealthMetrics + ResourceMetrics = ResourceMetrics + InterfaceStatusCounts = InterfaceStatusCounts + LogicalInterfaceCounts = LogicalInterfaceCounts + ResponseCounts = ResponseCounts + + # VPC pair details (template configuration) + VpcPairDetailsDefault = VpcPairDetailsDefault + VpcPairDetailsCustom = VpcPairDetailsCustom + + # Configuration domain + VpcPairBase = VpcPairBase + VpcPairingRequest = VpcPairingRequest + VpcUnpairingRequest = VpcUnpairingRequest + + # Monitoring domain + VpcPairsInfoBase = VpcPairsInfoBase + VpcPairHealthBase = VpcPairHealthBase + VpcPairsVxlanBase = VpcPairsVxlanBase + VpcPairsOverlayBase = VpcPairsOverlayBase + VpcPairsInventoryBase = VpcPairsInventoryBase + VpcPairsModuleBase = VpcPairsModuleBase + VpcPairAnomaliesBase = VpcPairAnomaliesBase + + # Monitoring domain wrappers + VpcPairsInfo = VpcPairsInfo + VpcPairHealth = VpcPairHealth + VpcPairsModule = VpcPairsModule + VpcPairAnomalies = VpcPairAnomalies + VpcPairsVxlan = VpcPairsVxlan + VpcPairsOverlay = VpcPairsOverlay + VpcPairsInventory = VpcPairsInventory + FullOverview = FullOverview + + # Consistency domain + CommonVpcConsistencyParams = CommonVpcConsistencyParams + VpcPairConsistency = VpcPairConsistency + + # Validation domain + VpcPairRecommendation = VpcPairRecommendation + + # Inventory domain + VpcPairBaseSwitchDetails = VpcPairBaseSwitchDetails + VpcPairIntended = VpcPairIntended + VpcPairDiscovered = VpcPairDiscovered + Metadata = Metadata + VpcPairsResponse = VpcPairsResponse diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py index eb57e943..73e1d492 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py @@ -18,7 +18,7 @@ - vpc_pair_schemas: Request/response data schemas Usage: - from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair import ( + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair import ( EpVpcPairGet, EpVpcPairPut, VpcPairDetailsDefault, @@ -65,7 +65,7 @@ # Try to import and expose components (graceful fallback if pydantic not available) try: - from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_endpoints import ( + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_endpoints import ( EpVpcPairGet, EpVpcPairPut, EpVpcPairSupportGet, @@ -74,7 +74,7 @@ EpVpcPairConsistencyGet, EpVpcPairsListGet, ) - from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.model_playbook_vpc_pair import ( + from ansible_collections.cisco.nd.plugins.models.model_playbook_vpc_pair import ( VpcPairDetailsDefault, VpcPairDetailsCustom, VpcPairingRequest, @@ -83,7 +83,7 @@ VpcPairConsistency, VpcPairRecommendation, ) - from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.enums import ( + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( VerbEnum, VpcActionEnum, VpcPairTypeEnum, @@ -97,7 +97,9 @@ VpcPairViewEnum, VpcFieldNames, ) - from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.base_paths import VpcPairBasePath + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.base_paths import ( + VpcPairBasePath, + ) except ImportError as e: # Pydantic not available - components will not be exposed diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/model_playbook_vpc_pair.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/model_playbook_vpc_pair.py index 939cc6d7..5c95eb83 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/model_playbook_vpc_pair.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/model_playbook_vpc_pair.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2025, Neil John (@neijohn) +# Copyright: (c) 2025, Sivakami Sivaraman # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -9,930 +9,22 @@ __metaclass__ = type """ -Pydantic models for VPC pair management in Nexus Dashboard 4.x API. +Backward-compatible export surface for vPC pair playbook models. -This module provides comprehensive models covering all 34 OpenAPI schemas -organized into functional domains: -- Configuration Domain: VPC pairing and lifecycle management -- Inventory Domain: VPC pair listing and discovery -- Monitoring Domain: Health, status, and operational metrics -- Consistency Domain: Configuration consistency validation -- Validation Domain: Support checks and peer recommendations +The implementation is split following the PR172 style: +- base.py: coercion helpers and base model +- nested.py: nested-model base class +- vpc_pair_models.py: vPC-pair-specific schemas """ -from abc import ABC, abstractmethod -from typing import List, Dict, Any, Optional, Union, Tuple, ClassVar, Literal, Annotated -from typing_extensions import Self -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - BeforeValidator, - ConfigDict, - Field, - field_validator, - model_validator, +from ansible_collections.cisco.nd.plugins.models.base import ( # noqa: F401 + coerce_str_to_int, + coerce_to_bool, + coerce_list_of_str, + FlexibleInt, + FlexibleBool, + FlexibleListStr, + NDVpcPairBaseModel, ) - -# Import enums from centralized location -from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.enums import ( - VpcActionEnum, - VpcPairTypeEnum, - KeepAliveVrfEnum, - PoModeEnum, - PortChannelDuplexEnum, - VpcRoleEnum, - MaintenanceModeEnum, - ComponentTypeOverviewEnum, - ComponentTypeSupportEnum, - VpcPairViewEnum, - VpcFieldNames, -) - -# ============================================================================ -# TYPE COERCION HELPERS -# ============================================================================ - - -def coerce_str_to_int(data): - """Convert string to int, handle None.""" - if data is None: - return None - if isinstance(data, str): - if data.strip() and data.lstrip("-").isdigit(): - return int(data) - raise ValueError(f"Cannot convert '{data}' to int") - return int(data) - - -def coerce_to_bool(data): - """Convert various formats to bool.""" - if data is None: - return None - if isinstance(data, str): - return data.lower() in ("true", "1", "yes", "on") - return bool(data) - - -def coerce_list_of_str(data): - """Ensure data is a list of strings.""" - if data is None: - return None - if isinstance(data, str): - return [item.strip() for item in data.split(",") if item.strip()] - if isinstance(data, list): - return [str(item) for item in data] - return data - - -# Type aliases for flexible validation -FlexibleInt = Annotated[int, BeforeValidator(coerce_str_to_int)] -FlexibleBool = Annotated[bool, BeforeValidator(coerce_to_bool)] -FlexibleListStr = Annotated[List[str], BeforeValidator(coerce_list_of_str)] - - -# ============================================================================ -# BASE CLASSES -# ============================================================================ - - -class NDVpcPairBaseModel(BaseModel, ABC): - """ - Base model for VPC pair objects with identifiers. - - Similar to NDBaseModel from base.py but specific to VPC pair resources. - """ - - model_config = ConfigDict(str_strip_whitespace=True, use_enum_values=True, validate_assignment=True, populate_by_name=True, extra="ignore") - - # Subclasses MUST define these - identifiers: ClassVar[List[str]] = [] - identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" - - # Optional: fields to exclude from diffs - exclude_from_diff: ClassVar[List[str]] = [] - - @abstractmethod - def to_payload(self) -> Dict[str, Any]: - """Convert model to API payload format.""" - pass - - @classmethod - @abstractmethod - def from_response(cls, response: Dict[str, Any]) -> Self: - """Create model instance from API response.""" - pass - - def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: - """ - Extract identifier value(s) from this instance. - - For VPC pairs, uses composite strategy with (switchId, peerSwitchId). - """ - if not self.identifiers: - raise ValueError(f"{self.__class__.__name__} has no identifiers defined") - - if self.identifier_strategy == "single": - value = getattr(self, self.identifiers[0], None) - if value is None: - raise ValueError(f"Single identifier field '{self.identifiers[0]}' is None") - return value - - elif self.identifier_strategy == "composite": - values = [] - 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. All required: {self.identifiers}") - - return tuple(values) - - elif self.identifier_strategy == "hierarchical": - for field in self.identifiers: - value = getattr(self, field, None) - if value is not None: - return (field, value) - - raise ValueError(f"No non-None value in hierarchical fields {self.identifiers}") - - else: - raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") - - def get_switch_pair_key(self) -> str: - """ - Generate a unique key for VPC pair (sorted switch IDs). - - Returns: - str: Unique identifier in format "switchId1-switchId2" - """ - if self.identifier_strategy != "composite" or len(self.identifiers) != 2: - raise ValueError("get_switch_pair_key only works with composite strategy and 2 identifiers") - - values = self.get_identifier_value() - sorted_ids = sorted([str(v) for v in values]) - return f"{sorted_ids[0]}-{sorted_ids[1]}" - - def to_diff_dict(self) -> Dict[str, Any]: - """Export for diff comparison (excludes sensitive fields).""" - return self.model_dump(by_alias=True, exclude_none=True, exclude=set(self.exclude_from_diff)) - - -class NDVpcPairNestedModel(BaseModel): - """ - Base for nested VPC pair models without identifiers. - - Similar to NDNestedModel from base.py. - """ - - model_config = ConfigDict(str_strip_whitespace=True, use_enum_values=True, validate_assignment=True, populate_by_name=True, extra="ignore") - - def to_payload(self) -> Dict[str, Any]: - """Convert model to API payload format.""" - return self.model_dump(by_alias=True, exclude_none=True) - - @classmethod - def from_response(cls, response: Dict[str, Any]) -> Self: - """Create model instance from API response.""" - return cls.model_validate(response) - - -# ============================================================================ -# NESTED MODELS (No Identifiers) -# ============================================================================ - - -class SwitchInfo(NDVpcPairNestedModel): - """Generic switch information for both peers.""" - - switch: str = Field(alias="switch", description="Switch value") - peer_switch: str = Field(alias="peerSwitch", description="Peer switch value") - - -class SwitchIntInfo(NDVpcPairNestedModel): - """Generic switch integer information for both peers.""" - - switch: FlexibleInt = Field(alias="switch", description="Switch value") - peer_switch: FlexibleInt = Field(alias="peerSwitch", description="Peer switch value") - - -class SwitchBoolInfo(NDVpcPairNestedModel): - """Generic switch boolean information for both peers.""" - - switch: FlexibleBool = Field(alias="switch", description="Switch value") - peer_switch: FlexibleBool = Field(alias="peerSwitch", description="Peer switch value") - - -class SyncCounts(NDVpcPairNestedModel): - """Sync status counts.""" - - in_sync: FlexibleInt = Field(default=0, alias="inSync", description="In-sync items") - pending: FlexibleInt = Field(default=0, alias="pending", description="Pending items") - out_of_sync: FlexibleInt = Field(default=0, alias="outOfSync", description="Out-of-sync items") - in_progress: FlexibleInt = Field(default=0, alias="inProgress", description="In-progress items") - - -class AnomaliesCount(NDVpcPairNestedModel): - """Anomaly counts by severity.""" - - critical: FlexibleInt = Field(default=0, alias="critical", description="Critical anomalies") - major: FlexibleInt = Field(default=0, alias="major", description="Major anomalies") - minor: FlexibleInt = Field(default=0, alias="minor", description="Minor anomalies") - warning: FlexibleInt = Field(default=0, alias="warning", description="Warning anomalies") - - -class HealthMetrics(NDVpcPairNestedModel): - """Health metrics for both switches.""" - - switch: str = Field(alias="switch", description="Switch health status") - peer_switch: str = Field(alias="peerSwitch", description="Peer switch health status") - - -class ResourceMetrics(NDVpcPairNestedModel): - """Resource utilization metrics.""" - - switch: FlexibleInt = Field(alias="switch", description="Switch metric value") - peer_switch: FlexibleInt = Field(alias="peerSwitch", description="Peer switch metric value") - - -class InterfaceStatusCounts(NDVpcPairNestedModel): - """Interface status counts.""" - - up: FlexibleInt = Field(alias="up", description="Interfaces in up state") - down: FlexibleInt = Field(alias="down", description="Interfaces in down state") - - -class LogicalInterfaceCounts(NDVpcPairNestedModel): - """Logical interface type counts.""" - - port_channel: FlexibleInt = Field(alias="portChannel", description="Port channel interfaces") - loopback: FlexibleInt = Field(alias="loopback", description="Loopback interfaces") - vpc: FlexibleInt = Field(alias="vPC", description="VPC interfaces") - vlan: FlexibleInt = Field(alias="vlan", description="VLAN interfaces") - nve: FlexibleInt = Field(alias="nve", description="NVE interfaces") - - -class ResponseCounts(NDVpcPairNestedModel): - """Response metadata counts.""" - - total: FlexibleInt = Field(alias="total", description="Total count") - remaining: FlexibleInt = Field(alias="remaining", description="Remaining count") - - -# ============================================================================ -# VPC PAIR DETAILS MODELS (Nested Template Configuration) -# ============================================================================ - - -class VpcPairDetailsDefault(NDVpcPairNestedModel): - """ - Default template VPC pair configuration. - - OpenAPI: vpcPairDetailsDefault - """ - - type: Literal["default"] = Field(default="default", alias="type", description="Template type") - domain_id: Optional[FlexibleInt] = Field(default=None, alias="domainId", description="VPC domain ID") - switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="switchKeepAliveLocalIp", description="Peer-1 keep-alive IP") - peer_switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="peerSwitchKeepAliveLocalIp", description="Peer-2 keep-alive IP") - keep_alive_vrf: Optional[KeepAliveVrfEnum] = Field(default=None, alias="keepAliveVrf", description="Keep-alive VRF") - keep_alive_hold_timeout: Optional[FlexibleInt] = Field(default=3, alias="keepAliveHoldTimeout", description="Keep-alive hold timeout") - enable_mirror_config: Optional[FlexibleBool] = Field(default=False, alias="enableMirrorConfig", description="Enable config mirroring") - is_vpc_plus: Optional[FlexibleBool] = Field(default=False, alias="isVpcPlus", description="VPC+ topology") - fabric_path_switch_id: Optional[FlexibleInt] = Field(default=None, alias="fabricPathSwitchId", description="FabricPath switch ID") - is_vteps: Optional[FlexibleBool] = Field(default=False, alias="isVteps", description="Configure NVE source loopback") - nve_interface: Optional[FlexibleInt] = Field(default=1, alias="nveInterface", description="NVE interface") - switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="switchSourceLoopback", description="Peer-1 source loopback") - peer_switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchSourceLoopback", description="Peer-2 source loopback") - switch_primary_ip: Optional[str] = Field(default=None, alias="switchPrimaryIp", description="Peer-1 primary IP") - peer_switch_primary_ip: Optional[str] = Field(default=None, alias="peerSwitchPrimaryIp", description="Peer-2 primary IP") - loopback_secondary_ip: Optional[str] = Field(default=None, alias="loopbackSecondaryIp", description="Secondary loopback IP") - switch_domain_config: Optional[str] = Field(default=None, alias="switchDomainConfig", description="Peer-1 domain config CLI") - peer_switch_domain_config: Optional[str] = Field(default=None, alias="peerSwitchDomainConfig", description="Peer-2 domain config CLI") - switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="switchPoId", description="Peer-1 port-channel ID") - peer_switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchPoId", description="Peer-2 port-channel ID") - switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="switchMemberInterfaces", description="Peer-1 member interfaces") - peer_switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="peerSwitchMemberInterfaces", description="Peer-2 member interfaces") - po_mode: Optional[str] = Field(default="active", alias="poMode", description="Port-channel mode") - switch_po_description: Optional[str] = Field(default=None, alias="switchPoDescription", description="Peer-1 port-channel description") - peer_switch_po_description: Optional[str] = Field(default=None, alias="peerSwitchPoDescription", description="Peer-2 port-channel description") - admin_state: Optional[FlexibleBool] = Field(default=True, alias="adminState", description="Admin state") - allowed_vlans: Optional[str] = Field(default="all", alias="allowedVlans", description="Allowed VLANs") - switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="switchNativeVlan", description="Peer-1 native VLAN") - peer_switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchNativeVlan", description="Peer-2 native VLAN") - switch_po_config: Optional[str] = Field(default=None, alias="switchPoConfig", description="Peer-1 port-channel freeform config") - peer_switch_po_config: Optional[str] = Field(default=None, alias="peerSwitchPoConfig", description="Peer-2 port-channel freeform config") - fabric_name: Optional[str] = Field(default=None, alias="fabricName", description="Fabric name") - - -class VpcPairDetailsCustom(NDVpcPairNestedModel): - """ - Custom template VPC pair configuration. - - OpenAPI: vpcPairDetailsCustom - """ - - type: Literal["custom"] = Field(default="custom", alias="type", description="Template type") - template_name: str = Field(alias="templateName", description="Name of the custom template") - template_config: Dict[str, Any] = Field(alias="templateConfig", description="Free-form configuration") - - -# ============================================================================ -# CONFIGURATION DOMAIN MODELS -# ============================================================================ - - -class VpcPairBase(NDVpcPairBaseModel): - """ - Base schema for VPC pairing with common properties. - - Identifier: (switch_id, peer_switch_id) - composite - OpenAPI: vpcPairBase - - Note: The nd_vpc_pair module uses a separate VpcPairModel class (not this one) because: - - Module needs use_virtual_peer_link=True as default (this uses False per API spec) - - Module uses NDBaseModel base class for framework integration - - Module needs strict bool types, this uses FlexibleBool for API flexibility - See plugins/modules/nd_vpc_pair.py VpcPairModel for the module-specific implementation. - """ - - # Identifier configuration - identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] - identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" - - # Fields with validation constraints - switch_id: str = Field( - alias="switchId", - description="Switch serial number (Peer-1)", - min_length=3, - max_length=64 - ) - peer_switch_id: str = Field( - alias="peerSwitchId", - description="Peer switch serial number (Peer-2)", - min_length=3, - max_length=64 - ) - use_virtual_peer_link: FlexibleBool = Field(default=False, alias="useVirtualPeerLink", description="Virtual peer link present") - vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( - default=None, discriminator="type", alias="vpcPairDetails", description="VPC pair configuration details" - ) - - @field_validator("switch_id", "peer_switch_id") - @classmethod - def validate_switch_id_format(cls, v: str) -> str: - """ - Validate switch ID is not empty or whitespace. - - Args: - v: Switch ID value - - Returns: - Stripped switch ID - - Raises: - ValueError: If switch ID is empty or whitespace - """ - if not v or not v.strip(): - raise ValueError("Switch ID cannot be empty or whitespace") - return v.strip() - - @model_validator(mode="after") - def validate_different_switches(self) -> Self: - """ - Ensure switch_id and peer_switch_id are different. - - Returns: - Validated model instance - - Raises: - ValueError: If switch_id equals peer_switch_id - """ - if self.switch_id == self.peer_switch_id: - raise ValueError( - f"switch_id and peer_switch_id must be different: {self.switch_id}" - ) - return self - - def to_payload(self) -> Dict[str, Any]: - """Convert to API payload format.""" - return self.model_dump(by_alias=True, exclude_none=True) - - @classmethod - def from_response(cls, response: Dict[str, Any]) -> Self: - """Create instance from API response.""" - return cls.model_validate(response) - - -class VpcPairingRequest(NDVpcPairBaseModel): - """ - Request schema for pairing VPC switches. - - Identifier: (switch_id, peer_switch_id) - composite - OpenAPI: vpcPairingRequest - """ - - # Identifier configuration - identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] - identifier_strategy: ClassVar[Literal["single", "composite", "hierarchical"]] = "composite" - - # Fields with validation constraints - vpc_action: VpcActionEnum = Field(default=VpcActionEnum.PAIR, alias="vpcAction", description="Action to pair") - switch_id: str = Field( - alias="switchId", - description="Switch serial number (Peer-1)", - min_length=3, - max_length=64 - ) - peer_switch_id: str = Field( - alias="peerSwitchId", - description="Peer switch serial number (Peer-2)", - min_length=3, - max_length=64 - ) - use_virtual_peer_link: FlexibleBool = Field(default=False, alias="useVirtualPeerLink", description="Virtual peer link present") - vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( - default=None, discriminator="type", alias="vpcPairDetails", description="VPC pair configuration details" - ) - - @field_validator("switch_id", "peer_switch_id") - @classmethod - def validate_switch_id_format(cls, v: str) -> str: - """ - Validate switch ID is not empty or whitespace. - - Args: - v: Switch ID value - - Returns: - Stripped switch ID - - Raises: - ValueError: If switch ID is empty or whitespace - """ - if not v or not v.strip(): - raise ValueError("Switch ID cannot be empty or whitespace") - return v.strip() - - @model_validator(mode="after") - def validate_different_switches(self) -> Self: - """ - Ensure switch_id and peer_switch_id are different. - - Returns: - Validated model instance - - Raises: - ValueError: If switch_id equals peer_switch_id - """ - if self.switch_id == self.peer_switch_id: - raise ValueError( - f"switch_id and peer_switch_id must be different: {self.switch_id}" - ) - return self - - def to_payload(self) -> Dict[str, Any]: - """Convert to API payload format.""" - return self.model_dump(by_alias=True, exclude_none=True) - - @classmethod - def from_response(cls, response: Dict[str, Any]) -> Self: - """Create instance from API response.""" - return cls.model_validate(response) - - -class VpcUnpairingRequest(NDVpcPairBaseModel): - """ - Request schema for unpairing VPC switches. - - Identifier: N/A (no specific switch IDs in unpair request) - OpenAPI: vpcUnpairingRequest - """ - - # No identifiers for unpair request - identifiers: ClassVar[List[str]] = [] - - # Fields - vpc_action: VpcActionEnum = Field(default=VpcActionEnum.UNPAIR, alias="vpcAction", description="Action to unpair") - - def get_identifier_value(self) -> str: - """Override - unpair doesn't have identifiers.""" - return "unpair" - - def to_payload(self) -> Dict[str, Any]: - """Convert to API payload format.""" - return self.model_dump(by_alias=True, exclude_none=True) - - @classmethod - def from_response(cls, response: Dict[str, Any]) -> Self: - """Create instance from API response.""" - return cls.model_validate(response) - - -# ============================================================================ -# MONITORING DOMAIN MODELS -# ============================================================================ - - -class VpcPairsInfoBase(NDVpcPairNestedModel): - """ - VPC pair information base. - - OpenAPI: vpcPairsInfoBase - """ - - switch_name: SwitchInfo = Field(alias="switchName", description="Switch name") - ip_address: SwitchInfo = Field(alias="ipAddress", description="IP address") - fabric_name: str = Field(alias="fabricName", description="Fabric name") - connectivity_status: SwitchInfo = Field(alias="connectivityStatus", description="Connectivity status") - maintenance_mode: SwitchInfo = Field(alias="maintenanceMode", description="Maintenance mode") - uptime: SwitchInfo = Field(alias="uptime", description="Switch uptime") - switch_id: SwitchInfo = Field(alias="switchId", description="Switch serial number") - model: SwitchInfo = Field(alias="model", description="Switch model") - switch_role: SwitchInfo = Field(alias="switchRole", description="Switch role") - is_consistent: SwitchBoolInfo = Field(alias="isConsistent", description="Consistency status") - domain_id: SwitchIntInfo = Field(alias="domainId", description="Domain ID") - platform_type: SwitchInfo = Field(alias="platformType", description="Platform type") - - -class VpcPairHealthBase(NDVpcPairNestedModel): - """ - VPC pair health information. - - OpenAPI: vpcPairHealthBase - """ - - switch_id: str = Field(alias="switchId", description="Switch serial number") - peer_switch_id: str = Field(alias="peerSwitchId", description="Peer switch serial number") - health: HealthMetrics = Field(alias="health", description="Health status") - cpu: ResourceMetrics = Field(alias="cpu", description="CPU utilization") - memory: ResourceMetrics = Field(alias="memory", description="Memory utilization") - temperature: ResourceMetrics = Field(alias="temperature", description="Temperature in Celsius") - - -class VpcPairsVxlanBase(NDVpcPairNestedModel): - """ - VPC pairs VXLAN details. - - OpenAPI: vpcPairsVxlanBase - """ - - switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") - peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") - routing_loopback: SwitchInfo = Field(alias="routingLoopback", description="Routing loopback") - routing_loopback_status: SwitchInfo = Field(alias="routingLoopbackStatus", description="Routing loopback status") - routing_loopback_primary_ip: SwitchInfo = Field(alias="routingLoopbackPrimaryIp", description="Routing loopback primary IP") - routing_loopback_secondary_ip: Optional[SwitchInfo] = Field(default=None, alias="routingLoopbackSecondaryIp", description="Routing loopback secondary IP") - vtep_loopback: SwitchInfo = Field(alias="vtepLoopback", description="VTEP loopback") - vtep_loopback_status: SwitchInfo = Field(alias="vtepLoopbackStatus", description="VTEP loopback status") - vtep_loopback_primary_ip: SwitchInfo = Field(alias="vtepLoopbackPrimaryIp", description="VTEP loopback primary IP") - vtep_loopback_secondary_ip: Optional[SwitchInfo] = Field(default=None, alias="vtepLoopbackSecondaryIp", description="VTEP loopback secondary IP") - nve_interface: SwitchInfo = Field(alias="nveInterface", description="NVE interface") - nve_status: SwitchInfo = Field(alias="nveStatus", description="NVE status") - multisite_loopback: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopback", description="Multisite loopback") - multisite_loopback_status: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopbackStatus", description="Multisite loopback status") - multisite_loopback_primary_ip: Optional[SwitchInfo] = Field(default=None, alias="multisiteLoopbackPrimaryIp", description="Multisite loopback primary IP") - - -class VpcPairsOverlayBase(NDVpcPairNestedModel): - """ - VPC pairs overlay base. - - OpenAPI: vpcPairsOverlayBase - """ - - network_count: SyncCounts = Field(alias="networkCount", description="Network count") - vrf_count: SyncCounts = Field(alias="vrfCount", description="VRF count") - - -class VpcPairsInventoryBase(NDVpcPairNestedModel): - """ - VPC pair inventory base. - - OpenAPI: vpcPairsInventoryBase - """ - - switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") - peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") - admin_status: InterfaceStatusCounts = Field(alias="adminStatus", description="Admin status") - operational_status: InterfaceStatusCounts = Field(alias="operationalStatus", description="Operational status") - sync_status: Dict[str, FlexibleInt] = Field(alias="syncStatus", description="Sync status") - logical_interfaces: LogicalInterfaceCounts = Field(alias="logicalInterfaces", description="Logical interfaces") - - -class VpcPairsModuleBase(NDVpcPairNestedModel): - """ - VPC pair module base. - - OpenAPI: vpcPairsModuleBase - """ - - switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") - peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") - module_information: Dict[str, str] = Field(default_factory=dict, alias="moduleInformation", description="VPC pair module information") - fex_details: Dict[str, str] = Field(default_factory=dict, alias="fexDetails", description="Fex details name-value pair(s)") - - -class VpcPairAnomaliesBase(NDVpcPairNestedModel): - """ - VPC pair anomalies information. - - OpenAPI: vpcPairAnomaliesBase - """ - - switch_id: str = Field(alias="switchId", description="Peer1 switch serial number") - peer_switch_id: str = Field(alias="peerSwitchId", description="Peer2 switch serial number") - anomalies_count: AnomaliesCount = Field(alias="anomaliesCount", description="Anomaly counts by severity") - - -# ============================================================================ -# CONSISTENCY DOMAIN MODELS -# ============================================================================ - - -class CommonVpcConsistencyParams(NDVpcPairNestedModel): - """ - Common consistency parameters for VPC domain. - - OpenAPI: commonVpcConsistencyParams - """ - - # Basic identifiers - switch_name: str = Field(alias="switchName", description="Switch name") - ip_address: str = Field(alias="ipAddress", description="IP address") - domain_id: FlexibleInt = Field(alias="domainId", description="Domain ID") - - # Port channel info - peer_link_port_channel: FlexibleInt = Field(alias="peerLinkPortChannel", description="Port channel peer link") - port_channel_name: Optional[str] = Field(default=None, alias="portChannelName", description="Port channel name") - description: Optional[str] = Field(default=None, alias="description", description="Port channel description") - - # VPC system parameters - system_mac_address: str = Field(alias="systemMacAddress", description="System MAC address") - system_priority: FlexibleInt = Field(alias="systemPriority", description="System priority") - udp_port: FlexibleInt = Field(alias="udpPort", description="UDP port") - interval: FlexibleInt = Field(alias="interval", description="Interval") - timeout: FlexibleInt = Field(alias="timeout", description="Timeout") - - # Additional fields (simplified - add as needed) - # NOTE: OpenAPI has many more fields - add them as required - - -class VpcPairConsistency(NDVpcPairNestedModel): - """ - VPC pair consistency check results. - - OpenAPI: vpcPairConsistency - """ - - switch_id: str = Field(alias="switchId", description="Primary switch serial number") - peer_switch_id: str = Field(alias="peerSwitchId", description="Secondary switch serial number") - type2_consistency: FlexibleBool = Field(alias="type2Consistency", description="Type-2 consistency status") - type2_consistency_reason: str = Field(alias="type2ConsistencyReason", description="Consistency reason") - timestamp: Optional[FlexibleInt] = Field(default=None, alias="timestamp", description="Timestamp of check") - primary_parameters: CommonVpcConsistencyParams = Field(alias="primaryParameters", description="Primary switch consistency parameters") - secondary_parameters: CommonVpcConsistencyParams = Field(alias="secondaryParameters", description="Secondary switch consistency parameters") - is_consistent: Optional[FlexibleBool] = Field(default=None, alias="isConsistent", description="Overall consistency") - is_discovered: Optional[FlexibleBool] = Field(default=None, alias="isDiscovered", description="Whether pair is discovered") - - -# ============================================================================ -# VALIDATION DOMAIN MODELS -# ============================================================================ - - -class VpcPairRecommendation(NDVpcPairNestedModel): - """ - Recommendation information for a switch. - - OpenAPI: vpcPairRecommendation - """ - - hostname: str = Field(alias="hostname", description="Logical name of switch") - ip_address: str = Field(alias="ipAddress", description="IP address of switch") - switch_id: str = Field(alias="switchId", description="Serial number of the switch") - software_version: str = Field(alias="softwareVersion", description="NXOS version of switch") - fabric_name: str = Field(alias="fabricName", description="Fabric name") - recommendation_reason: str = Field(alias="recommendationReason", description="Recommendation message") - block_selection: FlexibleBool = Field(alias="blockSelection", description="Block selection") - platform_type: str = Field(alias="platformType", description="Platform type of switch") - use_virtual_peer_link: FlexibleBool = Field(alias="useVirtualPeerLink", description="Virtual peer link available") - is_current_peer: FlexibleBool = Field(alias="isCurrentPeer", description="Device is current peer") - is_recommended: FlexibleBool = Field(alias="isRecommended", description="Recommended device") - - -# ============================================================================ -# INVENTORY DOMAIN MODELS -# ============================================================================ - - -class VpcPairBaseSwitchDetails(NDVpcPairNestedModel): - """ - Base fields for VPC pair records. - - OpenAPI: vpcPairBaseSwitchDetails - """ - - domain_id: FlexibleInt = Field(alias="domainId", description="Domain ID of the VPC") - switch_id: str = Field(alias="switchId", description="Serial number of the switch") - switch_name: str = Field(alias="switchName", description="Hostname of the switch") - peer_switch_id: str = Field(alias="peerSwitchId", description="Serial number of the peer switch") - peer_switch_name: str = Field(alias="peerSwitchName", description="Hostname of the peer switch") - - -class VpcPairIntended(VpcPairBaseSwitchDetails): - """ - Intended VPC pair record. - - OpenAPI: vpcPairIntended - """ - - type: Literal["intendedPairs"] = Field(default="intendedPairs", alias="type", description="Type identifier") - - -class VpcPairDiscovered(VpcPairBaseSwitchDetails): - """ - Discovered VPC pair record. - - OpenAPI: vpcPairDiscovered - """ - - type: Literal["discoveredPairs"] = Field(default="discoveredPairs", alias="type", description="Type identifier") - switch_vpc_role: VpcRoleEnum = Field(alias="switchVpcRole", description="VPC role of the switch") - peer_switch_vpc_role: VpcRoleEnum = Field(alias="peerSwitchVpcRole", description="VPC role of the peer switch") - intended_peer_name: str = Field(alias="intendedPeerName", description="Name of the intended peer switch") - description: str = Field(alias="description", description="Description of any discrepancies or issues") - - -class Metadata(NDVpcPairNestedModel): - """ - Metadata for pagination and links. - - OpenAPI: Metadata - """ - - counts: ResponseCounts = Field(alias="counts", description="Count information") - links: Optional[Dict[str, str]] = Field(default=None, alias="links", description="Pagination links (next, previous)") - - -class VpcPairsResponse(NDVpcPairNestedModel): - """ - Response schema for listing VPC pairs. - - OpenAPI: vpcPairsResponse - """ - - vpc_pairs: List[Union[VpcPairIntended, VpcPairDiscovered]] = Field(alias="vpcPairs", description="List of VPC pairs") - meta: Metadata = Field(alias="meta", description="Response metadata") - - -# ============================================================================ -# WRAPPER MODELS WITH COMPONENT TYPE -# ============================================================================ - - -class VpcPairsInfo(NDVpcPairNestedModel): - """VPC pairs information wrapper.""" - - component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.PAIRS_INFO, alias="componentType", description="Type of the component") - info: VpcPairsInfoBase = Field(alias="info", description="VPC pair info") - - -class VpcPairHealth(NDVpcPairNestedModel): - """VPC pair health wrapper.""" - - component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.HEALTH, alias="componentType", description="Type of the component") - health: VpcPairHealthBase = Field(alias="health", description="Health details") - - -class VpcPairsModule(NDVpcPairNestedModel): - """VPC pairs module wrapper.""" - - component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.MODULE, alias="componentType", description="Type of the component") - module: VpcPairsModuleBase = Field(alias="module", description="Module details") - - -class VpcPairAnomalies(NDVpcPairNestedModel): - """VPC pair anomalies wrapper.""" - - component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.ANOMALIES, alias="componentType", description="Type of the component") - anomalies: VpcPairAnomaliesBase = Field(alias="anomalies", description="Anomalies details") - - -class VpcPairsVxlan(NDVpcPairNestedModel): - """VPC pairs VXLAN wrapper.""" - - component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.VXLAN, alias="componentType", description="Type of the component") - vxlan: VpcPairsVxlanBase = Field(alias="vxlan", description="VXLAN details") - - -class VpcPairsOverlay(NDVpcPairNestedModel): - """VPC overlay details wrapper.""" - - component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.OVERLAY, alias="componentType", description="Type of the component") - overlay: VpcPairsOverlayBase = Field(alias="overlay", description="Overlay details") - - -class VpcPairsInventory(NDVpcPairNestedModel): - """VPC pairs inventory details wrapper.""" - - component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.INVENTORY, alias="componentType", description="Type of the component") - inventory: VpcPairsInventoryBase = Field(alias="inventory", description="Inventory details") - - -class FullOverview(NDVpcPairNestedModel): - """Full VPC overview response.""" - - component_type: ComponentTypeOverviewEnum = Field(default=ComponentTypeOverviewEnum.FULL, alias="componentType", description="Type of the component") - anomalies: VpcPairAnomaliesBase = Field(alias="anomalies", description="VPC pair anomalies") - health: VpcPairHealthBase = Field(alias="health", description="VPC pair health") - module: VpcPairsModuleBase = Field(alias="module", description="VPC pair module") - vxlan: VpcPairsVxlanBase = Field(alias="vxlan", description="VPC pair VXLAN") - overlay: VpcPairsOverlayBase = Field(alias="overlay", description="VPC pair overlay") - pairs_info: VpcPairsInfoBase = Field(alias="pairsInfo", description="VPC pair info") - inventory: VpcPairsInventoryBase = Field(alias="inventory", description="VPC pair inventory") - - -# ============================================================================ -# BACKWARD COMPATIBILITY CONTAINER (NdVpcPairSchema) -# ============================================================================ - - -class NdVpcPairSchema: - """ - Backward compatibility container for all VPC pair schemas. - - This provides a namespace similar to the old structure where models - were nested inside a container class. Allows imports like: - - from model_playbook_vpc_pair_nested import NdVpcPairSchema - vpc_pair = NdVpcPairSchema.VpcPairBase(**data) - """ - - # Base classes - VpcPairBaseModel = NDVpcPairBaseModel - VpcPairNestedModel = NDVpcPairNestedModel - - # Enumerations (these are class variable type hints, not assignments) - # VpcRole = VpcRoleEnum # Commented out - not needed - # TemplateType = VpcPairTypeEnum # Commented out - not needed - # KeepAliveVrf = KeepAliveVrfEnum # Commented out - not needed - # VpcAction = VpcActionEnum # Commented out - not needed - # ComponentType = ComponentTypeOverviewEnum # Commented out - not needed - - # Nested helper models - SwitchInfo = SwitchInfo - SwitchIntInfo = SwitchIntInfo - SwitchBoolInfo = SwitchBoolInfo - SyncCounts = SyncCounts - AnomaliesCount = AnomaliesCount - HealthMetrics = HealthMetrics - ResourceMetrics = ResourceMetrics - InterfaceStatusCounts = InterfaceStatusCounts - LogicalInterfaceCounts = LogicalInterfaceCounts - ResponseCounts = ResponseCounts - - # VPC pair details (template configuration) - VpcPairDetailsDefault = VpcPairDetailsDefault - VpcPairDetailsCustom = VpcPairDetailsCustom - - # Configuration domain - VpcPairBase = VpcPairBase - VpcPairingRequest = VpcPairingRequest - VpcUnpairingRequest = VpcUnpairingRequest - - # Monitoring domain - VpcPairsInfoBase = VpcPairsInfoBase - VpcPairHealthBase = VpcPairHealthBase - VpcPairsVxlanBase = VpcPairsVxlanBase - VpcPairsOverlayBase = VpcPairsOverlayBase - VpcPairsInventoryBase = VpcPairsInventoryBase - VpcPairsModuleBase = VpcPairsModuleBase - VpcPairAnomaliesBase = VpcPairAnomaliesBase - - # Monitoring domain wrappers - VpcPairsInfo = VpcPairsInfo - VpcPairHealth = VpcPairHealth - VpcPairsModule = VpcPairsModule - VpcPairAnomalies = VpcPairAnomalies - VpcPairsVxlan = VpcPairsVxlan - VpcPairsOverlay = VpcPairsOverlay - VpcPairsInventory = VpcPairsInventory - FullOverview = FullOverview - - # Consistency domain - CommonVpcConsistencyParams = CommonVpcConsistencyParams - VpcPairConsistency = VpcPairConsistency - - # Validation domain - VpcPairRecommendation = VpcPairRecommendation - - # Inventory domain - VpcPairBaseSwitchDetails = VpcPairBaseSwitchDetails - VpcPairIntended = VpcPairIntended - VpcPairDiscovered = VpcPairDiscovered - Metadata = Metadata - VpcPairsResponse = VpcPairsResponse +from ansible_collections.cisco.nd.plugins.models.nested import NDVpcPairNestedModel # noqa: F401 +from ansible_collections.cisco.nd.plugins.models.vpc_pair_models import * # noqa: F401,F403 diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py index 4ae341af..fd206ac2 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py @@ -25,8 +25,10 @@ from typing import TYPE_CHECKING, Literal, Optional -from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.base_paths import VpcPairBasePath -from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.mixins import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.base_paths import ( + VpcPairBasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.mixins import ( ComponentTypeMixin, FabricNameMixin, FilterMixin, diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py index ac8caa83..2e9fcec2 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py @@ -135,8 +135,26 @@ class VpcPairOrchestrator: model_class = None - def __init__(self, module: AnsibleModule): + def __init__( + self, + module: Optional[AnsibleModule] = None, + sender: Optional[Any] = None, + **kwargs, + ): + _ = kwargs + # Compatibility with both NDStateMachine variants: + # - legacy: model_orchestrator(module=...) + # - Current: model_orchestrator(sender=nd_module) + if module is None and sender is not None: + module = getattr(sender, "module", None) + if module is None: + raise ValueError( + "VpcPairOrchestrator requires either module=AnsibleModule " + "or sender=." + ) + self.module = module + self.sender = sender self.state_machine = None self.model_class = getattr(self.module, "_vpc_pair_model_class", None) @@ -198,7 +216,10 @@ def manage_state( override_exceptions = override_exceptions or [] self.state = state - self.params["state"] = state + if hasattr(self, "params") and isinstance(getattr(self, "params"), dict): + self.params["state"] = state + else: + self.module.params["state"] = state self.ansible_config = new_configs or [] try: diff --git a/plugins/module_utils/manage/__init__.py b/plugins/module_utils/manage/__init__.py new file mode 100644 index 00000000..ec71c82f --- /dev/null +++ b/plugins/module_utils/manage/__init__.py @@ -0,0 +1,3 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type diff --git a/plugins/module_utils/manage/vpc_pair/__init__.py b/plugins/module_utils/manage/vpc_pair/__init__.py new file mode 100644 index 00000000..ec71c82f --- /dev/null +++ b/plugins/module_utils/manage/vpc_pair/__init__.py @@ -0,0 +1,3 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_details.py b/plugins/module_utils/manage/vpc_pair/vpc_pair_details.py new file mode 100644 index 00000000..dd14069a --- /dev/null +++ b/plugins/module_utils/manage/vpc_pair/vpc_pair_details.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Sivakami Sivaraman + +# 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 + +""" +Compatibility bridge for vPC playbook detail models. + +Primary source of truth lives in `plugins/models/model_playbook_vpc_pair.py`. +This module exists only for Ansible module runtime compatibility when +`plugins/models` is not available in the AnsiballZ payload. +""" + +from typing import Any, Dict, List, Optional, Literal, Annotated + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, +) + + +try: + from ansible_collections.cisco.nd.plugins.models.model_playbook_vpc_pair import ( # noqa: F401 + VpcPairDetailsDefault, + VpcPairDetailsCustom, + ) +except Exception: + try: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( # noqa: F401 + KeepAliveVrfEnum, + ) + except Exception: + from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.enums import ( # noqa: F401 + KeepAliveVrfEnum, + ) + + def coerce_str_to_int(data): + if data is None: + return None + if isinstance(data, str): + if data.strip() and data.lstrip("-").isdigit(): + return int(data) + raise ValueError(f"Cannot convert '{data}' to int") + return int(data) + + def coerce_to_bool(data): + if data is None: + return None + if isinstance(data, str): + return data.lower() in ("true", "1", "yes", "on") + return bool(data) + + def coerce_list_of_str(data): + if data is None: + return None + if isinstance(data, str): + return [item.strip() for item in data.split(",") if item.strip()] + if isinstance(data, list): + return [str(item) for item in data] + return data + + FlexibleInt = Annotated[int, BeforeValidator(coerce_str_to_int)] + FlexibleBool = Annotated[bool, BeforeValidator(coerce_to_bool)] + FlexibleListStr = Annotated[List[str], BeforeValidator(coerce_list_of_str)] + + class _VpcPairDetailsBaseModel(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + extra="ignore", + ) + + class VpcPairDetailsDefault(_VpcPairDetailsBaseModel): + """Default template vPC pair configuration.""" + + type: Literal["default"] = Field(default="default", alias="type", description="Template type") + domain_id: Optional[FlexibleInt] = Field(default=None, alias="domainId", description="VPC domain ID") + switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="switchKeepAliveLocalIp", description="Peer-1 keep-alive IP") + peer_switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="peerSwitchKeepAliveLocalIp", description="Peer-2 keep-alive IP") + keep_alive_vrf: Optional[KeepAliveVrfEnum] = Field(default=None, alias="keepAliveVrf", description="Keep-alive VRF") + keep_alive_hold_timeout: Optional[FlexibleInt] = Field(default=3, alias="keepAliveHoldTimeout", description="Keep-alive hold timeout") + enable_mirror_config: Optional[FlexibleBool] = Field(default=False, alias="enableMirrorConfig", description="Enable config mirroring") + is_vpc_plus: Optional[FlexibleBool] = Field(default=False, alias="isVpcPlus", description="VPC+ topology") + fabric_path_switch_id: Optional[FlexibleInt] = Field(default=None, alias="fabricPathSwitchId", description="FabricPath switch ID") + is_vteps: Optional[FlexibleBool] = Field(default=False, alias="isVteps", description="Configure NVE source loopback") + nve_interface: Optional[FlexibleInt] = Field(default=1, alias="nveInterface", description="NVE interface") + switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="switchSourceLoopback", description="Peer-1 source loopback") + peer_switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchSourceLoopback", description="Peer-2 source loopback") + switch_primary_ip: Optional[str] = Field(default=None, alias="switchPrimaryIp", description="Peer-1 primary IP") + peer_switch_primary_ip: Optional[str] = Field(default=None, alias="peerSwitchPrimaryIp", description="Peer-2 primary IP") + loopback_secondary_ip: Optional[str] = Field(default=None, alias="loopbackSecondaryIp", description="Secondary loopback IP") + switch_domain_config: Optional[str] = Field(default=None, alias="switchDomainConfig", description="Peer-1 domain config CLI") + peer_switch_domain_config: Optional[str] = Field(default=None, alias="peerSwitchDomainConfig", description="Peer-2 domain config CLI") + switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="switchPoId", description="Peer-1 port-channel ID") + peer_switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchPoId", description="Peer-2 port-channel ID") + switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="switchMemberInterfaces", description="Peer-1 member interfaces") + peer_switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="peerSwitchMemberInterfaces", description="Peer-2 member interfaces") + po_mode: Optional[str] = Field(default="active", alias="poMode", description="Port-channel mode") + switch_po_description: Optional[str] = Field(default=None, alias="switchPoDescription", description="Peer-1 port-channel description") + peer_switch_po_description: Optional[str] = Field(default=None, alias="peerSwitchPoDescription", description="Peer-2 port-channel description") + admin_state: Optional[FlexibleBool] = Field(default=True, alias="adminState", description="Admin state") + allowed_vlans: Optional[str] = Field(default="all", alias="allowedVlans", description="Allowed VLANs") + switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="switchNativeVlan", description="Peer-1 native VLAN") + peer_switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchNativeVlan", description="Peer-2 native VLAN") + switch_po_config: Optional[str] = Field(default=None, alias="switchPoConfig", description="Peer-1 port-channel freeform config") + peer_switch_po_config: Optional[str] = Field(default=None, alias="peerSwitchPoConfig", description="Peer-2 port-channel freeform config") + fabric_name: Optional[str] = Field(default=None, alias="fabricName", description="Fabric name") + + class VpcPairDetailsCustom(_VpcPairDetailsBaseModel): + """Custom template vPC pair configuration.""" + + type: Literal["custom"] = Field(default="custom", alias="type", description="Template type") + template_name: str = Field(alias="templateName", description="Name of the custom template") + template_config: Dict[str, Any] = Field(alias="templateConfig", description="Free-form configuration") diff --git a/plugins/modules/nd_vpc_pair.py b/plugins/modules/nd_vpc_pair.py index 9627724c..f484ad44 100644 --- a/plugins/modules/nd_vpc_pair.py +++ b/plugins/modules/nd_vpc_pair.py @@ -275,12 +275,19 @@ from typing import Any, ClassVar, Dict, List, Literal, Optional, Union from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging # Service layer imports -from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_resources import ( - VpcPairResourceService, - VpcPairResourceError, -) +try: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_resources import ( + VpcPairResourceService, + VpcPairResourceError, + ) +except Exception: + from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_resources import ( + VpcPairResourceService, + VpcPairResourceError, + ) # Static imports so Ansible's AnsiballZ packager includes these files in the # module zip. Keep them optional when framework files are intentionally absent. @@ -303,21 +310,41 @@ # Enum imports from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair import ( - ComponentTypeSupportEnum, - VpcActionEnum, - VpcFieldNames, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair import ( - EpVpcPairConsistencyGet, - EpVpcPairGet, - EpVpcPairPut, - EpVpcPairOverviewGet, - EpVpcPairRecommendationGet, - EpVpcPairSupportGet, - EpVpcPairsListGet, - VpcPairBasePath, -) +try: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + ComponentTypeSupportEnum, + VpcActionEnum, + VpcFieldNames, + ) +except Exception: + from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair import ( + ComponentTypeSupportEnum, + VpcActionEnum, + VpcFieldNames, + ) + +try: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_endpoints import ( + EpVpcPairConsistencyGet, + EpVpcPairGet, + EpVpcPairPut, + EpVpcPairOverviewGet, + EpVpcPairRecommendationGet, + EpVpcPairSupportGet, + EpVpcPairsListGet, + VpcPairBasePath, + ) +except Exception: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair import ( + EpVpcPairConsistencyGet, + EpVpcPairGet, + EpVpcPairPut, + EpVpcPairOverviewGet, + EpVpcPairRecommendationGet, + EpVpcPairSupportGet, + EpVpcPairsListGet, + VpcPairBasePath, + ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( CompositeQueryParams, EndpointQueryParams, @@ -337,10 +364,16 @@ from pydantic import Field, field_validator, model_validator # VPC Pair schema imports (for vpc_pair_details support) -from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.model_playbook_vpc_pair import ( - VpcPairDetailsDefault, - VpcPairDetailsCustom, -) +try: + from ansible_collections.cisco.nd.plugins.models.model_playbook_vpc_pair import ( + VpcPairDetailsDefault, + VpcPairDetailsCustom, + ) +except Exception: + from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_details import ( + VpcPairDetailsDefault, + VpcPairDetailsCustom, + ) # DeepDiff for intelligent change detection try: @@ -734,6 +767,14 @@ def to_payload(self) -> Dict[str, Any]: """ return self.model_dump(by_alias=True, exclude_none=True) + def get_identifier_value(self): + """ + Return a stable composite identifier for VPC pair operations. + + Sort switch IDs to treat (A,B) and (B,A) as the same logical pair. + """ + return tuple(sorted([self.switch_id, self.peer_switch_id])) + def to_config(self, **kwargs) -> Dict[str, Any]: """ Convert to Ansible config shape with snake_case field names. @@ -1647,100 +1688,185 @@ def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pai # ===== Custom Action Functions (used by VpcPairResourceService via orchestrator) ===== -def custom_vpc_query_all(nrm) -> List[Dict]: +def _filter_vpc_pairs_by_requested_config( + pairs: List[Dict[str, Any]], + config: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: """ - Custom query function for VPC pairs using RestSend with full state tracking. + Filter queried VPC pairs by explicit pair keys provided in gathered config. - - Validates fabric and queries switch inventory (UpdateInventory) - - Tracks 3-state: have, pending_create, pending_delete (GetHave) - - Queries recommendation API for virtual peer link details - - Falls back to direct VPC query if recommendation fails - - Builds IP-to-SN mapping from switch inventory - - Stores fabric switches for validation + If gathered config is empty or does not contain complete switch pairs, return + the unfiltered pair list. + """ + if not pairs or not config: + return list(pairs or []) - Args: - nrm: NDStateMachine instance + requested_pair_keys = set() + for item in config: + switch_id = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if switch_id and peer_switch_id: + requested_pair_keys.add(tuple(sorted([switch_id, peer_switch_id]))) - Returns: - List of VPC pair dictionaries from API (have state) + if not requested_pair_keys: + return list(pairs) - Raises: - ValueError: If fabric_name is not configured - NDModuleError: If critical queries fail + filtered_pairs = [] + for item in pairs: + switch_id = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if switch_id and peer_switch_id: + pair_key = tuple(sorted([switch_id, peer_switch_id])) + if pair_key in requested_pair_keys: + filtered_pairs.append(item) + + return filtered_pairs + + +def custom_vpc_query_all(nrm) -> List[Dict]: + """ + Query existing VPC pairs with state-aware enrichment. + + Flow: + - Base query from /vpcPairs list (always attempted first) + - gathered/deleted: use lightweight list-only data when available + - merged/replaced/overridden: enrich with switch inventory and recommendation + APIs to build have/pending_create/pending_delete sets """ fabric_name = nrm.module.params.get("fabric_name") - # Fabric validation (from UpdateInventory.__init__) if not fabric_name or not isinstance(fabric_name, str) or not fabric_name.strip(): raise ValueError(f"fabric_name must be a non-empty string. Got: {fabric_name!r}") + state = nrm.module.params.get("state", "merged") + if state == "gathered": + config = nrm.module.params.get("_gather_filter_config") or [] + else: + config = nrm.module.params.get("config") or [] + # Initialize RestSend via NDModuleV2 nd_v2 = NDModuleV2(nrm.module) + def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + nrm.module.params["_fabric_switches"] = [] + nrm.module.params["_fabric_switches_count"] = 0 + nrm.module.params["_ip_to_sn_mapping"] = {} + nrm.module.params["_have"] = lightweight_have + nrm.module.params["_pending_create"] = [] + nrm.module.params["_pending_delete"] = [] + return lightweight_have + try: - # Step 1: Query and validate fabric switches (UpdateInventory.refresh()) + # Step 1: Base query from list endpoint (/vpcPairs) + have = [] + list_query_succeeded = False + try: + list_path = VpcPairEndpoints.vpc_pairs_list(fabric_name) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = nrm.module.params.get("query_timeout", 10) + try: + vpc_pairs_response = nd_v2.request(list_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + have.extend(_extract_vpc_pairs_from_list_response(vpc_pairs_response)) + list_query_succeeded = True + except Exception as list_error: + nrm.module.warn( + f"VPC pairs list query failed for fabric {fabric_name}: " + f"{str(list_error).splitlines()[0]}." + ) + + # Lightweight path for read-only and delete workflows. + # Keep heavy discovery/enrichment only for write states. + if state in ("deleted", "gathered"): + if list_query_succeeded: + if state == "gathered": + have = _filter_vpc_pairs_by_requested_config(have, config) + return _set_lightweight_context(have) + + nrm.module.warn( + "Skipping switch-level discovery for read-only/delete workflow because " + "the vPC list endpoint is unavailable." + ) + + if state == "gathered": + return _set_lightweight_context([]) + + # Preserve explicit delete intent without full-fabric discovery. + # This keeps delete deterministic and avoids expensive inventory calls. + fallback_have = [] + for item in config: + switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id_val or not peer_switch_id_val: + continue + + use_vpl_val = item.get("use_virtual_peer_link") + if use_vpl_val is None: + use_vpl_val = item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + fallback_have.append( + { + VpcFieldNames.SWITCH_ID: switch_id_val, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id_val, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl_val, + } + ) + + if fallback_have: + nrm.module.warn( + "Using requested delete config as fallback existing set because " + "vPC list query failed." + ) + return _set_lightweight_context(fallback_have) + + if config: + nrm.module.warn( + "Delete config did not contain complete vPC pairs. " + "No delete intents can be built from list-query fallback." + ) + return _set_lightweight_context([]) + + nrm.module.warn( + "Delete-all requested with no explicit pairs and unavailable list endpoint. " + "Falling back to switch-level discovery." + ) + + # Step 2 (write-state enrichment): Query and validate fabric switches. fabric_switches = _validate_fabric_switches(nd_v2, fabric_name) if not fabric_switches: nrm.module.warn(f"No switches found in fabric {fabric_name}") - nrm.module.params["_fabric_switches"] = [] # Use list for JSON serialization + nrm.module.params["_fabric_switches"] = [] nrm.module.params["_fabric_switches_count"] = 0 nrm.module.params["_have"] = [] nrm.module.params["_pending_create"] = [] nrm.module.params["_pending_delete"] = [] return [] - # Memory optimization: Convert to list immediately to avoid keeping full dict in memory - # Keep only switch IDs for validation (not full switch objects) - # Use list (not set) for JSON serialization compatibility + # Keep only switch IDs for validation and serialize safely in module params. fabric_switches_list = list(fabric_switches.keys()) nrm.module.params["_fabric_switches"] = fabric_switches_list nrm.module.params["_fabric_switches_count"] = len(fabric_switches) - # Build IP-to-SN mapping (extract before dict is discarded) + # Build IP-to-SN mapping (extract before dict is discarded). ip_to_sn = { sw.get(VpcFieldNames.FABRIC_MGMT_IP): sw.get(VpcFieldNames.SERIAL_NUMBER) for sw in fabric_switches.values() if VpcFieldNames.FABRIC_MGMT_IP in sw } nrm.module.params["_ip_to_sn_mapping"] = ip_to_sn - - # Step 2: Seed existing VPC pairs from list endpoint (/vpcPairs) - have = [] - try: - list_path = VpcPairEndpoints.vpc_pairs_list(fabric_name) - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = nrm.module.params.get("query_timeout", 10) - try: - vpc_pairs_response = nd_v2.request(list_path, HttpVerbEnum.GET) - finally: - rest_send.restore_settings() - have.extend(_extract_vpc_pairs_from_list_response(vpc_pairs_response)) - except Exception as list_error: - nrm.module.warn( - f"VPC pairs list query failed for fabric {fabric_name}: " - f"{str(list_error).splitlines()[0]}. Continuing with switch-level queries." - ) - # Step 3: Track 3-state VPC pairs (GetHave.refresh()) + # Step 3: Track 3-state VPC pairs (have/pending_create/pending_delete). pending_create = [] pending_delete = [] processed_switches = set() - # Build set of switch IDs from user config to limit recommendation queries. - # For gathered state, main() stores filters in _gather_filter_config and clears - # config before framework initialization to guarantee read-only behavior. - state = nrm.module.params.get("state", "merged") - if state == "gathered": - config = nrm.module.params.get("_gather_filter_config") or [] - else: - config = nrm.module.params.get("config") or [] desired_pairs = {} config_switch_ids = set() for item in config: - # Note: config items have been normalized to snake_case (switch_id, peer_switch_id) - # not the original Ansible input names (peer1_switch_id, peer2_switch_id) + # Config items are normalized to snake_case in main(). switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) @@ -1764,8 +1890,7 @@ def custom_vpc_query_all(nrm) -> List[Dict]: processed_switches.add(switch_id) processed_switches.add(peer_switch_id) - # For configured pairs, prefer direct vPC query as the source of truth. - # Recommendation payloads can be stale for useVirtualPeerLink. + # For configured pairs, prefer direct vPC query as source of truth. try: vpc_pair_path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) rest_send = nd_v2._get_rest_send() @@ -1800,9 +1925,8 @@ def custom_vpc_query_all(nrm) -> List[Dict]: if desired_use_vpl is None: desired_use_vpl = desired_item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) - # Narrow override: only trust direct payload for write states when - # it matches desired pair intent. This preserves idempotence without - # masking true post-delete stale data during gathered/deleted flows. + # Narrow override: trust direct payload only for write states + # when it matches desired pair intent. if state in ("merged", "replaced", "overridden") and desired_item is not None: if desired_use_vpl is None or bool(desired_use_vpl) == bool(use_vpl): nrm.module.warn( @@ -1845,7 +1969,7 @@ def custom_vpc_query_all(nrm) -> List[Dict]: VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, }) else: - # VPC configured but query failed - mark as pending delete + # VPC configured but query failed - mark as pending delete. pending_delete.append({ VpcFieldNames.SWITCH_ID: switch_id, VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, @@ -1853,7 +1977,6 @@ def custom_vpc_query_all(nrm) -> List[Dict]: }) elif not config_switch_ids or switch_id in config_switch_ids: # For unconfigured switches, prefer direct vPC pair query first. - # Recommendation endpoints can lag and may return stale useVirtualPeerLink. try: vpc_pair_path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) rest_send = nd_v2._get_rest_send() @@ -1928,8 +2051,8 @@ def custom_vpc_query_all(nrm) -> List[Dict]: VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, }) - - # Step 4: Store all states for use in create/update/delete + + # Step 4: Store all states for use in create/update/delete. nrm.module.params["_have"] = have nrm.module.params["_pending_create"] = pending_create nrm.module.params["_pending_delete"] = pending_delete @@ -1956,16 +2079,12 @@ def custom_vpc_query_all(nrm) -> List[Dict]: pair_by_key.pop(key, None) existing_pairs = list(pair_by_key.values()) - - # Note: Memory optimization already applied at line 1219-1220 - # fabric_switches dict was converted to set immediately after query return existing_pairs except NDModuleError as error: error_dict = error.to_dict() - # Preserve original API error message with different key to avoid conflict - if 'msg' in error_dict: - error_dict['api_error_msg'] = error_dict.pop('msg') + if "msg" in error_dict: + error_dict["api_error_msg"] = error_dict.pop("msg") _raise_vpc_error( msg=f"Failed to query VPC pairs: {error.msg}", fabric=fabric_name, @@ -2732,6 +2851,7 @@ def main(): ) module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + setup_logging(module) # Module-level validations if sys.version_info < (3, 9): @@ -2744,7 +2864,7 @@ def main(): ) # State-specific parameter validations - state = module.params.get("state") + state = module.params.get("state", "merged") deploy = module.params.get("deploy") dry_run = module.params.get("dry_run") @@ -2762,7 +2882,6 @@ def main(): # - state=deleted # - state=overridden with empty config (interpreted as delete-all) force = module.params.get("force", False) - state = module.params.get("state", "merged") user_config = module.params.get("config") or [] force_applicable = state == "deleted" or ( state == "overridden" and len(user_config) == 0 From 980ff2e109ab9825cb61e9cf15a49a5eb63b0c18 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 11 Mar 2026 10:37:29 +0530 Subject: [PATCH 12/39] Folder/File restructure --- plugins/models/vpc_pair_models.py | 41 ++++++------------- .../endpoints/v1/manage_vpc_pair/__init__.py | 2 +- .../model_playbook_vpc_pair.py | 30 -------------- .../v1/manage_vpc_pair/vpc_pair_schemas.py} | 33 +++++++-------- plugins/module_utils/manage/__init__.py | 3 -- .../module_utils/manage/vpc_pair/__init__.py | 3 -- plugins/modules/nd_vpc_pair.py | 33 +++++---------- 7 files changed, 41 insertions(+), 104 deletions(-) delete mode 100644 plugins/module_utils/endpoints/v1/manage_vpc_pair/model_playbook_vpc_pair.py rename plugins/module_utils/{manage/vpc_pair/vpc_pair_details.py => endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py} (88%) delete mode 100644 plugins/module_utils/manage/__init__.py delete mode 100644 plugins/module_utils/manage/vpc_pair/__init__.py diff --git a/plugins/models/vpc_pair_models.py b/plugins/models/vpc_pair_models.py index 58735fce..35845acf 100644 --- a/plugins/models/vpc_pair_models.py +++ b/plugins/models/vpc_pair_models.py @@ -36,34 +36,19 @@ from ansible_collections.cisco.nd.plugins.models.nested import NDVpcPairNestedModel # Import enums from centralized location -try: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( - VpcActionEnum, - VpcPairTypeEnum, - KeepAliveVrfEnum, - PoModeEnum, - PortChannelDuplexEnum, - VpcRoleEnum, - MaintenanceModeEnum, - ComponentTypeOverviewEnum, - ComponentTypeSupportEnum, - VpcPairViewEnum, - VpcFieldNames, - ) -except Exception: - from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.enums import ( - VpcActionEnum, - VpcPairTypeEnum, - KeepAliveVrfEnum, - PoModeEnum, - PortChannelDuplexEnum, - VpcRoleEnum, - MaintenanceModeEnum, - ComponentTypeOverviewEnum, - ComponentTypeSupportEnum, - VpcPairViewEnum, - VpcFieldNames, - ) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + VpcActionEnum, + VpcPairTypeEnum, + KeepAliveVrfEnum, + PoModeEnum, + PortChannelDuplexEnum, + VpcRoleEnum, + MaintenanceModeEnum, + ComponentTypeOverviewEnum, + ComponentTypeSupportEnum, + VpcPairViewEnum, + VpcFieldNames, +) # ============================================================================ # NESTED MODELS (No Identifiers) diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py index 73e1d492..4ed28125 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py @@ -74,7 +74,7 @@ EpVpcPairConsistencyGet, EpVpcPairsListGet, ) - from ansible_collections.cisco.nd.plugins.models.model_playbook_vpc_pair import ( + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_schemas import ( VpcPairDetailsDefault, VpcPairDetailsCustom, VpcPairingRequest, diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/model_playbook_vpc_pair.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/model_playbook_vpc_pair.py deleted file mode 100644 index 5c95eb83..00000000 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/model_playbook_vpc_pair.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2025, Sivakami Sivaraman - -# 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 - -""" -Backward-compatible export surface for vPC pair playbook models. - -The implementation is split following the PR172 style: -- base.py: coercion helpers and base model -- nested.py: nested-model base class -- vpc_pair_models.py: vPC-pair-specific schemas -""" - -from ansible_collections.cisco.nd.plugins.models.base import ( # noqa: F401 - coerce_str_to_int, - coerce_to_bool, - coerce_list_of_str, - FlexibleInt, - FlexibleBool, - FlexibleListStr, - NDVpcPairBaseModel, -) -from ansible_collections.cisco.nd.plugins.models.nested import NDVpcPairNestedModel # noqa: F401 -from ansible_collections.cisco.nd.plugins.models.vpc_pair_models import * # noqa: F401,F403 diff --git a/plugins/module_utils/manage/vpc_pair/vpc_pair_details.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py similarity index 88% rename from plugins/module_utils/manage/vpc_pair/vpc_pair_details.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py index dd14069a..27f1d518 100644 --- a/plugins/module_utils/manage/vpc_pair/vpc_pair_details.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py @@ -9,11 +9,11 @@ __metaclass__ = type """ -Compatibility bridge for vPC playbook detail models. +Backward-compatible export surface for vPC pair schemas. -Primary source of truth lives in `plugins/models/model_playbook_vpc_pair.py`. -This module exists only for Ansible module runtime compatibility when -`plugins/models` is not available in the AnsiballZ payload. +Primary source of truth lives in `plugins/models/vpc_pair_models.py`. +This module also provides local fallback models for AnsiballZ runtimes where +`plugins/models` files may not be packaged. """ from typing import Any, Dict, List, Optional, Literal, Annotated @@ -25,21 +25,22 @@ Field, ) - try: - from ansible_collections.cisco.nd.plugins.models.model_playbook_vpc_pair import ( # noqa: F401 - VpcPairDetailsDefault, - VpcPairDetailsCustom, + from ansible_collections.cisco.nd.plugins.models.base import ( # noqa: F401 + coerce_str_to_int, + coerce_to_bool, + coerce_list_of_str, + FlexibleInt, + FlexibleBool, + FlexibleListStr, + NDVpcPairBaseModel, ) + from ansible_collections.cisco.nd.plugins.models.nested import NDVpcPairNestedModel # noqa: F401 + from ansible_collections.cisco.nd.plugins.models.vpc_pair_models import * # noqa: F401,F403 except Exception: - try: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( # noqa: F401 - KeepAliveVrfEnum, - ) - except Exception: - from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.enums import ( # noqa: F401 - KeepAliveVrfEnum, - ) + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( # noqa: F401 + KeepAliveVrfEnum, + ) def coerce_str_to_int(data): if data is None: diff --git a/plugins/module_utils/manage/__init__.py b/plugins/module_utils/manage/__init__.py deleted file mode 100644 index ec71c82f..00000000 --- a/plugins/module_utils/manage/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import absolute_import, division, print_function - -__metaclass__ = type diff --git a/plugins/module_utils/manage/vpc_pair/__init__.py b/plugins/module_utils/manage/vpc_pair/__init__.py deleted file mode 100644 index ec71c82f..00000000 --- a/plugins/module_utils/manage/vpc_pair/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import absolute_import, division, print_function - -__metaclass__ = type diff --git a/plugins/modules/nd_vpc_pair.py b/plugins/modules/nd_vpc_pair.py index f484ad44..0c90d258 100644 --- a/plugins/modules/nd_vpc_pair.py +++ b/plugins/modules/nd_vpc_pair.py @@ -278,16 +278,10 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging # Service layer imports -try: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_resources import ( - VpcPairResourceService, - VpcPairResourceError, - ) -except Exception: - from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_resources import ( - VpcPairResourceService, - VpcPairResourceError, - ) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_resources import ( + VpcPairResourceService, + VpcPairResourceError, +) # Static imports so Ansible's AnsiballZ packager includes these files in the # module zip. Keep them optional when framework files are intentionally absent. @@ -310,18 +304,11 @@ # Enum imports from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -try: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( - ComponentTypeSupportEnum, - VpcActionEnum, - VpcFieldNames, - ) -except Exception: - from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair import ( - ComponentTypeSupportEnum, - VpcActionEnum, - VpcFieldNames, - ) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + ComponentTypeSupportEnum, + VpcActionEnum, + VpcFieldNames, +) try: from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_endpoints import ( @@ -370,7 +357,7 @@ VpcPairDetailsCustom, ) except Exception: - from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_details import ( + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_schemas import ( VpcPairDetailsDefault, VpcPairDetailsCustom, ) From d507f3aecb8e6cd51150d8cbab7a5a49901312f0 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 11 Mar 2026 10:48:59 +0530 Subject: [PATCH 13/39] Removal of obsolete files --- .../endpoints/v1/manage_vpc_pair.py | 32 ------------------- 1 file changed, 32 deletions(-) delete mode 100644 plugins/module_utils/endpoints/v1/manage_vpc_pair.py diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair.py deleted file mode 100644 index 64006224..00000000 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Sivakami S -# 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.manage.vpc_pair.base_paths import ( - VpcPairBasePath, -) -from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_endpoints import ( - EpVpcPairConsistencyGet, - EpVpcPairGet, - EpVpcPairOverviewGet, - EpVpcPairPut, - EpVpcPairRecommendationGet, - EpVpcPairSupportGet, - EpVpcPairsListGet, -) - -__all__ = [ - "VpcPairBasePath", - "EpVpcPairGet", - "EpVpcPairPut", - "EpVpcPairSupportGet", - "EpVpcPairOverviewGet", - "EpVpcPairRecommendationGet", - "EpVpcPairConsistencyGet", - "EpVpcPairsListGet", -] From e2ee0ea2d7d236de8ffc914ff1635dcff1fa61a6 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 11 Mar 2026 10:48:59 +0530 Subject: [PATCH 14/39] Rename/Removal of obsolete files --- .../endpoints/v1/manage_vpc_pair.py | 32 ------------------- .../{nd_vpc_pair.py => nd_manage_vpc_pair.py} | 0 2 files changed, 32 deletions(-) delete mode 100644 plugins/module_utils/endpoints/v1/manage_vpc_pair.py rename plugins/modules/{nd_vpc_pair.py => nd_manage_vpc_pair.py} (100%) diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair.py deleted file mode 100644 index 64006224..00000000 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Sivakami S -# 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.manage.vpc_pair.base_paths import ( - VpcPairBasePath, -) -from ansible_collections.cisco.nd.plugins.module_utils.manage.vpc_pair.vpc_pair_endpoints import ( - EpVpcPairConsistencyGet, - EpVpcPairGet, - EpVpcPairOverviewGet, - EpVpcPairPut, - EpVpcPairRecommendationGet, - EpVpcPairSupportGet, - EpVpcPairsListGet, -) - -__all__ = [ - "VpcPairBasePath", - "EpVpcPairGet", - "EpVpcPairPut", - "EpVpcPairSupportGet", - "EpVpcPairOverviewGet", - "EpVpcPairRecommendationGet", - "EpVpcPairConsistencyGet", - "EpVpcPairsListGet", -] diff --git a/plugins/modules/nd_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py similarity index 100% rename from plugins/modules/nd_vpc_pair.py rename to plugins/modules/nd_manage_vpc_pair.py From 015237c05852af134ab6a45b4362038fd9f6a396 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 11 Mar 2026 11:28:45 +0530 Subject: [PATCH 15/39] Aligning with ND Orchestrator style layering --- .../v1/manage_vpc_pair/vpc_pair_resources.py | 190 ++---------------- .../orchestrators/nd_vpc_pair_orchestrator.py | 92 +++++++++ plugins/modules/nd_manage_vpc_pair.py | 14 +- 3 files changed, 111 insertions(+), 185 deletions(-) create mode 100644 plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py index 2e9fcec2..5e418293 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py @@ -7,103 +7,18 @@ __metaclass__ = type -import importlib -import sys -import types as py_types from typing import Any, Callable, Dict, List, Optional from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import ( + NDStateMachine, +) +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_pair import ( + VpcPairOrchestrator, +) from pydantic import ValidationError -def register_nd_state_machine_import_aliases() -> None: - """ - Register compatibility aliases required by nd_state_machine flat imports. - """ - try: - nd_module = importlib.import_module( - "ansible_collections.cisco.nd.plugins.module_utils.nd" - ) - constants_module = importlib.import_module( - "ansible_collections.cisco.nd.plugins.module_utils.constants" - ) - nd_config_collection_module = importlib.import_module( - "ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection" - ) - models_base_module = importlib.import_module( - "ansible_collections.cisco.nd.plugins.module_utils.models.base" - ) - except Exception: - # Keep vpc_pair files importable even when state-machine framework files - # are intentionally not present in this branch. - return - - sys.modules.setdefault("nd", nd_module) - sys.modules.setdefault("constants", constants_module) - sys.modules.setdefault("nd_config_collection", nd_config_collection_module) - - # Keep compatibility scoped to vpc_pair runtime: NDStateMachine expects - # NDConfigCollection.to_list(), while PR172 exposes to_ansible_config(). - nd_config_collection_cls = getattr( - nd_config_collection_module, "NDConfigCollection", None - ) - if ( - nd_config_collection_cls is not None - and not hasattr(nd_config_collection_cls, "to_list") - ): - def _to_list(self, **kwargs): - return self.to_ansible_config(**kwargs) - - setattr(nd_config_collection_cls, "to_list", _to_list) - - models_pkg = sys.modules.get("models") - if models_pkg is None: - models_pkg = py_types.ModuleType("models") - models_pkg.__path__ = [] - sys.modules["models"] = models_pkg - - setattr(models_pkg, "base", models_base_module) - sys.modules.setdefault("models.base", models_base_module) - - # nd_state_machine imports orchestrators.base for typing. Prefer the real - # module and only install a shim if importing it fails in this environment. - orchestrators_pkg_name = ( - "ansible_collections.cisco.nd.plugins.module_utils.orchestrators" - ) - orchestrator_base_name = f"{orchestrators_pkg_name}.base" - if orchestrator_base_name not in sys.modules: - try: - importlib.import_module(orchestrator_base_name) - except Exception: - orchestrators_pkg = sys.modules.get(orchestrators_pkg_name) - if orchestrators_pkg is None: - orchestrators_pkg = py_types.ModuleType(orchestrators_pkg_name) - orchestrators_pkg.__path__ = [] - sys.modules[orchestrators_pkg_name] = orchestrators_pkg - - orchestrator_base_module = py_types.ModuleType(orchestrator_base_name) - - class NDBaseOrchestrator: # pragma: no cover - import shim - pass - - orchestrator_base_module.NDBaseOrchestrator = NDBaseOrchestrator - setattr(orchestrators_pkg, "base", orchestrator_base_module) - sys.modules[orchestrator_base_name] = orchestrator_base_module - - -register_nd_state_machine_import_aliases() - -HAS_ND_STATE_MACHINE = True -try: - from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine -except Exception: - HAS_ND_STATE_MACHINE = False - - class NDStateMachine: # pragma: no cover - compatibility shim - def __init__(self, *args, **kwargs): - _ = (args, kwargs) - - ActionHandler = Callable[[Any], Any] RunStateHandler = Callable[[Any], Dict[str, Any]] DeployHandler = Callable[[Any, str, Dict[str, Any]], Dict[str, Any]] @@ -119,84 +34,8 @@ def __init__(self, msg: str, **details: Any): self.details = details -class _VpcPairQueryContext: - """Minimal context object for query_all during NDStateMachine initialization.""" - - def __init__(self, module: AnsibleModule): - self.module = module - - -class VpcPairOrchestrator: - """ - VPC orchestrator implementation for NDStateMachine. - - Delegates CRUD operations to injected vPC action handlers. - """ - - model_class = None - - def __init__( - self, - module: Optional[AnsibleModule] = None, - sender: Optional[Any] = None, - **kwargs, - ): - _ = kwargs - # Compatibility with both NDStateMachine variants: - # - legacy: model_orchestrator(module=...) - # - Current: model_orchestrator(sender=nd_module) - if module is None and sender is not None: - module = getattr(sender, "module", None) - if module is None: - raise ValueError( - "VpcPairOrchestrator requires either module=AnsibleModule " - "or sender=." - ) - - self.module = module - self.sender = sender - self.state_machine = None - - self.model_class = getattr(self.module, "_vpc_pair_model_class", None) - self.actions = getattr(self.module, "_vpc_pair_actions", {}) - - if self.model_class is None: - raise ValueError("Missing _vpc_pair_model_class in module params") - required_actions = {"query_all", "create", "update", "delete"} - if not required_actions.issubset(set(self.actions)): - raise ValueError( - "Missing required _vpc_pair_actions. Required keys: " - "query_all, create, update, delete" - ) - - def bind_state_machine(self, state_machine: "VpcPairStateMachine") -> None: - self.state_machine = state_machine - - def query_all(self): - context = self.state_machine if self.state_machine is not None else _VpcPairQueryContext(self.module) - return self.actions["query_all"](context) - - def create(self, model_instance, **kwargs): - _ = (model_instance, kwargs) - if self.state_machine is None: - raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") - return self.actions["create"](self.state_machine) - - def update(self, model_instance, **kwargs): - _ = (model_instance, kwargs) - if self.state_machine is None: - raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") - return self.actions["update"](self.state_machine) - - def delete(self, model_instance, **kwargs): - _ = (model_instance, kwargs) - if self.state_machine is None: - raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") - return self.actions["delete"](self.state_machine) - - class VpcPairStateMachine(NDStateMachine): - """NDStateMachine adapter with state handling compatible with nd_vpc_pair.""" + """NDStateMachine adapter with state handling for nd_manage_vpc_pair.""" def __init__(self, module: AnsibleModule): super().__init__(module=module, model_orchestrator=VpcPairOrchestrator) @@ -380,7 +219,7 @@ def _manage_delete_state(self) -> None: class VpcPairResourceService: """ - Runtime service for nd_vpc_pair execution flow. + Runtime service for nd_manage_vpc_pair execution flow. Orchestrates state management and optional deployment while keeping module entrypoint thin. @@ -414,25 +253,20 @@ def _prime_runtime_context(self) -> None: self.module._vpc_pair_actions = self.actions def execute(self, fabric_name: str) -> Dict[str, Any]: - if not HAS_ND_STATE_MACHINE: - raise RuntimeError( - "nd_vpc_pair requires nd_state_machine framework files, " - "which are not present in this branch." - ) self._prime_runtime_context() - nd_vpc_pair = VpcPairStateMachine(module=self.module) - result = self.run_state_handler(nd_vpc_pair) + nd_manage_vpc_pair = VpcPairStateMachine(module=self.module) + result = self.run_state_handler(nd_manage_vpc_pair) if "_ip_to_sn_mapping" in self.module.params: result["ip_to_sn_mapping"] = self.module.params["_ip_to_sn_mapping"] deploy = self.module.params.get("deploy", False) if deploy and not self.module.check_mode: - deploy_result = self.deploy_handler(nd_vpc_pair, fabric_name, result) + deploy_result = self.deploy_handler(nd_manage_vpc_pair, fabric_name, result) result["deployment"] = deploy_result result["deployment_needed"] = deploy_result.get( "deployment_needed", - self.needs_deployment_handler(result, nd_vpc_pair), + self.needs_deployment_handler(result, nd_manage_vpc_pair), ) return result diff --git a/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py b/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py new file mode 100644 index 00000000..0bf9dcc8 --- /dev/null +++ b/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from typing import Any, Optional + +from ansible.module_utils.basic import AnsibleModule + + +class _VpcPairQueryContext: + """Minimal context object for query_all during NDStateMachine initialization.""" + + def __init__(self, module: AnsibleModule): + self.module = module + + +class VpcPairOrchestrator: + """ + VPC orchestrator implementation for NDStateMachine. + + Delegates CRUD operations to injected vPC action handlers. + """ + + model_class = None + + def __init__( + self, + module: Optional[AnsibleModule] = None, + sender: Optional[Any] = None, + **kwargs, + ): + _ = kwargs + # Compatibility with both NDStateMachine variants: + # - legacy: model_orchestrator(module=...) + # - Current: model_orchestrator(sender=nd_module) + if module is None and sender is not None: + module = getattr(sender, "module", None) + if module is None: + raise ValueError( + "VpcPairOrchestrator requires either module=AnsibleModule " + "or sender=." + ) + + self.module = module + self.sender = sender + self.state_machine = None + + self.model_class = getattr(self.module, "_vpc_pair_model_class", None) + self.actions = getattr(self.module, "_vpc_pair_actions", {}) + + if self.model_class is None: + raise ValueError("Missing _vpc_pair_model_class in module params") + required_actions = {"query_all", "create", "update", "delete"} + if not required_actions.issubset(set(self.actions)): + raise ValueError( + "Missing required _vpc_pair_actions. Required keys: " + "query_all, create, update, delete" + ) + + def bind_state_machine(self, state_machine: Any) -> None: + self.state_machine = state_machine + + def query_all(self): + context = ( + self.state_machine + if self.state_machine is not None + else _VpcPairQueryContext(self.module) + ) + return self.actions["query_all"](context) + + def create(self, model_instance, **kwargs): + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return self.actions["create"](self.state_machine) + + def update(self, model_instance, **kwargs): + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return self.actions["update"](self.state_machine) + + def delete(self, model_instance, **kwargs): + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return self.actions["delete"](self.state_machine) diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index 0c90d258..fa922919 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -11,7 +11,7 @@ DOCUMENTATION = """ --- -module: nd_vpc_pair +module: nd_manage_vpc_pair short_description: Manage vPC pairs in Nexus devices. version_added: "1.0.0" description: @@ -100,7 +100,7 @@ EXAMPLES = """ # Create a new vPC pair - name: Create vPC pair - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: myFabric state: merged config: @@ -110,7 +110,7 @@ # Delete a vPC pair - name: Delete vPC pair - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: myFabric state: deleted config: @@ -119,13 +119,13 @@ # Gather existing vPC pairs - name: Gather all vPC pairs - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: myFabric state: gathered # Create and deploy - name: Create vPC pair and deploy - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: myFabric state: merged deploy: true @@ -135,7 +135,7 @@ # Dry run to see what would change - name: Dry run vPC pair creation - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: myFabric state: merged dry_run: true @@ -664,7 +664,7 @@ def fabric_config_deploy(fabric_name: str, force_show_run: bool = True) -> str: class VpcPairModel(NDNestedModel): """ - Pydantic model for VPC pair configuration specific to nd_vpc_pair module. + Pydantic model for VPC pair configuration specific to nd_manage_vpc_pair module. Uses composite identifier: (switch_id, peer_switch_id) From 498f26a9772c55802a655fae4a22782b892a2840 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 11 Mar 2026 11:36:10 +0530 Subject: [PATCH 16/39] Integration tests related changes --- .../tests/integration/nd_vpc_pair_validate.py | 210 +++++++++++++ .../targets/nd_vpc_pair/tasks/main.yaml | 28 ++ .../nd_vpc_pair/templates/nd_vpc_pair_conf.j2 | 49 +++ .../nd_vpc_pair/tests/nd/base_tasks.yaml | 49 +++ .../nd_vpc_pair/tests/nd/conf_prep_tasks.yaml | 21 ++ .../tests/nd/nd_vpc_pair_delete.yaml | 136 +++++---- .../tests/nd/nd_vpc_pair_gather.yaml | 82 +++-- .../tests/nd/nd_vpc_pair_merge.yaml | 279 +++++++++++------- .../tests/nd/nd_vpc_pair_override.yaml | 137 +++++---- .../tests/nd/nd_vpc_pair_replace.yaml | 101 ++++--- 10 files changed, 794 insertions(+), 298 deletions(-) create mode 100644 plugins/action/tests/integration/nd_vpc_pair_validate.py create mode 100644 tests/integration/targets/nd_vpc_pair/tasks/main.yaml create mode 100644 tests/integration/targets/nd_vpc_pair/templates/nd_vpc_pair_conf.j2 create mode 100644 tests/integration/targets/nd_vpc_pair/tests/nd/base_tasks.yaml create mode 100644 tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml diff --git a/plugins/action/tests/integration/nd_vpc_pair_validate.py b/plugins/action/tests/integration/nd_vpc_pair_validate.py new file mode 100644 index 00000000..51239e3e --- /dev/null +++ b/plugins/action/tests/integration/nd_vpc_pair_validate.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman +# 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.plugins.action import ActionBase +from ansible.utils.display import Display + +display = Display() + + +def _normalize_pair(pair): + """Return a frozenset key of (switch_id, peer_switch_id) so order does not matter.""" + s1 = pair.get("switchId") or pair.get("switch_id") or pair.get("peer1_switch_id", "") + s2 = pair.get("peerSwitchId") or pair.get("peer_switch_id") or pair.get("peer2_switch_id", "") + return frozenset([s1.strip(), s2.strip()]) + + +def _get_virtual_peer_link(pair): + """Extract the use_virtual_peer_link / useVirtualPeerLink value from a pair dict.""" + for key in ("useVirtualPeerLink", "use_virtual_peer_link"): + if key in pair: + return pair[key] + return None + + +class ActionModule(ActionBase): + """Ansible action plugin that validates nd_vpc_pair gathered output against expected test data. + + Usage in a playbook task:: + + - name: Validate vPC pairs + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ gathered_result }}" + expected_data: "{{ expected_conf }}" + changed: "{{ result.changed }}" + mode: "full" # full | count_only | exists + + Parameters + ---------- + gathered_data : dict + The full register output of a ``cisco.nd.nd_manage_vpc_pair`` task with ``state: gathered``. + Must contain ``gathered.vpc_pairs`` (list). + expected_data : list + List of dicts with expected vPC pair config. Each dict should have at least + ``peer1_switch_id`` / ``peer2_switch_id`` (playbook-style keys). + API-style keys (``switchId`` / ``peerSwitchId``) are also accepted. + changed : bool, optional + If provided the plugin asserts that the previous action reported ``changed``. + mode : str, optional + ``full`` – (default) match count **and** per-pair field values. + ``count_only`` – only verify the number of pairs matches. + ``exists`` – verify that every expected pair exists (extra pairs OK). + """ + + VALID_MODES = frozenset(["full", "count_only", "exists"]) + + def run(self, tmp=None, task_vars=None): + results = super(ActionModule, self).run(tmp, task_vars) + results["failed"] = False + + # ------------------------------------------------------------------ + # Extract arguments + # ------------------------------------------------------------------ + gathered_data = self._task.args.get("gathered_data") + expected_data = self._task.args.get("expected_data") + changed = self._task.args.get("changed") + mode = self._task.args.get("mode", "full").lower() + + if mode not in self.VALID_MODES: + results["failed"] = True + results["msg"] = "Invalid mode '{0}'. Choose from: {1}".format(mode, ", ".join(sorted(self.VALID_MODES))) + return results + + # ------------------------------------------------------------------ + # Validate 'changed' flag if provided + # ------------------------------------------------------------------ + if changed is not None: + # Accept bool or string representation + if isinstance(changed, str): + changed = changed.strip().lower() in ("true", "1", "yes") + if not changed: + results["failed"] = True + results["msg"] = "Preceding task reported changed=false but expected a change." + return results + + # ------------------------------------------------------------------ + # Unwrap gathered data + # ------------------------------------------------------------------ + if gathered_data is None: + results["failed"] = True + results["msg"] = "gathered_data is required." + return results + + if isinstance(gathered_data, dict): + # Could be the full register dict or just the gathered sub-dict + vpc_pairs = ( + gathered_data.get("gathered", {}).get("vpc_pairs") + or gathered_data.get("vpc_pairs") + ) + else: + results["failed"] = True + results["msg"] = "gathered_data must be a dict (register output or gathered sub-dict)." + return results + + if vpc_pairs is None: + vpc_pairs = [] + + # ------------------------------------------------------------------ + # Normalise expected data + # ------------------------------------------------------------------ + if expected_data is None: + expected_data = [] + if not isinstance(expected_data, list): + results["failed"] = True + results["msg"] = "expected_data must be a list of vpc pair dicts." + return results + + # ------------------------------------------------------------------ + # Count check + # ------------------------------------------------------------------ + if mode in ("full", "count_only"): + if len(vpc_pairs) != len(expected_data): + results["failed"] = True + results["msg"] = ( + "Pair count mismatch: gathered {0} pair(s) but expected {1}.".format( + len(vpc_pairs), len(expected_data) + ) + ) + results["gathered_count"] = len(vpc_pairs) + results["expected_count"] = len(expected_data) + return results + + if mode == "count_only": + results["msg"] = "Validation successful (count_only): {0} pair(s).".format(len(vpc_pairs)) + return results + + # ------------------------------------------------------------------ + # Build lookup of gathered pairs keyed by normalised pair key + # ------------------------------------------------------------------ + gathered_by_key = {} + for pair in vpc_pairs: + key = _normalize_pair(pair) + gathered_by_key[key] = pair + + # ------------------------------------------------------------------ + # Match each expected pair + # ------------------------------------------------------------------ + missing_pairs = [] + field_mismatches = [] + + for expected in expected_data: + key = _normalize_pair(expected) + gathered_pair = gathered_by_key.get(key) + + if gathered_pair is None: + missing_pairs.append( + { + "peer1": expected.get("peer1_switch_id") or expected.get("switchId", "?"), + "peer2": expected.get("peer2_switch_id") or expected.get("peerSwitchId", "?"), + } + ) + continue + + # Field-level comparison (only in full mode) + if mode == "full": + expected_vpl = _get_virtual_peer_link(expected) + gathered_vpl = _get_virtual_peer_link(gathered_pair) + if expected_vpl is not None and gathered_vpl is not None: + # Normalise to bool + if isinstance(expected_vpl, str): + expected_vpl = expected_vpl.lower() in ("true", "1", "yes") + if isinstance(gathered_vpl, str): + gathered_vpl = gathered_vpl.lower() in ("true", "1", "yes") + if bool(expected_vpl) != bool(gathered_vpl): + field_mismatches.append( + { + "pair": "{0}-{1}".format( + expected.get("peer1_switch_id") or expected.get("switchId", "?"), + expected.get("peer2_switch_id") or expected.get("peerSwitchId", "?"), + ), + "field": "use_virtual_peer_link", + "expected": bool(expected_vpl), + "actual": bool(gathered_vpl), + } + ) + + # ------------------------------------------------------------------ + # Compose result + # ------------------------------------------------------------------ + if missing_pairs or field_mismatches: + results["failed"] = True + parts = [] + if missing_pairs: + parts.append("Missing pairs: {0}".format(missing_pairs)) + if field_mismatches: + parts.append("Field mismatches: {0}".format(field_mismatches)) + results["msg"] = "Validation failed. " + "; ".join(parts) + results["missing_pairs"] = missing_pairs + results["field_mismatches"] = field_mismatches + else: + results["msg"] = "Validation successful: {0} pair(s) verified ({1} mode).".format( + len(expected_data), mode + ) + + return results diff --git a/tests/integration/targets/nd_vpc_pair/tasks/main.yaml b/tests/integration/targets/nd_vpc_pair/tasks/main.yaml new file mode 100644 index 00000000..7ca96b8c --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tasks/main.yaml @@ -0,0 +1,28 @@ +--- +# Test discovery and execution for nd_vpc_pair integration tests. +# +# Usage: +# ansible-playbook -i hosts.yaml tasks/main.yaml # run all tests +# ansible-playbook -i hosts.yaml tasks/main.yaml -e testcase=nd_vpc_pair_merge # run one +# ansible-playbook -i hosts.yaml tasks/main.yaml --tags merge # run by tag + +- name: Discover nd_vpc_pair test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/nd" + patterns: "{{ testcase | default('nd_vpc_pair_*') }}.yaml" + connection: local + register: nd_vpc_pair_testcases + +- name: Build list of test items + ansible.builtin.set_fact: + test_items: "{{ nd_vpc_pair_testcases.files | map(attribute='path') | list }}" + +- name: Display discovered tests + ansible.builtin.debug: + msg: "Discovered {{ test_items | length }} test file(s): {{ test_items | map('basename') | list }}" + +- name: Run nd_vpc_pair test cases + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/nd_vpc_pair/templates/nd_vpc_pair_conf.j2 b/tests/integration/targets/nd_vpc_pair/templates/nd_vpc_pair_conf.j2 new file mode 100644 index 00000000..e8115beb --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/templates/nd_vpc_pair_conf.j2 @@ -0,0 +1,49 @@ +--- +# This nd_vpc_pair test data structure is auto-generated +# DO NOT EDIT MANUALLY +# +# Template: nd_vpc_pair_conf.j2 +# Variables: vpc_pair_conf (list of dicts) + +{% if vpc_pair_conf is iterable %} +{% set pair_list = [] %} +{% for pair in vpc_pair_conf %} +{% set pair_item = {} %} +{% if pair.peer1_switch_id is defined %} +{% set _ = pair_item.update({'peer1_switch_id': pair.peer1_switch_id}) %} +{% endif %} +{% if pair.peer2_switch_id is defined %} +{% set _ = pair_item.update({'peer2_switch_id': pair.peer2_switch_id}) %} +{% endif %} +{% if pair.use_virtual_peer_link is defined %} +{% set _ = pair_item.update({'use_virtual_peer_link': pair.use_virtual_peer_link}) %} +{% endif %} +{% if pair.vpc_pair_details is defined %} +{% set details_item = {} %} +{% if pair.vpc_pair_details.type is defined %} +{% set _ = details_item.update({'type': pair.vpc_pair_details.type}) %} +{% endif %} +{% if pair.vpc_pair_details.domain_id is defined %} +{% set _ = details_item.update({'domain_id': pair.vpc_pair_details.domain_id}) %} +{% endif %} +{% if pair.vpc_pair_details.switch_keep_alive_local_ip is defined %} +{% set _ = details_item.update({'switch_keep_alive_local_ip': pair.vpc_pair_details.switch_keep_alive_local_ip}) %} +{% endif %} +{% if pair.vpc_pair_details.peer_switch_keep_alive_local_ip is defined %} +{% set _ = details_item.update({'peer_switch_keep_alive_local_ip': pair.vpc_pair_details.peer_switch_keep_alive_local_ip}) %} +{% endif %} +{% if pair.vpc_pair_details.keep_alive_vrf is defined %} +{% set _ = details_item.update({'keep_alive_vrf': pair.vpc_pair_details.keep_alive_vrf}) %} +{% endif %} +{% if pair.vpc_pair_details.template_name is defined %} +{% set _ = details_item.update({'template_name': pair.vpc_pair_details.template_name}) %} +{% endif %} +{% if pair.vpc_pair_details.template_config is defined %} +{% set _ = details_item.update({'template_config': pair.vpc_pair_details.template_config}) %} +{% endif %} +{% set _ = pair_item.update({'vpc_pair_details': details_item}) %} +{% endif %} +{% set _ = pair_list.append(pair_item) %} +{% endfor %} +{{ pair_list | to_nice_yaml(indent=2) }} +{% endif %} diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/base_tasks.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/base_tasks.yaml new file mode 100644 index 00000000..8d5690f1 --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/base_tasks.yaml @@ -0,0 +1,49 @@ +--- +# Shared base tasks for nd_vpc_pair integration tests. +# Import this at the top of each test file: +# - import_tasks: base_tasks.yaml +# tags: + +- name: BASE - Test Entry Point - [nd_manage_vpc_pair] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ nd_vpc_pair Integration Test Base Setup +" + - "----------------------------------------------------------------" + +# -------------------------------- +# Create Dictionary of Test Data +# -------------------------------- +- name: BASE - Setup Internal TestCase Variables + ansible.builtin.set_fact: + test_fabric: "{{ fabric_name }}" + test_switch1: "{{ switch1_serial }}" + test_switch2: "{{ switch2_serial }}" + test_fabric_type: "{{ fabric_type | default('LANClassic') }}" + deploy_local: true + delegate_to: localhost + +# ------------------------------------------ +# Query Fabric Existence +# ------------------------------------------ +- name: BASE - Verify fabric is reachable via API + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}" + method: get + register: fabric_query + ignore_errors: true + +- name: BASE - Assert fabric exists + ansible.builtin.assert: + that: + - fabric_query.failed == false + fail_msg: "Fabric '{{ test_fabric }}' not found or API unreachable." + +# ------------------------------------------ +# Clean up existing vPC pairs +# ------------------------------------------ +- name: BASE - Clean up existing vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + ignore_errors: true diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml new file mode 100644 index 00000000..6e1fe950 --- /dev/null +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml @@ -0,0 +1,21 @@ +--- +# Shared configuration preparation tasks for nd_vpc_pair integration tests. +# +# Usage: +# - name: Import Configuration Prepare Tasks +# vars: +# file: merge # output file identifier +# import_tasks: conf_prep_tasks.yaml +# +# Requires: vpc_pair_conf variable to be set before importing. + +- name: Build vPC Pair Config Data from Template + ansible.builtin.template: + src: nd_vpc_pair_conf.j2 + dest: "{{ role_path }}/files/nd_vpc_pair_{{ file }}_conf.yaml" + delegate_to: localhost + +- name: Load Configuration Data into Variable + ansible.builtin.set_fact: + "{{ 'nd_vpc_pair_' + file + '_conf' }}": "{{ lookup('file', role_path + '/files/nd_vpc_pair_' + file + '_conf.yaml') | from_yaml }}" + delegate_to: localhost diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml index 82279bfe..4bf364e4 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml @@ -6,29 +6,27 @@ ## SETUP ## ############################################## - - name: DELETE - Test Entry Point - [nd_vpc_pair] - ansible.builtin.debug: - msg: - - "----------------------------------------------------------------" - - "+ Executing Delete Tests - [nd_vpc_pair] +" - - "----------------------------------------------------------------" + - name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml tags: delete ############################################## - ## Setup Internal TestCase Variables ## + ## Setup Delete TestCase Variables ## ############################################## - - name: DELETE - Setup Internal TestCase Variables + - name: DELETE - Setup config ansible.builtin.set_fact: - deploy_local: true - test_fabric: "{{ fabric_name }}" - test_switch1: "{{ switch1_serial }}" - test_switch2: "{{ switch2_serial }}" - test_data_deleted: - vpc_pair_setup_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - use_virtual_peer_link: true + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: delete + + - name: Import Configuration Prepare Tasks - delete_setup + vars: + file: delete_setup + import_tasks: conf_prep_tasks.yaml tags: delete ############################################## @@ -36,17 +34,11 @@ ############################################## # TC1 - Setup: Create vPC pair for deletion tests - - name: DELETE - TC1 - DELETE - Clean up any existing vPC pairs - cisco.nd.nd_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - tags: delete - - name: DELETE - TC1 - MERGE - Create vPC pair for deletion testing - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged - config: "{{ test_data_deleted.vpc_pair_setup_conf }}" + config: "{{ nd_vpc_pair_delete_setup_conf }}" register: result tags: delete @@ -58,7 +50,7 @@ tags: delete - name: DELETE - TC1 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered config: @@ -67,16 +59,22 @@ register: verify_result tags: delete - - name: DELETE - TC1 - ASSERT - Verify vPC pair state in ND + - name: DELETE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_delete_setup_conf }}" + register: validation + tags: delete + + - name: DELETE - TC1 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' + - validation.failed == false tags: delete # TC2 - Delete vPC pair with specific config - name: DELETE - TC2 - DELETE - Delete vPC pair with specific peer config - cisco.nd.nd_vpc_pair: &delete_specific + cisco.nd.nd_manage_vpc_pair: &delete_specific fabric_name: "{{ test_fabric }}" state: deleted config: @@ -93,7 +91,7 @@ tags: delete - name: DELETE - TC2 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered config: @@ -102,16 +100,23 @@ register: verify_result tags: delete - - name: DELETE - TC2 - ASSERT - Verify vPC pair deletion + - name: DELETE - TC2 - VALIDATE - Verify vPC pair deletion + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: delete + + - name: DELETE - TC2 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 0' + - validation.failed == false tags: delete # TC3 - Idempotence test for deletion - name: DELETE - TC3 - conf - Idempotence - cisco.nd.nd_vpc_pair: *delete_specific + cisco.nd.nd_manage_vpc_pair: *delete_specific register: result tags: delete @@ -124,10 +129,10 @@ # TC4 - Create another vPC pair for bulk deletion test - name: DELETE - TC4 - MERGE - Create vPC pair for bulk deletion testing - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged - config: "{{ test_data_deleted.vpc_pair_setup_conf }}" + config: "{{ nd_vpc_pair_delete_setup_conf }}" register: result tags: delete @@ -139,7 +144,7 @@ tags: delete - name: DELETE - TC4 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered config: @@ -148,16 +153,23 @@ register: verify_result tags: delete - - name: DELETE - TC4 - ASSERT - Verify vPC pair state in ND for bulk deletion setup + - name: DELETE - TC4 - VALIDATE - Verify vPC pair state in ND for bulk deletion setup + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_delete_setup_conf }}" + mode: "exists" + register: validation + tags: delete + + - name: DELETE - TC4 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' + - validation.failed == false tags: delete # TC5 - Delete all vPC pairs without specific config - name: DELETE - TC5 - DELETE - Delete all vPC pairs without specific config - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted register: result @@ -171,22 +183,29 @@ tags: delete - name: DELETE - TC5 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered register: verify_result tags: delete - - name: DELETE - TC5 - ASSERT - Verify bulk deletion + - name: DELETE - TC5 - VALIDATE - Verify bulk deletion + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: delete + + - name: DELETE - TC5 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 0' + - validation.failed == false tags: delete # TC6 - Delete from empty fabric (should be no-op) - name: DELETE - TC6 - DELETE - Delete from empty fabric (no-op) - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted register: result @@ -201,10 +220,10 @@ # TC7 - Force deletion bypass path - name: DELETE - TC7 - MERGE - Create vPC pair for force delete test - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged - config: "{{ test_data_deleted.vpc_pair_setup_conf }}" + config: "{{ nd_vpc_pair_delete_setup_conf }}" register: result tags: delete @@ -215,7 +234,7 @@ tags: delete - name: DELETE - TC7 - DELETE - Delete vPC pair with force true - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted force: true @@ -233,7 +252,7 @@ tags: delete - name: DELETE - TC7 - GATHER - Verify force deletion result in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered config: @@ -242,11 +261,18 @@ register: verify_result tags: delete - - name: DELETE - TC7 - ASSERT - Confirm pair deleted with force + - name: DELETE - TC7 - VALIDATE - Confirm pair deleted with force + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: delete + + - name: DELETE - TC7 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 0' + - validation.failed == false tags: delete ############################################## @@ -254,7 +280,7 @@ ############################################## - name: DELETE - END - ensure clean state - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted when: cleanup_at_end | default(true) diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml index f34d9b80..a6b7f58e 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml @@ -6,29 +6,27 @@ ## SETUP ## ############################################## - - name: GATHER - Test Entry Point - [nd_vpc_pair] - ansible.builtin.debug: - msg: - - "----------------------------------------------------------------" - - "+ Executing Gather Tests - [nd_vpc_pair] +" - - "----------------------------------------------------------------" + - name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml tags: gather ############################################## - ## Setup Internal TestCase Variables ## + ## Setup Gather TestCase Variables ## ############################################## - - name: GATHER - Setup Internal TestCase Variables + - name: GATHER - Setup config ansible.builtin.set_fact: - deploy_local: true - test_fabric: "{{ fabric_name }}" - test_switch1: "{{ switch1_serial }}" - test_switch2: "{{ switch2_serial }}" - test_data_query: - vpc_pair_setup_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - use_virtual_peer_link: true + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: gather + + - name: Import Configuration Prepare Tasks - gather_setup + vars: + file: gather_setup + import_tasks: conf_prep_tasks.yaml tags: gather ############################################## @@ -36,17 +34,11 @@ ############################################## # TC1 - Setup: Create vPC pair for gather tests - - name: GATHER - TC1 - DELETE - Clean up any existing vPC pairs - cisco.nd.nd_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - tags: gather - - name: GATHER - TC1 - MERGE - Create vPC pair for testing - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged - config: "{{ test_data_query.vpc_pair_setup_conf }}" + config: "{{ nd_vpc_pair_gather_setup_conf }}" register: result tags: gather @@ -57,22 +49,28 @@ tags: gather - name: GATHER - TC1 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" register: verify_result tags: gather - - name: GATHER - TC1 - ASSERT - Verify vPC pair state in ND + - name: GATHER - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_gather_setup_conf }}" + register: validation + tags: gather + + - name: GATHER - TC1 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' + - validation.failed == false tags: gather # TC2 - Gather with no filters - name: GATHER - TC2 - GATHER - Gather all vPC pairs with no filters - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered register: result @@ -87,7 +85,7 @@ # TC3 - Gather with both peers specified - name: GATHER - TC3 - GATHER - Gather vPC pair with both peers specified - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered config: @@ -103,9 +101,9 @@ - '(result.gathered.vpc_pairs | length) == 1' tags: gather - # TC4 - Gather with one peer specified (not supported in nd_vpc_pair) + # TC4 - Gather with one peer specified (not supported in nd_manage_vpc_pair) - name: GATHER - TC4 - GATHER - Gather vPC pair with one peer specified - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered config: @@ -121,9 +119,9 @@ - result.msg is defined tags: gather - # TC5 - Gather with second peer specified (not supported in nd_vpc_pair) + # TC5 - Gather with second peer specified (not supported in nd_manage_vpc_pair) - name: GATHER - TC5 - GATHER - Gather vPC pair with second peer specified - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered config: @@ -141,7 +139,7 @@ # TC6 - Gather with non-existent peer - name: GATHER - TC6 - GATHER - Gather vPC pair with non-existent peer - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered config: @@ -159,7 +157,7 @@ # TC7 - Gather with custom query_timeout - name: GATHER - TC7 - GATHER - Gather with query_timeout override - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered query_timeout: 20 @@ -175,7 +173,7 @@ # TC8 - gathered + deploy validation (must fail) - name: GATHER - TC8 - GATHER - Gather with deploy enabled (invalid) - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered deploy: true @@ -192,7 +190,7 @@ # TC9 - gathered + dry_run validation (must fail) - name: GATHER - TC9 - GATHER - Gather with dry_run enabled (invalid) - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered dry_run: true @@ -216,7 +214,7 @@ tags: gather - name: GATHER - TC10 - GATHER - Query module gathered output for comparison - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered register: gathered_result @@ -259,7 +257,7 @@ # TC11 - Validate normalized pair matching for reversed switch order - name: GATHER - TC11 - GATHER - Gather with reversed/duplicate pair filters - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered config: @@ -282,7 +280,7 @@ ############################################## - name: GATHER - END - remove vPC pairs - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted when: cleanup_at_end | default(true) diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml index 1b9a0b30..c3e37cef 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml @@ -6,50 +6,71 @@ ## SETUP ## ############################################## - - name: MERGE - Test Entry Point - [nd_vpc_pair] - ansible.builtin.debug: - msg: - - "----------------------------------------------------------------" - - "+ Executing Merge Tests - [nd_vpc_pair] +" - - "----------------------------------------------------------------" + - name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml tags: merge ############################################## - ## Setup Internal TestCase Variables ## + ## Setup Merge TestCase Variables ## ############################################## - - name: MERGE - Setup Internal TestCase Variables + - name: MERGE - Setup full config ansible.builtin.set_fact: - deploy_local: false - test_fabric: "{{ fabric_name }}" - test_switch1: "{{ switch1_serial }}" - test_switch2: "{{ switch2_serial }}" - test_fabric_type: "{{ fabric_type | default('LANClassic') }}" - test_data_merged: - vpc_pair_full_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - use_virtual_peer_link: true - vpc_pair_full_deployed_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - use_virtual_peer_link: true - vpc_pair_modified_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - use_virtual_peer_link: false - vpc_pair_minimal_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - vpc_pair_no_deploy_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - use_virtual_peer_link: true - tags: merge - - - name: MERGE - Change deploy to true + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: merge + + - name: Import Configuration Prepare Tasks - merge_full + vars: + file: merge_full + import_tasks: conf_prep_tasks.yaml + tags: merge + + - name: MERGE - Setup modified config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: false + delegate_to: localhost + tags: merge + + - name: Import Configuration Prepare Tasks - merge_modified + vars: + file: merge_modified + import_tasks: conf_prep_tasks.yaml + tags: merge + + - name: MERGE - Setup minimal config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + delegate_to: localhost + tags: merge + + - name: Import Configuration Prepare Tasks - merge_minimal + vars: + file: merge_minimal + import_tasks: conf_prep_tasks.yaml + tags: merge + + - name: MERGE - Setup no-deploy config ansible.builtin.set_fact: - deploy_local: true + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: merge + + - name: Import Configuration Prepare Tasks - merge_no_deploy + vars: + file: merge_no_deploy + import_tasks: conf_prep_tasks.yaml tags: merge ############################################## @@ -57,17 +78,11 @@ ############################################## # TC1 - Create vPC pair with full configuration - - name: MERGE - TC1 - DELETE - Clean up any existing vPC pairs - cisco.nd.nd_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - tags: merge - - name: MERGE - TC1 - MERGE - Create vPC pair with full configuration - cisco.nd.nd_vpc_pair: &conf_full + cisco.nd.nd_manage_vpc_pair: &conf_full fabric_name: "{{ test_fabric }}" state: merged - config: "{{ test_data_merged.vpc_pair_full_conf }}" + config: "{{ nd_vpc_pair_merge_full_conf }}" register: result tags: merge @@ -79,7 +94,7 @@ tags: merge - name: MERGE - TC1 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -88,15 +103,22 @@ register: verify_result tags: merge - - name: MERGE - TC1 - ASSERT - Verify vPC pair state in ND + - name: MERGE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_full_conf }}" + changed: "{{ result.changed }}" + register: validation + tags: merge + + - name: MERGE - TC1 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' + - validation.failed == false tags: merge - name: MERGE - TC1 - conf - Idempotence - cisco.nd.nd_vpc_pair: *conf_full + cisco.nd.nd_manage_vpc_pair: *conf_full register: result tags: merge @@ -109,10 +131,10 @@ # TC2 - Modify existing vPC pair configuration - name: MERGE - TC2 - MERGE - Modify vPC pair configuration - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged - config: "{{ test_data_merged.vpc_pair_modified_conf }}" + config: "{{ nd_vpc_pair_merge_modified_conf }}" register: result when: test_fabric_type == "LANClassic" tags: merge @@ -125,7 +147,7 @@ tags: merge - name: MERGE - TC2 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -135,18 +157,25 @@ when: test_fabric_type == "LANClassic" tags: merge - - name: MERGE - TC2 - ASSERT - Verify vPC pair state in ND + - name: MERGE - TC2 - VALIDATE - Verify modified vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_modified_conf }}" + mode: "full" + register: validation + when: test_fabric_type == "LANClassic" + tags: merge + + - name: MERGE - TC2 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' - - verify_result.gathered.vpc_pairs[0].use_virtual_peer_link == false + - validation.failed == false when: test_fabric_type == "LANClassic" tags: merge # TC2b - VXLANFabric specific test - name: MERGE - TC2b - MERGE - Merge vPC pair for VXLAN fabric - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged config: @@ -164,7 +193,7 @@ tags: merge - name: MERGE - TC2b - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" register: verify_result @@ -173,7 +202,7 @@ # TC3 - Delete vPC pair - name: MERGE - TC3 - DELETE - Delete vPC pair - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted config: @@ -189,7 +218,7 @@ tags: merge - name: MERGE - TC3 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -199,18 +228,25 @@ tags: merge - name: MERGE - TC3 - ASSERT - Verify vPC pair deletion + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: merge + + - name: MERGE - TC3 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 0' + - validation.failed == false tags: merge # TC4 - Create vPC pair with minimal configuration - name: MERGE - TC4 - MERGE - Create vPC pair with minimal configuration - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged - config: "{{ test_data_merged.vpc_pair_minimal_conf }}" + config: "{{ nd_vpc_pair_merge_minimal_conf }}" register: result when: test_fabric_type == "LANClassic" tags: merge @@ -223,7 +259,7 @@ tags: merge - name: MERGE - TC4 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -233,17 +269,25 @@ when: test_fabric_type == "LANClassic" tags: merge - - name: MERGE - TC4 - ASSERT - Verify vPC pair state in ND + - name: MERGE - TC4 - VALIDATE - Verify minimal vPC pair + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_minimal_conf }}" + mode: "exists" + register: validation + when: test_fabric_type == "LANClassic" + tags: merge + + - name: MERGE - TC4 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' + - validation.failed == false when: test_fabric_type == "LANClassic" tags: merge # TC4b - Delete vPC pair after minimal test - name: MERGE - TC4b - DELETE - Delete vPC pair after minimal test - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted config: @@ -262,9 +306,9 @@ # TC5 - Create vPC pair with defaults (state omitted) - name: MERGE - TC5 - MERGE - Create vPC pair with defaults - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" - config: "{{ test_data_merged.vpc_pair_minimal_conf }}" + config: "{{ nd_vpc_pair_merge_minimal_conf }}" register: result tags: merge @@ -275,7 +319,7 @@ tags: merge - name: MERGE - TC5 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -284,16 +328,23 @@ register: verify_result tags: merge - - name: MERGE - TC5 - ASSERT - Verify vPC pair state in ND + - name: MERGE - TC5 - VALIDATE - Verify vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_minimal_conf }}" + mode: "exists" + register: validation + tags: merge + + - name: MERGE - TC5 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' + - validation.failed == false tags: merge # TC5b - Delete vPC pair after defaults test - name: MERGE - TC5b - DELETE - Delete vPC pair after defaults test - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted config: @@ -310,11 +361,11 @@ # TC6 - Create vPC pair with deploy flag false - name: MERGE - TC6 - MERGE - Create vPC pair with deploy false - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged deploy: false - config: "{{ test_data_merged.vpc_pair_no_deploy_conf }}" + config: "{{ nd_vpc_pair_merge_no_deploy_conf }}" register: result tags: merge @@ -326,7 +377,7 @@ tags: merge - name: MERGE - TC6 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -335,16 +386,22 @@ register: verify_result tags: merge - - name: MERGE - TC6 - ASSERT - Verify vPC pair state in ND + - name: MERGE - TC6 - VALIDATE - Verify vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_no_deploy_conf }}" + register: validation + tags: merge + + - name: MERGE - TC6 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' + - validation.failed == false tags: merge # TC7 - Merge with vpc_pair_details default template settings - name: MERGE - TC7 - MERGE - Update vPC pair with default vpc_pair_details - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged config: @@ -369,7 +426,7 @@ # TC8 - Merge with vpc_pair_details custom template settings - name: MERGE - TC8 - MERGE - Update vPC pair with custom vpc_pair_details - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged config: @@ -394,7 +451,7 @@ # TC9 - Test invalid configurations - name: MERGE - TC9 - MERGE - Create vPC pair with invalid peer switch - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged config: @@ -414,7 +471,7 @@ # TC10 - Create vPC pair with deploy enabled (actual deployment path) - name: MERGE - TC10 - DELETE - Ensure vPC pair is absent before deploy test - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted config: @@ -451,7 +508,7 @@ tags: merge - name: MERGE - TC10 - MERGE - Create vPC pair with deploy true - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged deploy: true @@ -471,7 +528,7 @@ # TC11 - Delete with custom api_timeout - name: MERGE - TC11 - DELETE - Delete vPC pair with api_timeout override - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted api_timeout: 60 @@ -489,7 +546,7 @@ # TC12 - dry_run should not apply configuration changes - name: MERGE - TC12 - DELETE - Ensure vPC pair is absent before dry_run test - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted config: @@ -499,11 +556,11 @@ tags: merge - name: MERGE - TC12 - MERGE - Run dry_run create for vPC pair - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged dry_run: true - config: "{{ test_data_merged.vpc_pair_full_conf }}" + config: "{{ nd_vpc_pair_merge_full_conf }}" register: result tags: merge @@ -514,7 +571,7 @@ tags: merge - name: MERGE - TC12 - GATHER - Verify dry_run did not create vPC pair - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -523,19 +580,26 @@ register: verify_result tags: merge - - name: MERGE - TC12 - ASSERT - Confirm no persistent changes from dry_run + - name: MERGE - TC12 - VALIDATE - Confirm no persistent changes from dry_run + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: merge + + - name: MERGE - TC12 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 0' + - validation.failed == false tags: merge # TC13 - Native Ansible check_mode should not apply configuration changes - name: MERGE - TC13 - MERGE - Run check_mode create for vPC pair - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged - config: "{{ test_data_merged.vpc_pair_full_conf }}" + config: "{{ nd_vpc_pair_merge_full_conf }}" check_mode: true register: result tags: merge @@ -547,7 +611,7 @@ tags: merge - name: MERGE - TC13 - GATHER - Verify check_mode did not create vPC pair - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -556,11 +620,18 @@ register: verify_result tags: merge - - name: MERGE - TC13 - ASSERT - Confirm no persistent changes from check_mode + - name: MERGE - TC13 - VALIDATE - Confirm no persistent changes from check_mode + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: merge + + - name: MERGE - TC13 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 0' + - validation.failed == false tags: merge # TC14 - Validate vpcPairSupport enforcement path (isPairingAllowed == false) @@ -617,7 +688,7 @@ tags: merge - name: MERGE - TC14 - MERGE - Verify unsupported pairing is blocked by module - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged config: @@ -651,7 +722,7 @@ ############################################## - name: MERGE - END - remove vPC pairs - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted when: cleanup_at_end | default(true) diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml index 1b94ed47..f8137d7b 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml @@ -6,34 +6,42 @@ ## SETUP ## ############################################## - - name: OVERRIDE - Test Entry Point - [nd_vpc_pair] - ansible.builtin.debug: - msg: - - "----------------------------------------------------------------" - - "+ Executing Override Tests - [nd_vpc_pair] +" - - "----------------------------------------------------------------" + - name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml tags: override ############################################## - ## Setup Internal TestCase Variables ## + ## Setup Override TestCase Variables ## ############################################## - - name: OVERRIDE - Setup Internal TestCase Variables + - name: OVERRIDE - Setup initial config ansible.builtin.set_fact: - deploy_local: true - test_fabric: "{{ fabric_name }}" - test_switch1: "{{ switch1_serial }}" - test_switch2: "{{ switch2_serial }}" - test_fabric_type: "{{ fabric_type | default('LANClassic') }}" - test_data_overridden: - vpc_pair_initial_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - use_virtual_peer_link: true - vpc_pair_overridden_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - use_virtual_peer_link: false + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: override + + - name: Import Configuration Prepare Tasks - override_initial + vars: + file: override_initial + import_tasks: conf_prep_tasks.yaml + tags: override + + - name: OVERRIDE - Setup overridden config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: false + delegate_to: localhost + tags: override + + - name: Import Configuration Prepare Tasks - override_overridden + vars: + file: override_overridden + import_tasks: conf_prep_tasks.yaml tags: override ############################################## @@ -41,17 +49,11 @@ ############################################## # TC1 - Override with a new vPC switch pair - - name: OVERRIDE - TC1 - DELETE - Clean up any existing vPC pairs - cisco.nd.nd_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - tags: override - - name: OVERRIDE - TC1 - OVERRIDE - Create vPC pair using override state - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: overridden - config: "{{ test_data_overridden.vpc_pair_initial_conf }}" + config: "{{ nd_vpc_pair_override_initial_conf }}" register: result tags: override @@ -62,7 +64,7 @@ tags: override - name: OVERRIDE - TC1 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -71,19 +73,25 @@ register: verify_result tags: override - - name: OVERRIDE - TC1 - ASSERT - Verify vPC pair state in ND + - name: OVERRIDE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_override_initial_conf }}" + register: validation + tags: override + + - name: OVERRIDE - TC1 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' + - validation.failed == false tags: override # TC2 - Override with same vPC switch pair with changes - name: OVERRIDE - TC2 - OVERRIDE - Override vPC pair with changes - cisco.nd.nd_vpc_pair: &conf_overridden + cisco.nd.nd_manage_vpc_pair: &conf_overridden fabric_name: "{{ test_fabric }}" state: overridden - config: "{{ test_data_overridden.vpc_pair_overridden_conf }}" + config: "{{ nd_vpc_pair_override_overridden_conf }}" register: result tags: override @@ -102,7 +110,7 @@ tags: override - name: OVERRIDE - TC2 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -111,18 +119,25 @@ register: verify_result tags: override - - name: OVERRIDE - TC2 - ASSERT - Verify vPC pair state in ND + - name: OVERRIDE - TC2 - VALIDATE - Verify overridden vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_override_overridden_conf }}" + mode: "full" + register: validation + when: test_fabric_type == "LANClassic" + tags: override + + - name: OVERRIDE - TC2 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' - - verify_result.gathered.vpc_pairs[0].use_virtual_peer_link == false + - validation.failed == false when: test_fabric_type == "LANClassic" tags: override # TC3 - Idempotence test - name: OVERRIDE - TC3 - conf - Idempotence - cisco.nd.nd_vpc_pair: *conf_overridden + cisco.nd.nd_manage_vpc_pair: *conf_overridden register: result tags: override @@ -135,7 +150,7 @@ # TC4 - Override existing vPC pair with no config (delete all) - name: OVERRIDE - TC4 - OVERRIDE - Delete all vPC pairs via override with no config - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: overridden config: [] @@ -149,22 +164,29 @@ tags: override - name: OVERRIDE - TC4 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" register: verify_result tags: override - - name: OVERRIDE - TC4 - ASSERT - Verify vPC pair deletion via override + - name: OVERRIDE - TC4 - VALIDATE - Verify vPC pair deletion via override + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: override + + - name: OVERRIDE - TC4 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 0' + - validation.failed == false tags: override # TC5 - Gather to verify deletion - name: OVERRIDE - TC5 - GATHER - Verify vPC pair deletion - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered config: @@ -179,7 +201,7 @@ # TC6 - Override with no vPC pair and no config (should be no-op) - name: OVERRIDE - TC6 - OVERRIDE - Override with no vPC pairs (no-op) - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: overridden config: [] @@ -193,17 +215,24 @@ tags: override - name: OVERRIDE - TC6 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" register: verify_result tags: override - - name: OVERRIDE - TC6 - ASSERT - Verify no-op override + - name: OVERRIDE - TC6 - VALIDATE - Verify no-op override + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: override + + - name: OVERRIDE - TC6 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 0' + - validation.failed == false tags: override ############################################## @@ -211,7 +240,7 @@ ############################################## - name: OVERRIDE - END - remove vPC pairs - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted when: cleanup_at_end | default(true) diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml index 0dbc1d4c..56c0f9c3 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml @@ -6,34 +6,42 @@ ## SETUP ## ############################################## - - name: REPLACE - Test Entry Point - [nd_vpc_pair] - ansible.builtin.debug: - msg: - - "----------------------------------------------------------------" - - "+ Executing Replace Tests - [nd_vpc_pair] +" - - "----------------------------------------------------------------" + - name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml tags: replace ############################################## - ## Setup Internal TestCase Variables ## + ## Setup Replace TestCase Variables ## ############################################## - - name: REPLACE - Setup Internal TestCase Variables + - name: REPLACE - Setup initial config ansible.builtin.set_fact: - deploy_local: true - test_fabric: "{{ fabric_name }}" - test_switch1: "{{ switch1_serial }}" - test_switch2: "{{ switch2_serial }}" - test_fabric_type: "{{ fabric_type | default('LANClassic') }}" - test_data_replaced: - vpc_pair_initial_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - use_virtual_peer_link: true - vpc_pair_replaced_conf: - - peer1_switch_id: "{{ switch1_serial }}" - peer2_switch_id: "{{ switch2_serial }}" - use_virtual_peer_link: false + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: replace + + - name: Import Configuration Prepare Tasks - replace_initial + vars: + file: replace_initial + import_tasks: conf_prep_tasks.yaml + tags: replace + + - name: REPLACE - Setup replaced config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: false + delegate_to: localhost + tags: replace + + - name: Import Configuration Prepare Tasks - replace_replaced + vars: + file: replace_replaced + import_tasks: conf_prep_tasks.yaml tags: replace ############################################## @@ -41,17 +49,11 @@ ############################################## # TC1 - Create initial vPC pair using replace state - - name: REPLACE - TC1 - DELETE - Clean up any existing vPC pairs - cisco.nd.nd_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - tags: replace - - name: REPLACE - TC1 - REPLACE - Create vPC pair using replace state - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: replaced - config: "{{ test_data_replaced.vpc_pair_initial_conf }}" + config: "{{ nd_vpc_pair_replace_initial_conf }}" register: result tags: replace @@ -62,7 +64,7 @@ tags: replace - name: REPLACE - TC1 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -71,19 +73,25 @@ register: verify_result tags: replace - - name: REPLACE - TC1 - ASSERT - Verify vPC pair state in ND + - name: REPLACE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_replace_initial_conf }}" + register: validation + tags: replace + + - name: REPLACE - TC1 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' + - validation.failed == false tags: replace # TC2 - Replace vPC pair configuration - name: REPLACE - TC2 - REPLACE - Replace vPC pair configuration - cisco.nd.nd_vpc_pair: &conf_replaced + cisco.nd.nd_manage_vpc_pair: &conf_replaced fabric_name: "{{ test_fabric }}" state: replaced - config: "{{ test_data_replaced.vpc_pair_replaced_conf }}" + config: "{{ nd_vpc_pair_replace_replaced_conf }}" register: result tags: replace @@ -102,7 +110,7 @@ tags: replace - name: REPLACE - TC2 - GATHER - Get vPC pair state in ND - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" config: @@ -111,18 +119,25 @@ register: verify_result tags: replace - - name: REPLACE - TC2 - ASSERT - Verify vPC pair state in ND + - name: REPLACE - TC2 - VALIDATE - Verify replaced vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_replace_replaced_conf }}" + mode: "full" + register: validation + when: test_fabric_type == "LANClassic" + tags: replace + + - name: REPLACE - TC2 - ASSERT - Validation passed ansible.builtin.assert: that: - - verify_result.failed == false - - '(verify_result.gathered.vpc_pairs | length) == 1' - - verify_result.gathered.vpc_pairs[0].use_virtual_peer_link == false + - validation.failed == false when: test_fabric_type == "LANClassic" tags: replace # TC3 - Idempotence test - name: REPLACE - TC3 - conf - Idempotence - cisco.nd.nd_vpc_pair: *conf_replaced + cisco.nd.nd_manage_vpc_pair: *conf_replaced register: result tags: replace @@ -138,7 +153,7 @@ ############################################## - name: REPLACE - END - remove vPC pairs - cisco.nd.nd_vpc_pair: + cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted when: cleanup_at_end | default(true) From 9df5e6a985c4f8e602ca9354cd450611aecf006a Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 12 Mar 2026 13:46:41 +0530 Subject: [PATCH 17/39] Intermediate fixes --- .../endpoints/v1/manage_vpc_pair/vpc_pair_resources.py | 2 +- plugins/module_utils/models/__init__.py | 4 ++++ .../targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml | 6 +++--- 3 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 plugins/module_utils/models/__init__.py diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py index 5e418293..257ad113 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List, Optional from ansible.module_utils.basic import AnsibleModule -from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import ( +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.nd_vpc_pair_orchestrator import ( NDStateMachine, ) from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_pair import ( diff --git a/plugins/module_utils/models/__init__.py b/plugins/module_utils/models/__init__.py new file mode 100644 index 00000000..7b839f18 --- /dev/null +++ b/plugins/module_utils/models/__init__.py @@ -0,0 +1,4 @@ +from .base import NDBaseModel +from .nested import NDNestedModel + +__all__ = ["NDBaseModel", "NDNestedModel"] diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml index 6e1fe950..5d0d8336 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml @@ -11,11 +11,11 @@ - name: Build vPC Pair Config Data from Template ansible.builtin.template: - src: nd_vpc_pair_conf.j2 - dest: "{{ role_path }}/files/nd_vpc_pair_{{ file }}_conf.yaml" + src: "{{ playbook_dir | dirname | dirname }}/templates/nd_vpc_pair_conf.j2" + dest: "{{ playbook_dir | dirname | dirname }}/files/nd_vpc_pair_{{ file }}_conf.yaml" delegate_to: localhost - name: Load Configuration Data into Variable ansible.builtin.set_fact: - "{{ 'nd_vpc_pair_' + file + '_conf' }}": "{{ lookup('file', role_path + '/files/nd_vpc_pair_' + file + '_conf.yaml') | from_yaml }}" + "{{ 'nd_vpc_pair_' + file + '_conf' }}": "{{ lookup('file', (playbook_dir | dirname | dirname) + '/files/nd_vpc_pair_' + file + '_conf.yaml') | from_yaml }}" delegate_to: localhost From ce6dd0104acda27eec204555152d5c1c967fd13a Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 12 Mar 2026 14:17:33 +0530 Subject: [PATCH 18/39] Intermediate changes --- .../v1/manage_vpc_pair/vpc_pair_endpoints.py | 47 ++++++++++++++++--- .../v1/manage_vpc_pair/vpc_pair_resources.py | 4 +- plugins/modules/nd_manage_vpc_pair.py | 10 ++-- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py index fd206ac2..770e3e25 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py @@ -55,7 +55,12 @@ # ============================================================================ -class _EpVpcPairBase(FabricNameMixin, SwitchIdMixin, FromClusterMixin, BaseModel): +class _EpVpcPairBase( + FabricNameMixin, + SwitchIdMixin, + FromClusterMixin, + BaseModel, +): """ Base class for VPC pair details endpoints. @@ -169,7 +174,13 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class EpVpcPairSupportGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, ComponentTypeMixin, BaseModel): +class EpVpcPairSupportGet( + FabricNameMixin, + SwitchIdMixin, + FromClusterMixin, + ComponentTypeMixin, + BaseModel, +): """ # Summary @@ -228,7 +239,13 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class EpVpcPairOverviewGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, ComponentTypeMixin, BaseModel): +class EpVpcPairOverviewGet( + FabricNameMixin, + SwitchIdMixin, + FromClusterMixin, + ComponentTypeMixin, + BaseModel, +): """ # Summary @@ -287,7 +304,12 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class EpVpcPairRecommendationGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, BaseModel): +class EpVpcPairRecommendationGet( + FabricNameMixin, + SwitchIdMixin, + FromClusterMixin, + BaseModel, +): """ # Summary @@ -347,7 +369,12 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class EpVpcPairConsistencyGet(FabricNameMixin, SwitchIdMixin, FromClusterMixin, BaseModel): +class EpVpcPairConsistencyGet( + FabricNameMixin, + SwitchIdMixin, + FromClusterMixin, + BaseModel, +): """ # Summary @@ -400,7 +427,15 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class EpVpcPairsListGet(FabricNameMixin, FromClusterMixin, FilterMixin, PaginationMixin, SortMixin, ViewMixin, BaseModel): +class EpVpcPairsListGet( + FabricNameMixin, + FromClusterMixin, + FilterMixin, + PaginationMixin, + SortMixin, + ViewMixin, + BaseModel, +): """ # Summary diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py index 257ad113..ed3ec86e 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py @@ -10,10 +10,10 @@ from typing import Any, Callable, Dict, List, Optional from ansible.module_utils.basic import AnsibleModule -from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.nd_vpc_pair_orchestrator import ( +from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import ( NDStateMachine, ) -from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_pair import ( +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.nd_vpc_pair_orchestrator import ( VpcPairOrchestrator, ) from pydantic import ValidationError diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index fa922919..dd7c7a0d 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -2639,7 +2639,7 @@ def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: "DATA": {}, } results.result_current = {"success": True, "changed": True} - results.register_task_result() + results.register_api_call() except NDModuleError as error: if _is_non_fatal_config_save_error(error): @@ -2654,7 +2654,7 @@ def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: "DATA": {}, } results.result_current = {"success": True, "changed": False} - results.register_task_result() + results.register_api_call() else: # Unknown config-save failures are fatal. results.response_current = { @@ -2665,7 +2665,7 @@ def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: "DATA": {}, } results.result_current = {"success": False, "changed": False} - results.register_task_result() + results.register_api_call() results.build_final_result() final_result = dict(results.final_result) final_msg = final_result.pop("msg", f"Config save failed: {error.msg}") @@ -2685,7 +2685,7 @@ def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: "DATA": {}, } results.result_current = {"success": True, "changed": True} - results.register_task_result() + results.register_api_call() except NDModuleError as error: results.response_current = { @@ -2696,7 +2696,7 @@ def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: "DATA": {}, } results.result_current = {"success": False, "changed": False} - results.register_task_result() + results.register_api_call() # Build final result and fail results.build_final_result() From 554f3fd64c6d054e2d227537dc62616a0fc86242 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 12 Mar 2026 19:54:28 +0530 Subject: [PATCH 19/39] Interim changes --- .../v1/manage_vpc_pair/base_paths.py | 27 ++++++++++--------- plugins/module_utils/vpc_pair/common.py | 25 +++++++++++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 plugins/module_utils/vpc_pair/common.py diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py index e9293974..ae77b1a6 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py @@ -26,6 +26,14 @@ from typing import Final +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ( + ApiPath, +) +from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.common import ( + build_path, + require_non_empty_str, +) + class VpcPairBasePath: """ @@ -60,7 +68,7 @@ class VpcPairBasePath: """ # Root API paths - MANAGE_API: Final = "/api/v1/manage" + MANAGE_API: Final = ApiPath.MANAGE.value @classmethod def manage(cls, *segments: str) -> str: @@ -84,9 +92,7 @@ def manage(cls, *segments: str) -> str: # Returns: /api/v1/manage/fabrics/Fabric1 ``` """ - if not segments: - return cls.MANAGE_API - return f"{cls.MANAGE_API}/{'/'.join(segments)}" + return build_path(cls.MANAGE_API, *segments) @classmethod def fabrics(cls, fabric_name: str, *segments: str) -> str: @@ -115,15 +121,12 @@ def fabrics(cls, fabric_name: str, *segments: str) -> str: # Returns: /api/v1/manage/fabrics/Fabric1/switches ``` """ - # Validate fabric_name - if not fabric_name or not isinstance(fabric_name, str) or not fabric_name.strip(): - raise ValueError( - f"VpcPairBasePath.fabrics(): fabric_name must be a non-empty string. " - f"Got: {fabric_name!r} (type: {type(fabric_name).__name__})" - ) + fabric_name = require_non_empty_str( + name="fabric_name", + value=fabric_name, + owner="VpcPairBasePath.fabrics()", + ) - if not segments: - return cls.manage("fabrics", fabric_name) return cls.manage("fabrics", fabric_name, *segments) @classmethod diff --git a/plugins/module_utils/vpc_pair/common.py b/plugins/module_utils/vpc_pair/common.py new file mode 100644 index 00000000..9e7e2a67 --- /dev/null +++ b/plugins/module_utils/vpc_pair/common.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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 + + +def build_path(base: str, *segments: str) -> str: + """Build a path from a base and optional path segments.""" + if not segments: + return base + return f"{base}/{'/'.join(segments)}" + + +def require_non_empty_str(name: str, value: Any, owner: str) -> str: + """Validate a required non-empty string parameter and return its stripped value.""" + if not value or not isinstance(value, str) or not value.strip(): + raise ValueError( + f"{owner}: {name} must be a non-empty string. " + f"Got: {value!r} (type: {type(value).__name__})" + ) + return value.strip() From f97be5af7c5e042c40c7817b823f30ab307c962b Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 12 Mar 2026 20:30:04 +0530 Subject: [PATCH 20/39] Integ test fixes --- .../v1/manage_vpc_pair/vpc_pair_resources.py | 68 + plugins/modules/nd_manage_vpc_pair.py | 107 +- .../targets/nd_vpc_pair/tasks/main.yaml | 38 +- .../nd_vpc_pair/tests/nd/conf_prep_tasks.yaml | 6 +- .../tests/nd/nd_vpc_pair_delete.yaml | 570 ++++--- .../tests/nd/nd_vpc_pair_gather.yaml | 570 ++++--- .../tests/nd/nd_vpc_pair_merge.yaml | 1454 ++++++++--------- .../tests/nd/nd_vpc_pair_override.yaml | 490 +++--- .../tests/nd/nd_vpc_pair_replace.yaml | 316 ++-- 9 files changed, 1885 insertions(+), 1734 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py index ed3ec86e..3b4d2992 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py @@ -43,6 +43,45 @@ def __init__(self, module: AnsibleModule): self.current_identifier = None self.existing_config: Dict[str, Any] = {} self.proposed_config: Dict[str, Any] = {} + self.logs: List[Dict[str, Any]] = [] + self.result: Dict[str, Any] = {} + + def format_log( + self, + identifier: Any, + status: str, + before_data: Optional[Any] = None, + after_data: Optional[Any] = None, + sent_payload_data: Optional[Any] = None, + ) -> None: + """Collect operation log entries expected by nd_manage_vpc_pair flows.""" + log_entry: Dict[str, Any] = {"identifier": identifier, "status": status} + if before_data is not None: + log_entry["before"] = before_data + if after_data is not None: + log_entry["after"] = after_data + if sent_payload_data is not None: + log_entry["sent_payload"] = sent_payload_data + self.logs.append(log_entry) + + def add_logs_and_outputs(self) -> None: + """ + Build final result payload compatible with nd_manage_vpc_pair runtime. + """ + self.output.assign( + after=getattr(self, "existing", None), + before=getattr(self, "before", None), + proposed=getattr(self, "proposed", None), + logs=self.logs, + ) + + formatted = self.output.format() + formatted.setdefault("current", formatted.get("after", [])) + formatted.setdefault("response", []) + formatted.setdefault("result", []) + if self.logs and "logs" not in formatted: + formatted["logs"] = self.logs + self.result = formatted def manage_state( self, @@ -151,6 +190,21 @@ def _manage_create_update_state(self, state: str, unwanted_keys: List) -> None: ), sent_payload_data=sent_payload, ) + except VpcPairResourceError as e: + # The error details from nd_manage_vpc_pair are dropped by + # State machine wrappers in vpc_pair_resources.py + # Here is the exception handling to capture those details + error_msg = f"Failed to process {identifier}: {e.msg}" + self.format_log( + identifier=identifier, + status="no_change", + after_data=self.existing_config, + ) + if not self.module.params.get("ignore_errors", False): + error_details = dict(getattr(e, "details", {}) or {}) + error_details.setdefault("identifier", str(identifier)) + error_details.setdefault("error", str(e)) + raise VpcPairResourceError(msg=error_msg, **error_details) except Exception as e: error_msg = f"Failed to process {identifier}: {e}" self.format_log( @@ -182,6 +236,13 @@ def _manage_override_deletions(self, override_exceptions: List) -> None: self.model_orchestrator.delete(existing_item) self.existing.delete(identifier) self.format_log(identifier=identifier, status="deleted", after_data={}) + except VpcPairResourceError as e: + error_msg = f"Failed to delete {identifier}: {e.msg}" + if not self.module.params.get("ignore_errors", False): + error_details = dict(getattr(e, "details", {}) or {}) + error_details.setdefault("identifier", str(identifier)) + error_details.setdefault("error", str(e)) + raise VpcPairResourceError(msg=error_msg, **error_details) except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" if not self.module.params.get("ignore_errors", False): @@ -207,6 +268,13 @@ def _manage_delete_state(self) -> None: self.model_orchestrator.delete(existing_item) self.existing.delete(identifier) self.format_log(identifier=identifier, status="deleted", after_data={}) + except VpcPairResourceError as e: + error_msg = f"Failed to delete {identifier}: {e.msg}" + if not self.module.params.get("ignore_errors", False): + error_details = dict(getattr(e, "details", {}) or {}) + error_details.setdefault("identifier", str(identifier)) + error_details.setdefault("error", str(e)) + raise VpcPairResourceError(msg=error_msg, **error_details) except Exception as e: error_msg = f"Failed to delete {identifier}: {e}" if not self.module.params.get("ignore_errors", False): diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index dd7c7a0d..4405fab9 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -286,18 +286,16 @@ # Static imports so Ansible's AnsiballZ packager includes these files in the # module zip. Keep them optional when framework files are intentionally absent. try: - from ansible_collections.cisco.nd.plugins.module_utils import nd_config_collection as _nd_config_collection # noqa: F401 - from ansible_collections.cisco.nd.plugins.module_utils import utils as _nd_utils # noqa: F401 + from ansible_collections.cisco.nd.plugins.module_utils import nd_config_collection as _nd_config_collection + from ansible_collections.cisco.nd.plugins.module_utils import utils as _nd_utils except Exception: # pragma: no cover - compatibility for stripped framework trees _nd_config_collection = None # noqa: F841 _nd_utils = None # noqa: F841 try: - # pre-PR172 layout from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDNestedModel except Exception: try: - # PR172 layout from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel except Exception: from pydantic import BaseModel as NDNestedModel @@ -1096,6 +1094,95 @@ def _extract_vpc_pairs_from_list_response(vpc_pairs_response: Any) -> List[Dict[ return extracted_pairs +def _enrich_pairs_from_direct_vpc( + nd_v2, + fabric_name: str, + pairs: List[Dict[str, Any]], + timeout: int = 5, +) -> List[Dict[str, Any]]: + """ + Enrich pair fields from per-switch /vpcPair endpoint when available. + + The /vpcPairs list response may omit fields like useVirtualPeerLink. + This helper preserves lightweight list discovery while improving field + accuracy for gathered output. + """ + if not pairs: + return [] + + enriched_pairs: List[Dict[str, Any]] = [] + for pair in pairs: + enriched = dict(pair) + switch_id = enriched.get(VpcFieldNames.SWITCH_ID) + if not switch_id: + enriched_pairs.append(enriched) + continue + + direct_vpc = None + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + direct_vpc = nd_v2.request(path, HttpVerbEnum.GET) + except (NDModuleError, Exception): + direct_vpc = None + finally: + rest_send.restore_settings() + + if isinstance(direct_vpc, dict): + peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) + if peer_switch_id: + enriched[VpcFieldNames.PEER_SWITCH_ID] = peer_switch_id + + use_virtual_peer_link = _get_api_field_value( + direct_vpc, + "useVirtualPeerLink", + enriched.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK), + ) + if use_virtual_peer_link is not None: + enriched[VpcFieldNames.USE_VIRTUAL_PEER_LINK] = use_virtual_peer_link + + enriched_pairs.append(enriched) + + return enriched_pairs + + +def _filter_stale_vpc_pairs( + nd_v2, + fabric_name: str, + pairs: List[Dict[str, Any]], + module, +) -> List[Dict[str, Any]]: + """ + Remove stale pairs using overview membership checks. + + `/vpcPairs` can briefly lag after unpair operations. We perform a lightweight + best-effort membership check and drop entries that are explicitly reported as + not part of a vPC pair. + """ + if not pairs: + return [] + + pruned_pairs: List[Dict[str, Any]] = [] + for pair in pairs: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) + if not switch_id: + pruned_pairs.append(pair) + continue + + membership = _is_switch_in_vpc_pair(nd_v2, fabric_name, switch_id, timeout=5) + if membership is False: + module.warn( + f"Excluding stale vPC pair entry for switch {switch_id} " + "because overview reports it is not in a vPC pair." + ) + continue + pruned_pairs.append(pair) + + return pruned_pairs + + def _get_pairing_support_details( nd_v2, fabric_name: str, @@ -1770,6 +1857,18 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic if list_query_succeeded: if state == "gathered": have = _filter_vpc_pairs_by_requested_config(have, config) + have = _enrich_pairs_from_direct_vpc( + nd_v2=nd_v2, + fabric_name=fabric_name, + pairs=have, + timeout=5, + ) + have = _filter_stale_vpc_pairs( + nd_v2=nd_v2, + fabric_name=fabric_name, + pairs=have, + module=nrm.module, + ) return _set_lightweight_context(have) nrm.module.warn( diff --git a/tests/integration/targets/nd_vpc_pair/tasks/main.yaml b/tests/integration/targets/nd_vpc_pair/tasks/main.yaml index 7ca96b8c..1ca161e9 100644 --- a/tests/integration/targets/nd_vpc_pair/tasks/main.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/main.yaml @@ -6,23 +6,27 @@ # ansible-playbook -i hosts.yaml tasks/main.yaml -e testcase=nd_vpc_pair_merge # run one # ansible-playbook -i hosts.yaml tasks/main.yaml --tags merge # run by tag -- name: Discover nd_vpc_pair test cases - ansible.builtin.find: - paths: "{{ role_path }}/tests/nd" - patterns: "{{ testcase | default('nd_vpc_pair_*') }}.yaml" - connection: local - register: nd_vpc_pair_testcases +- name: nd_vpc_pair integration tests + hosts: nd + gather_facts: false + tasks: + - name: Discover nd_vpc_pair test cases + ansible.builtin.find: + paths: "{{ playbook_dir }}/../tests/nd" + patterns: "{{ testcase | default('nd_vpc_pair_*') }}.yaml" + connection: local + register: nd_vpc_pair_testcases -- name: Build list of test items - ansible.builtin.set_fact: - test_items: "{{ nd_vpc_pair_testcases.files | map(attribute='path') | list }}" + - name: Build list of test items + ansible.builtin.set_fact: + test_items: "{{ nd_vpc_pair_testcases.files | map(attribute='path') | list }}" -- name: Display discovered tests - ansible.builtin.debug: - msg: "Discovered {{ test_items | length }} test file(s): {{ test_items | map('basename') | list }}" + - name: Display discovered tests + ansible.builtin.debug: + msg: "Discovered {{ test_items | length }} test file(s): {{ test_items | map('basename') | list }}" -- name: Run nd_vpc_pair test cases - ansible.builtin.include_tasks: "{{ test_case_to_run }}" - with_items: "{{ test_items }}" - loop_control: - loop_var: test_case_to_run + - name: Run nd_vpc_pair test cases + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml index 5d0d8336..ceb8fa7d 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml @@ -11,11 +11,11 @@ - name: Build vPC Pair Config Data from Template ansible.builtin.template: - src: "{{ playbook_dir | dirname | dirname }}/templates/nd_vpc_pair_conf.j2" - dest: "{{ playbook_dir | dirname | dirname }}/files/nd_vpc_pair_{{ file }}_conf.yaml" + src: "{{ playbook_dir | dirname }}/templates/nd_vpc_pair_conf.j2" + dest: "{{ playbook_dir | dirname }}/files/nd_vpc_pair_{{ file }}_conf.yaml" delegate_to: localhost - name: Load Configuration Data into Variable ansible.builtin.set_fact: - "{{ 'nd_vpc_pair_' + file + '_conf' }}": "{{ lookup('file', (playbook_dir | dirname | dirname) + '/files/nd_vpc_pair_' + file + '_conf.yaml') | from_yaml }}" + "{{ 'nd_vpc_pair_' + file + '_conf' }}": "{{ lookup('file', (playbook_dir | dirname) + '/files/nd_vpc_pair_' + file + '_conf.yaml') | from_yaml }}" delegate_to: localhost diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml index 4bf364e4..9865b02f 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml @@ -1,287 +1,283 @@ -- name: ND vPC pair delete tests - hosts: nd - gather_facts: false - tasks: - ############################################## - ## SETUP ## - ############################################## - - - name: Import nd_vpc_pair Base Tasks - import_tasks: base_tasks.yaml - tags: delete - - ############################################## - ## Setup Delete TestCase Variables ## - ############################################## - - - name: DELETE - Setup config - ansible.builtin.set_fact: - vpc_pair_conf: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: true - delegate_to: localhost - tags: delete - - - name: Import Configuration Prepare Tasks - delete_setup - vars: - file: delete_setup - import_tasks: conf_prep_tasks.yaml - tags: delete - - ############################################## - ## DELETE ## - ############################################## - - # TC1 - Setup: Create vPC pair for deletion tests - - name: DELETE - TC1 - MERGE - Create vPC pair for deletion testing - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: "{{ nd_vpc_pair_delete_setup_conf }}" - register: result - tags: delete - - - name: DELETE - TC1 - ASSERT - Check if creation successful - ansible.builtin.assert: - that: - - result.changed == true - - result.failed == false - tags: delete - - - name: DELETE - TC1 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: delete - - - name: DELETE - TC1 - VALIDATE - Verify vPC pair state in ND - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_delete_setup_conf }}" - register: validation - tags: delete - - - name: DELETE - TC1 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: delete - - # TC2 - Delete vPC pair with specific config - - name: DELETE - TC2 - DELETE - Delete vPC pair with specific peer config - cisco.nd.nd_manage_vpc_pair: &delete_specific - fabric_name: "{{ test_fabric }}" - state: deleted - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: result - tags: delete - - - name: DELETE - TC2 - ASSERT - Check if deletion successful - ansible.builtin.assert: - that: - - result.changed == true - - result.failed == false - tags: delete - - - name: DELETE - TC2 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: delete - - - name: DELETE - TC2 - VALIDATE - Verify vPC pair deletion - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: [] - mode: "count_only" - register: validation - tags: delete - - - name: DELETE - TC2 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: delete - - # TC3 - Idempotence test for deletion - - name: DELETE - TC3 - conf - Idempotence - cisco.nd.nd_manage_vpc_pair: *delete_specific - register: result - tags: delete - - - name: DELETE - TC3 - ASSERT - Check if changed flag is false - ansible.builtin.assert: - that: - - result.changed == false - - result.failed == false - tags: delete - - # TC4 - Create another vPC pair for bulk deletion test - - name: DELETE - TC4 - MERGE - Create vPC pair for bulk deletion testing - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: "{{ nd_vpc_pair_delete_setup_conf }}" - register: result - tags: delete - - - name: DELETE - TC4 - ASSERT - Check if creation successful - ansible.builtin.assert: - that: - - result.changed == true - - result.failed == false - tags: delete - - - name: DELETE - TC4 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: delete - - - name: DELETE - TC4 - VALIDATE - Verify vPC pair state in ND for bulk deletion setup - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_delete_setup_conf }}" - mode: "exists" - register: validation - tags: delete - - - name: DELETE - TC4 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: delete - - # TC5 - Delete all vPC pairs without specific config - - name: DELETE - TC5 - DELETE - Delete all vPC pairs without specific config - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - register: result - tags: delete - - - name: DELETE - TC5 - ASSERT - Check if bulk deletion successful - ansible.builtin.assert: - that: - - result.failed == false - - result.changed == true or (result.current | length) == 0 - tags: delete - - - name: DELETE - TC5 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - register: verify_result - tags: delete - - - name: DELETE - TC5 - VALIDATE - Verify bulk deletion - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: [] - mode: "count_only" - register: validation - tags: delete - - - name: DELETE - TC5 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: delete - - # TC6 - Delete from empty fabric (should be no-op) - - name: DELETE - TC6 - DELETE - Delete from empty fabric (no-op) - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - register: result - tags: delete - - - name: DELETE - TC6 - ASSERT - Check if no change occurred - ansible.builtin.assert: - that: - - result.changed == false - - result.failed == false - tags: delete - - # TC7 - Force deletion bypass path - - name: DELETE - TC7 - MERGE - Create vPC pair for force delete test - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: "{{ nd_vpc_pair_delete_setup_conf }}" - register: result - tags: delete - - - name: DELETE - TC7 - ASSERT - Verify setup creation for force test - ansible.builtin.assert: - that: - - result.failed == false - tags: delete - - - name: DELETE - TC7 - DELETE - Delete vPC pair with force true - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - force: true - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: result - tags: delete - - - name: DELETE - TC7 - ASSERT - Verify force delete execution - ansible.builtin.assert: - that: - - result.failed == false - - result.changed == true - tags: delete - - - name: DELETE - TC7 - GATHER - Verify force deletion result in ND - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: delete - - - name: DELETE - TC7 - VALIDATE - Confirm pair deleted with force - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: [] - mode: "count_only" - register: validation - tags: delete - - - name: DELETE - TC7 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: delete - - ############################################## - ## CLEAN-UP ## - ############################################## - - - name: DELETE - END - ensure clean state - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - when: cleanup_at_end | default(true) - tags: delete +############################################## +## SETUP ## +############################################## + +- name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml + tags: delete + +############################################## +## Setup Delete TestCase Variables ## +############################################## + +- name: DELETE - Setup config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: delete + +- name: Import Configuration Prepare Tasks - delete_setup + vars: + file: delete_setup + import_tasks: conf_prep_tasks.yaml + tags: delete + +############################################## +## DELETE ## +############################################## + +# TC1 - Setup: Create vPC pair for deletion tests +- name: DELETE - TC1 - MERGE - Create vPC pair for deletion testing + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_delete_setup_conf }}" + register: result + tags: delete + +- name: DELETE - TC1 - ASSERT - Check if creation successful + ansible.builtin.assert: + that: + - result.changed == true + - result.failed == false + tags: delete + +- name: DELETE - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + +- name: DELETE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_delete_setup_conf }}" + register: validation + tags: delete + +- name: DELETE - TC1 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: delete + +# TC2 - Delete vPC pair with specific config +- name: DELETE - TC2 - DELETE - Delete vPC pair with specific peer config + cisco.nd.nd_manage_vpc_pair: &delete_specific + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: delete + +- name: DELETE - TC2 - ASSERT - Check if deletion successful + ansible.builtin.assert: + that: + - result.changed == true + - result.failed == false + tags: delete + +- name: DELETE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + +- name: DELETE - TC2 - VALIDATE - Verify vPC pair deletion + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: delete + +- name: DELETE - TC2 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: delete + +# TC3 - Idempotence test for deletion +- name: DELETE - TC3 - conf - Idempotence + cisco.nd.nd_manage_vpc_pair: *delete_specific + register: result + tags: delete + +- name: DELETE - TC3 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: delete + +# TC4 - Create another vPC pair for bulk deletion test +- name: DELETE - TC4 - MERGE - Create vPC pair for bulk deletion testing + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_delete_setup_conf }}" + register: result + tags: delete + +- name: DELETE - TC4 - ASSERT - Check if creation successful + ansible.builtin.assert: + that: + - result.changed == true + - result.failed == false + tags: delete + +- name: DELETE - TC4 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + +- name: DELETE - TC4 - VALIDATE - Verify vPC pair state in ND for bulk deletion setup + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_delete_setup_conf }}" + mode: "exists" + register: validation + tags: delete + +- name: DELETE - TC4 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: delete + +# TC5 - Delete all vPC pairs without specific config +- name: DELETE - TC5 - DELETE - Delete all vPC pairs without specific config + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + register: result + tags: delete + +- name: DELETE - TC5 - ASSERT - Check if bulk deletion successful + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true or (result.current | length) == 0 + tags: delete + +- name: DELETE - TC5 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + register: verify_result + tags: delete + +- name: DELETE - TC5 - VALIDATE - Verify bulk deletion + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: delete + +- name: DELETE - TC5 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: delete + +# TC6 - Delete from empty fabric (should be no-op) +- name: DELETE - TC6 - DELETE - Delete from empty fabric (no-op) + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + register: result + tags: delete + +- name: DELETE - TC6 - ASSERT - Check if no change occurred + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: delete + +# TC7 - Force deletion bypass path +- name: DELETE - TC7 - MERGE - Create vPC pair for force delete test + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_delete_setup_conf }}" + register: result + tags: delete + +- name: DELETE - TC7 - ASSERT - Verify setup creation for force test + ansible.builtin.assert: + that: + - result.failed == false + tags: delete + +- name: DELETE - TC7 - DELETE - Delete vPC pair with force true + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + force: true + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: delete + +- name: DELETE - TC7 - ASSERT - Verify force delete execution + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: delete + +- name: DELETE - TC7 - GATHER - Verify force deletion result in ND + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: delete + +- name: DELETE - TC7 - VALIDATE - Confirm pair deleted with force + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: delete + +- name: DELETE - TC7 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: delete + +############################################## +## CLEAN-UP ## +############################################## + +- name: DELETE - END - ensure clean state + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: delete diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml index a6b7f58e..45758a70 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml @@ -1,287 +1,283 @@ -- name: ND vPC pair gather tests - hosts: nd - gather_facts: false - tasks: - ############################################## - ## SETUP ## - ############################################## - - - name: Import nd_vpc_pair Base Tasks - import_tasks: base_tasks.yaml - tags: gather - - ############################################## - ## Setup Gather TestCase Variables ## - ############################################## - - - name: GATHER - Setup config - ansible.builtin.set_fact: - vpc_pair_conf: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: true - delegate_to: localhost - tags: gather - - - name: Import Configuration Prepare Tasks - gather_setup - vars: - file: gather_setup - import_tasks: conf_prep_tasks.yaml - tags: gather - - ############################################## - ## GATHER ## - ############################################## - - # TC1 - Setup: Create vPC pair for gather tests - - name: GATHER - TC1 - MERGE - Create vPC pair for testing - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: "{{ nd_vpc_pair_gather_setup_conf }}" - register: result - tags: gather - - - name: GATHER - TC1 - ASSERT - Check if creation successful - ansible.builtin.assert: - that: - - result.failed == false - tags: gather - - - name: GATHER - TC1 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - register: verify_result - tags: gather - - - name: GATHER - TC1 - VALIDATE - Verify vPC pair state in ND - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_gather_setup_conf }}" - register: validation - tags: gather - - - name: GATHER - TC1 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: gather - - # TC2 - Gather with no filters - - name: GATHER - TC2 - GATHER - Gather all vPC pairs with no filters - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - register: result - tags: gather - - - name: GATHER - TC2 - ASSERT - Check gather results - ansible.builtin.assert: - that: - - result.failed == false - - '(result.gathered.vpc_pairs | length) == 1' - tags: gather - - # TC3 - Gather with both peers specified - - name: GATHER - TC3 - GATHER - Gather vPC pair with both peers specified - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: result - tags: gather - - - name: GATHER - TC3 - ASSERT - Check gather results with both peers - ansible.builtin.assert: - that: - - result.failed == false - - '(result.gathered.vpc_pairs | length) == 1' - tags: gather - - # TC4 - Gather with one peer specified (not supported in nd_manage_vpc_pair) - - name: GATHER - TC4 - GATHER - Gather vPC pair with one peer specified - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - config: - - peer1_switch_id: "{{ test_switch1 }}" - register: result - ignore_errors: true - tags: gather - - - name: GATHER - TC4 - ASSERT - Verify partial peer gather is rejected - ansible.builtin.assert: - that: - - result.failed == true - - result.msg is defined - tags: gather - - # TC5 - Gather with second peer specified (not supported in nd_manage_vpc_pair) - - name: GATHER - TC5 - GATHER - Gather vPC pair with second peer specified - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - config: - - peer2_switch_id: "{{ test_switch2 }}" - register: result - ignore_errors: true - tags: gather - - - name: GATHER - TC5 - ASSERT - Verify partial peer gather is rejected - ansible.builtin.assert: - that: - - result.failed == true - - result.msg is defined - tags: gather - - # TC6 - Gather with non-existent peer - - name: GATHER - TC6 - GATHER - Gather vPC pair with non-existent peer - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - config: - - peer1_switch_id: "INVALID_SERIAL" - peer2_switch_id: "{{ test_switch2 }}" - register: result - ignore_errors: true - tags: gather - - - name: GATHER - TC6 - ASSERT - Check gather results with non-existent peer - ansible.builtin.assert: - that: - - result.failed == false - tags: gather - - # TC7 - Gather with custom query_timeout - - name: GATHER - TC7 - GATHER - Gather with query_timeout override - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - query_timeout: 20 - register: result - tags: gather - - - name: GATHER - TC7 - ASSERT - Verify query_timeout path execution - ansible.builtin.assert: - that: - - result.failed == false - - result.gathered is defined - tags: gather - - # TC8 - gathered + deploy validation (must fail) - - name: GATHER - TC8 - GATHER - Gather with deploy enabled (invalid) - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - deploy: true - register: result - ignore_errors: true - tags: gather - - - name: GATHER - TC8 - ASSERT - Verify gathered+deploy validation - ansible.builtin.assert: - that: - - result.failed == true - - result.msg is search("Deploy parameter cannot be used") - tags: gather - - # TC9 - gathered + dry_run validation (must fail) - - name: GATHER - TC9 - GATHER - Gather with dry_run enabled (invalid) - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - dry_run: true - register: result - ignore_errors: true - tags: gather - - - name: GATHER - TC9 - ASSERT - Verify gathered+dry_run validation - ansible.builtin.assert: - that: - - result.failed == true - - result.msg is search("Dry_run parameter cannot be used") - tags: gather - - # TC10 - Validate /vpcPairs list API alignment with module gathered output - - name: GATHER - TC10 - LIST - Query vPC pairs list endpoint directly - cisco.nd.nd_rest: - path: "/api/v1/manage/fabrics/{{ test_fabric }}/vpcPairs" - method: get - register: vpc_pairs_list_result - tags: gather - - - name: GATHER - TC10 - GATHER - Query module gathered output for comparison - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - register: gathered_result - tags: gather - - - name: GATHER - TC10 - ASSERT - Verify list and gathered payload availability - ansible.builtin.assert: - that: - - vpc_pairs_list_result.failed == false - - vpc_pairs_list_result.current.vpcPairs is defined - - vpc_pairs_list_result.current.vpcPairs is sequence - - gathered_result.failed == false - - gathered_result.gathered.vpc_pairs is defined - tags: gather - - - name: GATHER - TC10 - ASSERT - Ensure each /vpcPairs entry appears in gathered output - ansible.builtin.assert: - that: - - >- - ( - ( - gathered_result.gathered.vpc_pairs - | selectattr('switch_id', 'equalto', item.switchId) - | selectattr('peer_switch_id', 'equalto', item.peerSwitchId) - | list - | length - ) > 0 - ) or - ( - ( - gathered_result.gathered.vpc_pairs - | selectattr('switch_id', 'equalto', item.peerSwitchId) - | selectattr('peer_switch_id', 'equalto', item.switchId) - | list - | length - ) > 0 - ) - loop: "{{ vpc_pairs_list_result.current.vpcPairs | default([]) }}" - tags: gather - - # TC11 - Validate normalized pair matching for reversed switch order - - name: GATHER - TC11 - GATHER - Gather with reversed/duplicate pair filters - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - - peer1_switch_id: "{{ test_switch2 }}" - peer2_switch_id: "{{ test_switch1 }}" - register: result - tags: gather - - - name: GATHER - TC11 - ASSERT - Verify one pair returned for reversed filters - ansible.builtin.assert: - that: - - result.failed == false - - '(result.gathered.vpc_pairs | length) == 1' - tags: gather - - ############################################## - ## CLEAN-UP ## - ############################################## - - - name: GATHER - END - remove vPC pairs - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - when: cleanup_at_end | default(true) - tags: gather +############################################## +## SETUP ## +############################################## + +- name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml + tags: gather + +############################################## +## Setup Gather TestCase Variables ## +############################################## + +- name: GATHER - Setup config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: gather + +- name: Import Configuration Prepare Tasks - gather_setup + vars: + file: gather_setup + import_tasks: conf_prep_tasks.yaml + tags: gather + +############################################## +## GATHER ## +############################################## + +# TC1 - Setup: Create vPC pair for gather tests +- name: GATHER - TC1 - MERGE - Create vPC pair for testing + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_gather_setup_conf }}" + register: result + tags: gather + +- name: GATHER - TC1 - ASSERT - Check if creation successful + ansible.builtin.assert: + that: + - result.failed == false + tags: gather + +- name: GATHER - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + tags: gather + +- name: GATHER - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_gather_setup_conf }}" + register: validation + tags: gather + +- name: GATHER - TC1 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: gather + +# TC2 - Gather with no filters +- name: GATHER - TC2 - GATHER - Gather all vPC pairs with no filters + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + register: result + tags: gather + +- name: GATHER - TC2 - ASSERT - Check gather results + ansible.builtin.assert: + that: + - result.failed == false + - '(result.gathered.vpc_pairs | length) == 1' + tags: gather + +# TC3 - Gather with both peers specified +- name: GATHER - TC3 - GATHER - Gather vPC pair with both peers specified + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: gather + +- name: GATHER - TC3 - ASSERT - Check gather results with both peers + ansible.builtin.assert: + that: + - result.failed == false + - '(result.gathered.vpc_pairs | length) == 1' + tags: gather + +# TC4 - Gather with one peer specified (not supported in nd_manage_vpc_pair) +- name: GATHER - TC4 - GATHER - Gather vPC pair with one peer specified + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + register: result + ignore_errors: true + tags: gather + +- name: GATHER - TC4 - ASSERT - Verify partial peer gather is rejected + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is defined + tags: gather + +# TC5 - Gather with second peer specified (not supported in nd_manage_vpc_pair) +- name: GATHER - TC5 - GATHER - Gather vPC pair with second peer specified + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer2_switch_id: "{{ test_switch2 }}" + register: result + ignore_errors: true + tags: gather + +- name: GATHER - TC5 - ASSERT - Verify partial peer gather is rejected + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is defined + tags: gather + +# TC6 - Gather with non-existent peer +- name: GATHER - TC6 - GATHER - Gather vPC pair with non-existent peer + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "INVALID_SERIAL" + peer2_switch_id: "{{ test_switch2 }}" + register: result + ignore_errors: true + tags: gather + +- name: GATHER - TC6 - ASSERT - Check gather results with non-existent peer + ansible.builtin.assert: + that: + - result.failed == false + tags: gather + +# TC7 - Gather with custom query_timeout +- name: GATHER - TC7 - GATHER - Gather with query_timeout override + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + query_timeout: 20 + register: result + tags: gather + +- name: GATHER - TC7 - ASSERT - Verify query_timeout path execution + ansible.builtin.assert: + that: + - result.failed == false + - result.gathered is defined + tags: gather + +# TC8 - gathered + deploy validation (must fail) +- name: GATHER - TC8 - GATHER - Gather with deploy enabled (invalid) + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + deploy: true + register: result + ignore_errors: true + tags: gather + +- name: GATHER - TC8 - ASSERT - Verify gathered+deploy validation + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is search("Deploy parameter cannot be used") + tags: gather + +# TC9 - gathered + dry_run validation (must fail) +- name: GATHER - TC9 - GATHER - Gather with dry_run enabled (invalid) + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + dry_run: true + register: result + ignore_errors: true + tags: gather + +- name: GATHER - TC9 - ASSERT - Verify gathered+dry_run validation + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is search("Dry_run parameter cannot be used") + tags: gather + +# TC10 - Validate /vpcPairs list API alignment with module gathered output +- name: GATHER - TC10 - LIST - Query vPC pairs list endpoint directly + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/vpcPairs" + method: get + register: vpc_pairs_list_result + tags: gather + +- name: GATHER - TC10 - GATHER - Query module gathered output for comparison + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + register: gathered_result + tags: gather + +- name: GATHER - TC10 - ASSERT - Verify list and gathered payload availability + ansible.builtin.assert: + that: + - vpc_pairs_list_result.failed == false + - vpc_pairs_list_result.current.vpcPairs is defined + - vpc_pairs_list_result.current.vpcPairs is sequence + - gathered_result.failed == false + - gathered_result.gathered.vpc_pairs is defined + tags: gather + +- name: GATHER - TC10 - ASSERT - Ensure each /vpcPairs entry appears in gathered output + ansible.builtin.assert: + that: + - >- + ( + ( + gathered_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', item.switchId) + | selectattr('peer_switch_id', 'equalto', item.peerSwitchId) + | list + | length + ) > 0 + ) or + ( + ( + gathered_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', item.peerSwitchId) + | selectattr('peer_switch_id', 'equalto', item.switchId) + | list + | length + ) > 0 + ) + loop: "{{ vpc_pairs_list_result.current.vpcPairs | default([]) }}" + tags: gather + +# TC11 - Validate normalized pair matching for reversed switch order +- name: GATHER - TC11 - GATHER - Gather with reversed/duplicate pair filters + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + - peer1_switch_id: "{{ test_switch2 }}" + peer2_switch_id: "{{ test_switch1 }}" + register: result + tags: gather + +- name: GATHER - TC11 - ASSERT - Verify one pair returned for reversed filters + ansible.builtin.assert: + that: + - result.failed == false + - '(result.gathered.vpc_pairs | length) == 1' + tags: gather + +############################################## +## CLEAN-UP ## +############################################## + +- name: GATHER - END - remove vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: gather diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml index c3e37cef..68f9e888 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml @@ -1,729 +1,725 @@ -- name: ND vPC pair merge tests - hosts: nd - gather_facts: false - tasks: - ############################################## - ## SETUP ## - ############################################## - - - name: Import nd_vpc_pair Base Tasks - import_tasks: base_tasks.yaml - tags: merge - - ############################################## - ## Setup Merge TestCase Variables ## - ############################################## - - - name: MERGE - Setup full config - ansible.builtin.set_fact: - vpc_pair_conf: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: true - delegate_to: localhost - tags: merge - - - name: Import Configuration Prepare Tasks - merge_full - vars: - file: merge_full - import_tasks: conf_prep_tasks.yaml - tags: merge - - - name: MERGE - Setup modified config - ansible.builtin.set_fact: - vpc_pair_conf: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: false - delegate_to: localhost - tags: merge - - - name: Import Configuration Prepare Tasks - merge_modified - vars: - file: merge_modified - import_tasks: conf_prep_tasks.yaml - tags: merge - - - name: MERGE - Setup minimal config - ansible.builtin.set_fact: - vpc_pair_conf: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - delegate_to: localhost - tags: merge - - - name: Import Configuration Prepare Tasks - merge_minimal - vars: - file: merge_minimal - import_tasks: conf_prep_tasks.yaml - tags: merge - - - name: MERGE - Setup no-deploy config - ansible.builtin.set_fact: - vpc_pair_conf: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: true - delegate_to: localhost - tags: merge - - - name: Import Configuration Prepare Tasks - merge_no_deploy - vars: - file: merge_no_deploy - import_tasks: conf_prep_tasks.yaml - tags: merge - - ############################################## - ## MERGE ## - ############################################## - - # TC1 - Create vPC pair with full configuration - - name: MERGE - TC1 - MERGE - Create vPC pair with full configuration - cisco.nd.nd_manage_vpc_pair: &conf_full - fabric_name: "{{ test_fabric }}" - state: merged - config: "{{ nd_vpc_pair_merge_full_conf }}" - register: result - tags: merge - - - name: MERGE - TC1 - ASSERT - Check if changed flag is true - ansible.builtin.assert: - that: - - result.failed == false - - result.changed == true - tags: merge - - - name: MERGE - TC1 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: merge - - - name: MERGE - TC1 - VALIDATE - Verify vPC pair state in ND - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_merge_full_conf }}" - changed: "{{ result.changed }}" - register: validation - tags: merge - - - name: MERGE - TC1 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: merge - - - name: MERGE - TC1 - conf - Idempotence - cisco.nd.nd_manage_vpc_pair: *conf_full - register: result - tags: merge - - - name: MERGE - TC1 - ASSERT - Check if changed flag is false - ansible.builtin.assert: - that: - - result.changed == false - - result.failed == false - tags: merge - - # TC2 - Modify existing vPC pair configuration - - name: MERGE - TC2 - MERGE - Modify vPC pair configuration - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: "{{ nd_vpc_pair_merge_modified_conf }}" - register: result - when: test_fabric_type == "LANClassic" - tags: merge - - - name: MERGE - TC2 - ASSERT - Check if changed flag is true - ansible.builtin.assert: - that: - - result.failed == false - when: test_fabric_type == "LANClassic" - tags: merge - - - name: MERGE - TC2 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - when: test_fabric_type == "LANClassic" - tags: merge - - - name: MERGE - TC2 - VALIDATE - Verify modified vPC pair state - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_merge_modified_conf }}" - mode: "full" - register: validation - when: test_fabric_type == "LANClassic" - tags: merge - - - name: MERGE - TC2 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - when: test_fabric_type == "LANClassic" - tags: merge - - # TC2b - VXLANFabric specific test - - name: MERGE - TC2b - MERGE - Merge vPC pair for VXLAN fabric - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: result - when: test_fabric_type == "VXLANFabric" - tags: merge - - - name: MERGE - TC2b - ASSERT - Check if changed flag is false for VXLAN - ansible.builtin.assert: - that: - - result.failed == false - when: test_fabric_type == "VXLANFabric" - tags: merge - - - name: MERGE - TC2b - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - register: verify_result - when: test_fabric_type == "VXLANFabric" - tags: merge - - # TC3 - Delete vPC pair - - name: MERGE - TC3 - DELETE - Delete vPC pair - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: result - tags: merge - - - name: MERGE - TC3 - ASSERT - Check if delete successfully - ansible.builtin.assert: - that: - - result.failed == false - tags: merge - - - name: MERGE - TC3 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: merge - - - name: MERGE - TC3 - ASSERT - Verify vPC pair deletion - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: [] - mode: "count_only" - register: validation - tags: merge - - - name: MERGE - TC3 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: merge - - # TC4 - Create vPC pair with minimal configuration - - name: MERGE - TC4 - MERGE - Create vPC pair with minimal configuration - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: "{{ nd_vpc_pair_merge_minimal_conf }}" - register: result - when: test_fabric_type == "LANClassic" - tags: merge - - - name: MERGE - TC4 - ASSERT - Check if changed flag is true - ansible.builtin.assert: - that: - - result.failed == false - when: test_fabric_type == "LANClassic" - tags: merge - - - name: MERGE - TC4 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - when: test_fabric_type == "LANClassic" - tags: merge - - - name: MERGE - TC4 - VALIDATE - Verify minimal vPC pair - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_merge_minimal_conf }}" - mode: "exists" - register: validation - when: test_fabric_type == "LANClassic" - tags: merge - - - name: MERGE - TC4 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - when: test_fabric_type == "LANClassic" - tags: merge - - # TC4b - Delete vPC pair after minimal test - - name: MERGE - TC4b - DELETE - Delete vPC pair after minimal test - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: result - when: test_fabric_type == "LANClassic" - tags: merge - - - name: MERGE - TC4b - ASSERT - Check if delete successfully - ansible.builtin.assert: - that: - - result.failed == false - when: test_fabric_type == "LANClassic" - tags: merge - - # TC5 - Create vPC pair with defaults (state omitted) - - name: MERGE - TC5 - MERGE - Create vPC pair with defaults - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - config: "{{ nd_vpc_pair_merge_minimal_conf }}" - register: result - tags: merge - - - name: MERGE - TC5 - ASSERT - Check if changed flag is true - ansible.builtin.assert: - that: - - result.failed == false - tags: merge - - - name: MERGE - TC5 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: merge - - - name: MERGE - TC5 - VALIDATE - Verify vPC pair state - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_merge_minimal_conf }}" - mode: "exists" - register: validation - tags: merge - - - name: MERGE - TC5 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: merge - - # TC5b - Delete vPC pair after defaults test - - name: MERGE - TC5b - DELETE - Delete vPC pair after defaults test - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: result - tags: merge - - - name: MERGE - TC5b - ASSERT - Check if delete successfully - ansible.builtin.assert: - that: - - result.failed == false - tags: merge - - # TC6 - Create vPC pair with deploy flag false - - name: MERGE - TC6 - MERGE - Create vPC pair with deploy false - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - deploy: false - config: "{{ nd_vpc_pair_merge_no_deploy_conf }}" - register: result - tags: merge - - - name: MERGE - TC6 - ASSERT - Check if changed flag is true and no deploy - ansible.builtin.assert: - that: - - result.failed == false - - result.deployment is not defined - tags: merge - - - name: MERGE - TC6 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: merge - - - name: MERGE - TC6 - VALIDATE - Verify vPC pair state - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_merge_no_deploy_conf }}" - register: validation - tags: merge - - - name: MERGE - TC6 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: merge - - # TC7 - Merge with vpc_pair_details default template settings - - name: MERGE - TC7 - MERGE - Update vPC pair with default vpc_pair_details - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: true - vpc_pair_details: - type: default - domain_id: 10 - switch_keep_alive_local_ip: "192.0.2.11" - peer_switch_keep_alive_local_ip: "192.0.2.12" - keep_alive_vrf: management - register: result - ignore_errors: true - tags: merge - - - name: MERGE - TC7 - ASSERT - Verify default vpc_pair_details path - ansible.builtin.assert: - that: - - result.failed == false or (result.failed == true and ("Failed to update VPC pair" in result.msg or "Failed to create VPC pair" in result.msg)) - tags: merge - - # TC8 - Merge with vpc_pair_details custom template settings - - name: MERGE - TC8 - MERGE - Update vPC pair with custom vpc_pair_details - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: true - vpc_pair_details: - type: custom - template_name: "my_custom_template" - template_config: - domainId: "20" - customConfig: "vpc domain 20" - register: result - ignore_errors: true - tags: merge - - - name: MERGE - TC8 - ASSERT - Verify custom vpc_pair_details path - ansible.builtin.assert: - that: - - result.failed == false or (result.failed == true and ("Failed to update VPC pair" in result.msg or "Failed to create VPC pair" in result.msg)) - tags: merge - - # TC9 - Test invalid configurations - - name: MERGE - TC9 - MERGE - Create vPC pair with invalid peer switch - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: - - peer1_switch_id: "INVALID_SERIAL" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: true - register: result - ignore_errors: true - tags: merge - - - name: MERGE - TC9 - ASSERT - Check invalid peer switch error - ansible.builtin.assert: - that: - - result.failed == true - - result.msg is defined - tags: merge - - # TC10 - Create vPC pair with deploy enabled (actual deployment path) - - name: MERGE - TC10 - DELETE - Ensure vPC pair is absent before deploy test - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - ignore_errors: true - tags: merge - - - name: MERGE - TC10 - PREP - Query fabric peering support for switch1 - cisco.nd.nd_rest: - path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch1 }}/vpcPairSupport?componentType=checkFabricPeeringSupport" - method: get - register: tc10_support_switch1 - ignore_errors: true - tags: merge - - - name: MERGE - TC10 - PREP - Query fabric peering support for switch2 - cisco.nd.nd_rest: - path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch2 }}/vpcPairSupport?componentType=checkFabricPeeringSupport" - method: get - register: tc10_support_switch2 - ignore_errors: true - tags: merge - - - name: MERGE - TC10 - PREP - Decide virtual peer link flag for deploy test - ansible.builtin.set_fact: - tc10_use_virtual_peer_link: >- - {{ - (not (tc10_support_switch1.failed | default(false))) and - (not (tc10_support_switch2.failed | default(false))) and - (tc10_support_switch1.current.isVpcFabricPeeringSupported | default(false) | bool) and - (tc10_support_switch2.current.isVpcFabricPeeringSupported | default(false) | bool) - }} - tags: merge - - - name: MERGE - TC10 - MERGE - Create vPC pair with deploy true - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - deploy: true - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: "{{ tc10_use_virtual_peer_link }}" - register: result - tags: merge - - - name: MERGE - TC10 - ASSERT - Verify deploy path execution - ansible.builtin.assert: - that: - - result.failed == false - - result.deployment is defined - tags: merge - - # TC11 - Delete with custom api_timeout - - name: MERGE - TC11 - DELETE - Delete vPC pair with api_timeout override - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - api_timeout: 60 - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: result - tags: merge - - - name: MERGE - TC11 - ASSERT - Verify api_timeout path execution - ansible.builtin.assert: - that: - - result.failed == false - tags: merge - - # TC12 - dry_run should not apply configuration changes - - name: MERGE - TC12 - DELETE - Ensure vPC pair is absent before dry_run test - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - ignore_errors: true - tags: merge - - - name: MERGE - TC12 - MERGE - Run dry_run create for vPC pair - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - dry_run: true - config: "{{ nd_vpc_pair_merge_full_conf }}" - register: result - tags: merge - - - name: MERGE - TC12 - ASSERT - Verify dry_run invocation succeeded - ansible.builtin.assert: - that: - - result.failed == false - tags: merge - - - name: MERGE - TC12 - GATHER - Verify dry_run did not create vPC pair - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: merge - - - name: MERGE - TC12 - VALIDATE - Confirm no persistent changes from dry_run - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: [] - mode: "count_only" - register: validation - tags: merge - - - name: MERGE - TC12 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: merge - - # TC13 - Native Ansible check_mode should not apply configuration changes - - name: MERGE - TC13 - MERGE - Run check_mode create for vPC pair - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: "{{ nd_vpc_pair_merge_full_conf }}" - check_mode: true - register: result - tags: merge - - - name: MERGE - TC13 - ASSERT - Verify check_mode invocation succeeded - ansible.builtin.assert: - that: - - result.failed == false - tags: merge - - - name: MERGE - TC13 - GATHER - Verify check_mode did not create vPC pair - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: merge - - - name: MERGE - TC13 - VALIDATE - Confirm no persistent changes from check_mode - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: [] - mode: "count_only" - register: validation - tags: merge - - - name: MERGE - TC13 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: merge - - # TC14 - Validate vpcPairSupport enforcement path (isPairingAllowed == false) - - name: MERGE - TC14 - PREP - Query fabric switches for support validation - cisco.nd.nd_rest: - path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches" - method: get - register: switches_result - tags: merge - - - name: MERGE - TC14 - PREP - Query pairing support for each switch - cisco.nd.nd_rest: - path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ item }}/vpcPairSupport?componentType=checkPairing" - method: get - loop: "{{ (switches_result.current.switches | default([])) | map(attribute='serialNumber') | select('defined') | list }}" - register: support_result - ignore_errors: true - tags: merge - - - name: MERGE - TC14 - PREP - Choose blocked and allowed switch candidates - ansible.builtin.set_fact: - blocked_switch_id: >- - {{ - ( - support_result.results - | selectattr('current', 'defined') - | selectattr('current.isPairingAllowed', 'defined') - | selectattr('current.isPairingAllowed', 'equalto', false) - | map(attribute='item') - | list - | first - ) | default('') - }} - allowed_switch_id: >- - {{ - ( - support_result.results - | selectattr('current', 'defined') - | selectattr('current.isPairingAllowed', 'defined') - | selectattr('current.isPairingAllowed', 'equalto', true) - | map(attribute='item') - | list - | first - ) | default('') - }} - tags: merge - - - name: MERGE - TC14 - ASSERT - Ensure support candidates are available - ansible.builtin.assert: - that: - - blocked_switch_id | length > 0 - - allowed_switch_id | length > 0 - - blocked_switch_id != allowed_switch_id - tags: merge - - - name: MERGE - TC14 - MERGE - Verify unsupported pairing is blocked by module - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: merged - config: - - peer1_switch_id: "{{ blocked_switch_id }}" - peer2_switch_id: "{{ allowed_switch_id }}" - use_virtual_peer_link: true - register: result - ignore_errors: true - tags: merge - - - name: MERGE - TC14 - ASSERT - Validate unsupported pairing failure details - ansible.builtin.assert: - that: - - result.failed == true - - > - ( - (result.msg is search("VPC pairing is not allowed for switch")) - and (result.support_details is defined) - and (result.support_details.isPairingAllowed == false) - ) - or - ( - (result.msg is search("Switch conflicts detected")) - and (result.conflicts is defined) - and ((result.conflicts | length) > 0) - ) - tags: merge - - ############################################## - ## CLEAN-UP ## - ############################################## - - - name: MERGE - END - remove vPC pairs - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - when: cleanup_at_end | default(true) - tags: merge +############################################## +## SETUP ## +############################################## + +- name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml + tags: merge + +############################################## +## Setup Merge TestCase Variables ## +############################################## + +- name: MERGE - Setup full config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_full + vars: + file: merge_full + import_tasks: conf_prep_tasks.yaml + tags: merge + +- name: MERGE - Setup modified config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: false + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_modified + vars: + file: merge_modified + import_tasks: conf_prep_tasks.yaml + tags: merge + +- name: MERGE - Setup minimal config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_minimal + vars: + file: merge_minimal + import_tasks: conf_prep_tasks.yaml + tags: merge + +- name: MERGE - Setup no-deploy config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: merge + +- name: Import Configuration Prepare Tasks - merge_no_deploy + vars: + file: merge_no_deploy + import_tasks: conf_prep_tasks.yaml + tags: merge + +############################################## +## MERGE ## +############################################## + +# TC1 - Create vPC pair with full configuration +- name: MERGE - TC1 - MERGE - Create vPC pair with full configuration + cisco.nd.nd_manage_vpc_pair: &conf_full + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_merge_full_conf }}" + register: result + tags: merge + +- name: MERGE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + - result.changed == true + tags: merge + +- name: MERGE - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_full_conf }}" + changed: "{{ result.changed }}" + register: validation + tags: merge + +- name: MERGE - TC1 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +- name: MERGE - TC1 - conf - Idempotence + cisco.nd.nd_manage_vpc_pair: *conf_full + register: result + tags: merge + +- name: MERGE - TC1 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: merge + +# TC2 - Modify existing vPC pair configuration +- name: MERGE - TC2 - MERGE - Modify vPC pair configuration + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_merge_modified_conf }}" + register: result + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC2 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC2 - VALIDATE - Verify modified vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_modified_conf }}" + mode: "full" + register: validation + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC2 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + +# TC2b - VXLANFabric specific test +- name: MERGE - TC2b - MERGE - Merge vPC pair for VXLAN fabric + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + when: test_fabric_type == "VXLANFabric" + tags: merge + +- name: MERGE - TC2b - ASSERT - Check if changed flag is false for VXLAN + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "VXLANFabric" + tags: merge + +- name: MERGE - TC2b - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + when: test_fabric_type == "VXLANFabric" + tags: merge + +# TC3 - Delete vPC pair +- name: MERGE - TC3 - DELETE - Delete vPC pair + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: merge + +- name: MERGE - TC3 - ASSERT - Check if delete successfully + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +- name: MERGE - TC3 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC3 - ASSERT - Verify vPC pair deletion + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: merge + +- name: MERGE - TC3 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +# TC4 - Create vPC pair with minimal configuration +- name: MERGE - TC4 - MERGE - Create vPC pair with minimal configuration + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_merge_minimal_conf }}" + register: result + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC4 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC4 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC4 - VALIDATE - Verify minimal vPC pair + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_minimal_conf }}" + mode: "exists" + register: validation + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC4 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + +# TC4b - Delete vPC pair after minimal test +- name: MERGE - TC4b - DELETE - Delete vPC pair after minimal test + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + when: test_fabric_type == "LANClassic" + tags: merge + +- name: MERGE - TC4b - ASSERT - Check if delete successfully + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: merge + +# TC5 - Create vPC pair with defaults (state omitted) +- name: MERGE - TC5 - MERGE - Create vPC pair with defaults + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + config: "{{ nd_vpc_pair_merge_minimal_conf }}" + register: result + tags: merge + +- name: MERGE - TC5 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +- name: MERGE - TC5 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC5 - VALIDATE - Verify vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_minimal_conf }}" + mode: "exists" + register: validation + tags: merge + +- name: MERGE - TC5 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +# TC5b - Delete vPC pair after defaults test +- name: MERGE - TC5b - DELETE - Delete vPC pair after defaults test + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: merge + +- name: MERGE - TC5b - ASSERT - Check if delete successfully + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +# TC6 - Create vPC pair with deploy flag false +- name: MERGE - TC6 - MERGE - Create vPC pair with deploy false + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + deploy: false + config: "{{ nd_vpc_pair_merge_no_deploy_conf }}" + register: result + tags: merge + +- name: MERGE - TC6 - ASSERT - Check if changed flag is true and no deploy + ansible.builtin.assert: + that: + - result.failed == false + - result.deployment is not defined + tags: merge + +- name: MERGE - TC6 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC6 - VALIDATE - Verify vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_merge_no_deploy_conf }}" + register: validation + tags: merge + +- name: MERGE - TC6 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +# TC7 - Merge with vpc_pair_details default template settings +- name: MERGE - TC7 - MERGE - Update vPC pair with default vpc_pair_details + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + vpc_pair_details: + type: default + domain_id: 10 + switch_keep_alive_local_ip: "192.0.2.11" + peer_switch_keep_alive_local_ip: "192.0.2.12" + keep_alive_vrf: management + register: result + ignore_errors: true + tags: merge + +- name: MERGE - TC7 - ASSERT - Verify default vpc_pair_details path + ansible.builtin.assert: + that: + - result.failed == false or (result.failed == true and ("Failed to update VPC pair" in result.msg or "Failed to create VPC pair" in result.msg)) + tags: merge + +# TC8 - Merge with vpc_pair_details custom template settings +- name: MERGE - TC8 - MERGE - Update vPC pair with custom vpc_pair_details + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + vpc_pair_details: + type: custom + template_name: "my_custom_template" + template_config: + domainId: "20" + customConfig: "vpc domain 20" + register: result + ignore_errors: true + tags: merge + +- name: MERGE - TC8 - ASSERT - Verify custom vpc_pair_details path + ansible.builtin.assert: + that: + - result.failed == false or (result.failed == true and ("Failed to update VPC pair" in result.msg or "Failed to create VPC pair" in result.msg)) + tags: merge + +# TC9 - Test invalid configurations +- name: MERGE - TC9 - MERGE - Create vPC pair with invalid peer switch + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "INVALID_SERIAL" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + register: result + ignore_errors: true + tags: merge + +- name: MERGE - TC9 - ASSERT - Check invalid peer switch error + ansible.builtin.assert: + that: + - result.failed == true + - result.msg is defined + tags: merge + +# TC10 - Create vPC pair with deploy enabled (actual deployment path) +- name: MERGE - TC10 - DELETE - Ensure vPC pair is absent before deploy test + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + ignore_errors: true + tags: merge + +- name: MERGE - TC10 - PREP - Query fabric peering support for switch1 + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch1 }}/vpcPairSupport?componentType=checkFabricPeeringSupport" + method: get + register: tc10_support_switch1 + ignore_errors: true + tags: merge + +- name: MERGE - TC10 - PREP - Query fabric peering support for switch2 + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch2 }}/vpcPairSupport?componentType=checkFabricPeeringSupport" + method: get + register: tc10_support_switch2 + ignore_errors: true + tags: merge + +- name: MERGE - TC10 - PREP - Decide virtual peer link flag for deploy test + ansible.builtin.set_fact: + tc10_use_virtual_peer_link: >- + {{ + (not (tc10_support_switch1.failed | default(false))) and + (not (tc10_support_switch2.failed | default(false))) and + (tc10_support_switch1.current.isVpcFabricPeeringSupported | default(false) | bool) and + (tc10_support_switch2.current.isVpcFabricPeeringSupported | default(false) | bool) + }} + tags: merge + +- name: MERGE - TC10 - MERGE - Create vPC pair with deploy true + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + deploy: true + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: "{{ tc10_use_virtual_peer_link }}" + register: result + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify deploy path execution + ansible.builtin.assert: + that: + - result.failed == false + - result.deployment is defined + tags: merge + +# TC11 - Delete with custom api_timeout +- name: MERGE - TC11 - DELETE - Delete vPC pair with api_timeout override + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + api_timeout: 60 + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + tags: merge + +- name: MERGE - TC11 - ASSERT - Verify api_timeout path execution + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +# TC12 - dry_run should not apply configuration changes +- name: MERGE - TC12 - DELETE - Ensure vPC pair is absent before dry_run test + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + ignore_errors: true + tags: merge + +- name: MERGE - TC12 - MERGE - Run dry_run create for vPC pair + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + dry_run: true + config: "{{ nd_vpc_pair_merge_full_conf }}" + register: result + tags: merge + +- name: MERGE - TC12 - ASSERT - Verify dry_run invocation succeeded + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +- name: MERGE - TC12 - GATHER - Verify dry_run did not create vPC pair + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC12 - VALIDATE - Confirm no persistent changes from dry_run + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: merge + +- name: MERGE - TC12 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +# TC13 - Native Ansible check_mode should not apply configuration changes +- name: MERGE - TC13 - MERGE - Run check_mode create for vPC pair + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: "{{ nd_vpc_pair_merge_full_conf }}" + check_mode: true + register: result + tags: merge + +- name: MERGE - TC13 - ASSERT - Verify check_mode invocation succeeded + ansible.builtin.assert: + that: + - result.failed == false + tags: merge + +- name: MERGE - TC13 - GATHER - Verify check_mode did not create vPC pair + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: merge + +- name: MERGE - TC13 - VALIDATE - Confirm no persistent changes from check_mode + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: merge + +- name: MERGE - TC13 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: merge + +# TC14 - Validate vpcPairSupport enforcement path (isPairingAllowed == false) +- name: MERGE - TC14 - PREP - Query fabric switches for support validation + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches" + method: get + register: switches_result + tags: merge + +- name: MERGE - TC14 - PREP - Query pairing support for each switch + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ item }}/vpcPairSupport?componentType=checkPairing" + method: get + loop: "{{ (switches_result.current.switches | default([])) | map(attribute='serialNumber') | select('defined') | list }}" + register: support_result + ignore_errors: true + tags: merge + +- name: MERGE - TC14 - PREP - Choose blocked and allowed switch candidates + ansible.builtin.set_fact: + blocked_switch_id: >- + {{ + ( + support_result.results + | selectattr('current', 'defined') + | selectattr('current.isPairingAllowed', 'defined') + | selectattr('current.isPairingAllowed', 'equalto', false) + | map(attribute='item') + | list + | first + ) | default('') + }} + allowed_switch_id: >- + {{ + ( + support_result.results + | selectattr('current', 'defined') + | selectattr('current.isPairingAllowed', 'defined') + | selectattr('current.isPairingAllowed', 'equalto', true) + | map(attribute='item') + | list + | first + ) | default('') + }} + tags: merge + +- name: MERGE - TC14 - ASSERT - Ensure support candidates are available + ansible.builtin.assert: + that: + - blocked_switch_id | length > 0 + - allowed_switch_id | length > 0 + - blocked_switch_id != allowed_switch_id + tags: merge + +- name: MERGE - TC14 - MERGE - Verify unsupported pairing is blocked by module + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: merged + config: + - peer1_switch_id: "{{ blocked_switch_id }}" + peer2_switch_id: "{{ allowed_switch_id }}" + use_virtual_peer_link: true + register: result + ignore_errors: true + tags: merge + +- name: MERGE - TC14 - ASSERT - Validate unsupported pairing failure details + ansible.builtin.assert: + that: + - result.failed == true + - > + ( + (result.msg is search("VPC pairing is not allowed for switch")) + and (result.support_details is defined) + and (result.support_details.isPairingAllowed == false) + ) + or + ( + (result.msg is search("Switch conflicts detected")) + and (result.conflicts is defined) + and ((result.conflicts | length) > 0) + ) + tags: merge + +############################################## +## CLEAN-UP ## +############################################## + +- name: MERGE - END - remove vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: merge diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml index f8137d7b..a6a1e406 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml @@ -1,247 +1,243 @@ -- name: ND vPC pair override tests - hosts: nd - gather_facts: false - tasks: - ############################################## - ## SETUP ## - ############################################## - - - name: Import nd_vpc_pair Base Tasks - import_tasks: base_tasks.yaml - tags: override - - ############################################## - ## Setup Override TestCase Variables ## - ############################################## - - - name: OVERRIDE - Setup initial config - ansible.builtin.set_fact: - vpc_pair_conf: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: true - delegate_to: localhost - tags: override - - - name: Import Configuration Prepare Tasks - override_initial - vars: - file: override_initial - import_tasks: conf_prep_tasks.yaml - tags: override - - - name: OVERRIDE - Setup overridden config - ansible.builtin.set_fact: - vpc_pair_conf: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: false - delegate_to: localhost - tags: override - - - name: Import Configuration Prepare Tasks - override_overridden - vars: - file: override_overridden - import_tasks: conf_prep_tasks.yaml - tags: override - - ############################################## - ## OVERRIDE ## - ############################################## - - # TC1 - Override with a new vPC switch pair - - name: OVERRIDE - TC1 - OVERRIDE - Create vPC pair using override state - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: overridden - config: "{{ nd_vpc_pair_override_initial_conf }}" - register: result - tags: override - - - name: OVERRIDE - TC1 - ASSERT - Check if changed flag is true - ansible.builtin.assert: - that: - - result.failed == false - tags: override - - - name: OVERRIDE - TC1 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: override - - - name: OVERRIDE - TC1 - VALIDATE - Verify vPC pair state in ND - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_override_initial_conf }}" - register: validation - tags: override - - - name: OVERRIDE - TC1 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: override - - # TC2 - Override with same vPC switch pair with changes - - name: OVERRIDE - TC2 - OVERRIDE - Override vPC pair with changes - cisco.nd.nd_manage_vpc_pair: &conf_overridden - fabric_name: "{{ test_fabric }}" - state: overridden - config: "{{ nd_vpc_pair_override_overridden_conf }}" - register: result - tags: override - - - name: OVERRIDE - TC2 - ASSERT - Check if changed flag is true for LANClassic - ansible.builtin.assert: - that: - - result.failed == false - when: test_fabric_type == "LANClassic" - tags: override - - - name: OVERRIDE - TC2 - ASSERT - Check if changed flag is false for VXLANFabric - ansible.builtin.assert: - that: - - result.failed == false - when: test_fabric_type == "VXLANFabric" - tags: override - - - name: OVERRIDE - TC2 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: override - - - name: OVERRIDE - TC2 - VALIDATE - Verify overridden vPC pair state - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_override_overridden_conf }}" - mode: "full" - register: validation - when: test_fabric_type == "LANClassic" - tags: override - - - name: OVERRIDE - TC2 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - when: test_fabric_type == "LANClassic" - tags: override - - # TC3 - Idempotence test - - name: OVERRIDE - TC3 - conf - Idempotence - cisco.nd.nd_manage_vpc_pair: *conf_overridden - register: result - tags: override - - - name: OVERRIDE - TC3 - ASSERT - Check if changed flag is false - ansible.builtin.assert: - that: - - result.changed == false - - result.failed == false - tags: override - - # TC4 - Override existing vPC pair with no config (delete all) - - name: OVERRIDE - TC4 - OVERRIDE - Delete all vPC pairs via override with no config - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: overridden - config: [] - register: result - tags: override - - - name: OVERRIDE - TC4 - ASSERT - Check if deletion successful - ansible.builtin.assert: - that: - - result.failed == false - tags: override - - - name: OVERRIDE - TC4 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - register: verify_result - tags: override - - - name: OVERRIDE - TC4 - VALIDATE - Verify vPC pair deletion via override - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: [] - mode: "count_only" - register: validation - tags: override - - - name: OVERRIDE - TC4 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: override - - # TC5 - Gather to verify deletion - - name: OVERRIDE - TC5 - GATHER - Verify vPC pair deletion - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: gathered - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: result - until: - - '(result.gathered.vpc_pairs | length) == 0' - retries: 30 - delay: 5 - tags: override - - # TC6 - Override with no vPC pair and no config (should be no-op) - - name: OVERRIDE - TC6 - OVERRIDE - Override with no vPC pairs (no-op) - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: overridden - config: [] - register: result - tags: override - - - name: OVERRIDE - TC6 - ASSERT - Check if no change occurred - ansible.builtin.assert: - that: - - result.failed == false - tags: override - - - name: OVERRIDE - TC6 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - register: verify_result - tags: override - - - name: OVERRIDE - TC6 - VALIDATE - Verify no-op override - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: [] - mode: "count_only" - register: validation - tags: override - - - name: OVERRIDE - TC6 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: override - - ############################################## - ## CLEAN-UP ## - ############################################## - - - name: OVERRIDE - END - remove vPC pairs - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - when: cleanup_at_end | default(true) - tags: override +############################################## +## SETUP ## +############################################## + +- name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml + tags: override + +############################################## +## Setup Override TestCase Variables ## +############################################## + +- name: OVERRIDE - Setup initial config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: override + +- name: Import Configuration Prepare Tasks - override_initial + vars: + file: override_initial + import_tasks: conf_prep_tasks.yaml + tags: override + +- name: OVERRIDE - Setup overridden config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: false + delegate_to: localhost + tags: override + +- name: Import Configuration Prepare Tasks - override_overridden + vars: + file: override_overridden + import_tasks: conf_prep_tasks.yaml + tags: override + +############################################## +## OVERRIDE ## +############################################## + +# TC1 - Override with a new vPC switch pair +- name: OVERRIDE - TC1 - OVERRIDE - Create vPC pair using override state + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: overridden + config: "{{ nd_vpc_pair_override_initial_conf }}" + register: result + tags: override + +- name: OVERRIDE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + tags: override + +- name: OVERRIDE - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_override_initial_conf }}" + register: validation + tags: override + +- name: OVERRIDE - TC1 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: override + +# TC2 - Override with same vPC switch pair with changes +- name: OVERRIDE - TC2 - OVERRIDE - Override vPC pair with changes + cisco.nd.nd_manage_vpc_pair: &conf_overridden + fabric_name: "{{ test_fabric }}" + state: overridden + config: "{{ nd_vpc_pair_override_overridden_conf }}" + register: result + tags: override + +- name: OVERRIDE - TC2 - ASSERT - Check if changed flag is true for LANClassic + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: override + +- name: OVERRIDE - TC2 - ASSERT - Check if changed flag is false for VXLANFabric + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "VXLANFabric" + tags: override + +- name: OVERRIDE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC2 - VALIDATE - Verify overridden vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_override_overridden_conf }}" + mode: "full" + register: validation + when: test_fabric_type == "LANClassic" + tags: override + +- name: OVERRIDE - TC2 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + when: test_fabric_type == "LANClassic" + tags: override + +# TC3 - Idempotence test +- name: OVERRIDE - TC3 - conf - Idempotence + cisco.nd.nd_manage_vpc_pair: *conf_overridden + register: result + tags: override + +- name: OVERRIDE - TC3 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: override + +# TC4 - Override existing vPC pair with no config (delete all) +- name: OVERRIDE - TC4 - OVERRIDE - Delete all vPC pairs via override with no config + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: overridden + config: [] + register: result + tags: override + +- name: OVERRIDE - TC4 - ASSERT - Check if deletion successful + ansible.builtin.assert: + that: + - result.failed == false + tags: override + +- name: OVERRIDE - TC4 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC4 - VALIDATE - Verify vPC pair deletion via override + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: override + +- name: OVERRIDE - TC4 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: override + +# TC5 - Gather to verify deletion +- name: OVERRIDE - TC5 - GATHER - Verify vPC pair deletion + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: gathered + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: result + until: + - '(result.gathered.vpc_pairs | length) == 0' + retries: 30 + delay: 5 + tags: override + +# TC6 - Override with no vPC pair and no config (should be no-op) +- name: OVERRIDE - TC6 - OVERRIDE - Override with no vPC pairs (no-op) + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: overridden + config: [] + register: result + tags: override + +- name: OVERRIDE - TC6 - ASSERT - Check if no change occurred + ansible.builtin.assert: + that: + - result.failed == false + tags: override + +- name: OVERRIDE - TC6 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + register: verify_result + tags: override + +- name: OVERRIDE - TC6 - VALIDATE - Verify no-op override + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: [] + mode: "count_only" + register: validation + tags: override + +- name: OVERRIDE - TC6 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: override + +############################################## +## CLEAN-UP ## +############################################## + +- name: OVERRIDE - END - remove vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: override diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml index 56c0f9c3..fbf61b39 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml @@ -1,160 +1,156 @@ -- name: ND vPC pair replace tests - hosts: nd - gather_facts: false - tasks: - ############################################## - ## SETUP ## - ############################################## - - - name: Import nd_vpc_pair Base Tasks - import_tasks: base_tasks.yaml - tags: replace - - ############################################## - ## Setup Replace TestCase Variables ## - ############################################## - - - name: REPLACE - Setup initial config - ansible.builtin.set_fact: - vpc_pair_conf: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: true - delegate_to: localhost - tags: replace - - - name: Import Configuration Prepare Tasks - replace_initial - vars: - file: replace_initial - import_tasks: conf_prep_tasks.yaml - tags: replace - - - name: REPLACE - Setup replaced config - ansible.builtin.set_fact: - vpc_pair_conf: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: false - delegate_to: localhost - tags: replace - - - name: Import Configuration Prepare Tasks - replace_replaced - vars: - file: replace_replaced - import_tasks: conf_prep_tasks.yaml - tags: replace - - ############################################## - ## REPLACE ## - ############################################## - - # TC1 - Create initial vPC pair using replace state - - name: REPLACE - TC1 - REPLACE - Create vPC pair using replace state - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: replaced - config: "{{ nd_vpc_pair_replace_initial_conf }}" - register: result - tags: replace - - - name: REPLACE - TC1 - ASSERT - Check if changed flag is true - ansible.builtin.assert: - that: - - result.failed == false - tags: replace - - - name: REPLACE - TC1 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: replace - - - name: REPLACE - TC1 - VALIDATE - Verify vPC pair state in ND - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_replace_initial_conf }}" - register: validation - tags: replace - - - name: REPLACE - TC1 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - tags: replace - - # TC2 - Replace vPC pair configuration - - name: REPLACE - TC2 - REPLACE - Replace vPC pair configuration - cisco.nd.nd_manage_vpc_pair: &conf_replaced - fabric_name: "{{ test_fabric }}" - state: replaced - config: "{{ nd_vpc_pair_replace_replaced_conf }}" - register: result - tags: replace - - - name: REPLACE - TC2 - ASSERT - Check if changed flag is true for LANClassic - ansible.builtin.assert: - that: - - result.failed == false - when: test_fabric_type == "LANClassic" - tags: replace - - - name: REPLACE - TC2 - ASSERT - Check if changed flag is false for VXLANFabric - ansible.builtin.assert: - that: - - result.failed == false - when: test_fabric_type == "VXLANFabric" - tags: replace - - - name: REPLACE - TC2 - GATHER - Get vPC pair state in ND - cisco.nd.nd_manage_vpc_pair: - state: gathered - fabric_name: "{{ test_fabric }}" - config: - - peer1_switch_id: "{{ test_switch1 }}" - peer2_switch_id: "{{ test_switch2 }}" - register: verify_result - tags: replace - - - name: REPLACE - TC2 - VALIDATE - Verify replaced vPC pair state - cisco.nd.tests.integration.nd_vpc_pair_validate: - gathered_data: "{{ verify_result }}" - expected_data: "{{ nd_vpc_pair_replace_replaced_conf }}" - mode: "full" - register: validation - when: test_fabric_type == "LANClassic" - tags: replace - - - name: REPLACE - TC2 - ASSERT - Validation passed - ansible.builtin.assert: - that: - - validation.failed == false - when: test_fabric_type == "LANClassic" - tags: replace - - # TC3 - Idempotence test - - name: REPLACE - TC3 - conf - Idempotence - cisco.nd.nd_manage_vpc_pair: *conf_replaced - register: result - tags: replace - - - name: REPLACE - TC3 - ASSERT - Check if changed flag is false - ansible.builtin.assert: - that: - - result.changed == false - - result.failed == false - tags: replace - - ############################################## - ## CLEAN-UP ## - ############################################## - - - name: REPLACE - END - remove vPC pairs - cisco.nd.nd_manage_vpc_pair: - fabric_name: "{{ test_fabric }}" - state: deleted - when: cleanup_at_end | default(true) - tags: replace +############################################## +## SETUP ## +############################################## + +- name: Import nd_vpc_pair Base Tasks + import_tasks: base_tasks.yaml + tags: replace + +############################################## +## Setup Replace TestCase Variables ## +############################################## + +- name: REPLACE - Setup initial config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: true + delegate_to: localhost + tags: replace + +- name: Import Configuration Prepare Tasks - replace_initial + vars: + file: replace_initial + import_tasks: conf_prep_tasks.yaml + tags: replace + +- name: REPLACE - Setup replaced config + ansible.builtin.set_fact: + vpc_pair_conf: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + use_virtual_peer_link: false + delegate_to: localhost + tags: replace + +- name: Import Configuration Prepare Tasks - replace_replaced + vars: + file: replace_replaced + import_tasks: conf_prep_tasks.yaml + tags: replace + +############################################## +## REPLACE ## +############################################## + +# TC1 - Create initial vPC pair using replace state +- name: REPLACE - TC1 - REPLACE - Create vPC pair using replace state + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: replaced + config: "{{ nd_vpc_pair_replace_initial_conf }}" + register: result + tags: replace + +- name: REPLACE - TC1 - ASSERT - Check if changed flag is true + ansible.builtin.assert: + that: + - result.failed == false + tags: replace + +- name: REPLACE - TC1 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: replace + +- name: REPLACE - TC1 - VALIDATE - Verify vPC pair state in ND + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_replace_initial_conf }}" + register: validation + tags: replace + +- name: REPLACE - TC1 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + tags: replace + +# TC2 - Replace vPC pair configuration +- name: REPLACE - TC2 - REPLACE - Replace vPC pair configuration + cisco.nd.nd_manage_vpc_pair: &conf_replaced + fabric_name: "{{ test_fabric }}" + state: replaced + config: "{{ nd_vpc_pair_replace_replaced_conf }}" + register: result + tags: replace + +- name: REPLACE - TC2 - ASSERT - Check if changed flag is true for LANClassic + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "LANClassic" + tags: replace + +- name: REPLACE - TC2 - ASSERT - Check if changed flag is false for VXLANFabric + ansible.builtin.assert: + that: + - result.failed == false + when: test_fabric_type == "VXLANFabric" + tags: replace + +- name: REPLACE - TC2 - GATHER - Get vPC pair state in ND + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: verify_result + tags: replace + +- name: REPLACE - TC2 - VALIDATE - Verify replaced vPC pair state + cisco.nd.tests.integration.nd_vpc_pair_validate: + gathered_data: "{{ verify_result }}" + expected_data: "{{ nd_vpc_pair_replace_replaced_conf }}" + mode: "full" + register: validation + when: test_fabric_type == "LANClassic" + tags: replace + +- name: REPLACE - TC2 - ASSERT - Validation passed + ansible.builtin.assert: + that: + - validation.failed == false + when: test_fabric_type == "LANClassic" + tags: replace + +# TC3 - Idempotence test +- name: REPLACE - TC3 - conf - Idempotence + cisco.nd.nd_manage_vpc_pair: *conf_replaced + register: result + tags: replace + +- name: REPLACE - TC3 - ASSERT - Check if changed flag is false + ansible.builtin.assert: + that: + - result.changed == false + - result.failed == false + tags: replace + +############################################## +## CLEAN-UP ## +############################################## + +- name: REPLACE - END - remove vPC pairs + cisco.nd.nd_manage_vpc_pair: + fabric_name: "{{ test_fabric }}" + state: deleted + when: cleanup_at_end | default(true) + tags: replace From 722c58384a7f64d1f1575a12d42bcbed4b4cf7e8 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Fri, 13 Mar 2026 11:20:47 +0530 Subject: [PATCH 21/39] Adhering to the latest changes --- .../endpoints/v1/manage_vpc_pair/__init__.py | 1 - .../v1/manage_vpc_pair/base_paths.py | 38 +++++-- .../endpoints/v1/manage_vpc_pair/enums.py | 1 - .../endpoints/v1/manage_vpc_pair/mixins.py | 102 +++++------------- .../v1/manage_vpc_pair/vpc_pair_endpoints.py | 23 ++-- .../v1/manage_vpc_pair/vpc_pair_resources.py | 4 +- .../v1/manage_vpc_pair/vpc_pair_schemas.py | 2 - plugins/module_utils/vpc_pair/common.py | 25 ----- 8 files changed, 68 insertions(+), 128 deletions(-) delete mode 100644 plugins/module_utils/vpc_pair/common.py diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py index 4ed28125..5b368d2e 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py @@ -1,6 +1,5 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type __copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." __author__ = "Neil John" diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py index ae77b1a6..558caf4f 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py @@ -21,18 +21,29 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type __author__ = "Sivakami Sivaraman" from typing import Final -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ( - ApiPath, -) -from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.common import ( - build_path, - require_non_empty_str, -) +try: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( + BasePath as _ManageBasePath, + ) +except Exception: + # Forward-compat with the smart-endpoints package layout. + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( # type: ignore + BasePath as _ManageBasePath, + ) + + +def _require_non_empty_str(name: str, value: str, owner: str) -> str: + """Validate required string params for endpoint path construction.""" + if not value or not isinstance(value, str) or not value.strip(): + raise ValueError( + f"{owner}: {name} must be a non-empty string. " + f"Got: {value!r} (type: {type(value).__name__})" + ) + return value.strip() class VpcPairBasePath: @@ -68,7 +79,7 @@ class VpcPairBasePath: """ # Root API paths - MANAGE_API: Final = ApiPath.MANAGE.value + MANAGE_API: Final = _ManageBasePath.path() @classmethod def manage(cls, *segments: str) -> str: @@ -92,7 +103,7 @@ def manage(cls, *segments: str) -> str: # Returns: /api/v1/manage/fabrics/Fabric1 ``` """ - return build_path(cls.MANAGE_API, *segments) + return _ManageBasePath.path(*segments) @classmethod def fabrics(cls, fabric_name: str, *segments: str) -> str: @@ -121,7 +132,7 @@ def fabrics(cls, fabric_name: str, *segments: str) -> str: # Returns: /api/v1/manage/fabrics/Fabric1/switches ``` """ - fabric_name = require_non_empty_str( + fabric_name = _require_non_empty_str( name="fabric_name", value=fabric_name, owner="VpcPairBasePath.fabrics()", @@ -153,6 +164,11 @@ def switches(cls, fabric_name: str, switch_id: str, *segments: str) -> str: # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85 ``` """ + switch_id = _require_non_empty_str( + name="switch_id", + value=switch_id, + owner="VpcPairBasePath.switches()", + ) if not segments: return cls.fabrics(fabric_name, "switches", switch_id) return cls.fabrics(fabric_name, "switches", switch_id, *segments) diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/enums.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/enums.py index 4f547cc0..304d1e0e 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/enums.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/enums.py @@ -23,7 +23,6 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type __author__ = "Sivakami Sivaraman" from enum import Enum diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py index 34f2ccb7..c93eb105 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py @@ -14,85 +14,39 @@ """ Reusable mixin classes for VPC pair endpoint models. -This module provides mixin classes that can be composed to add common -fields to endpoint models without duplication. +This module re-exports shared endpoint mixins from +`plugins/module_utils/endpoints/mixins.py` to avoid local duplication +while preserving the existing import path for vPC pair code. """ from __future__ import absolute_import, annotations, division, print_function -__metaclass__ = type __author__ = "Sivakami Sivaraman" -from typing import Optional - -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - Field, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ComponentTypeMixin, + FabricNameMixin, + FilterMixin, + FromClusterMixin, + PaginationMixin, + PeerSwitchIdMixin, + SortMixin, + SwitchIdMixin, + TicketIdMixin, + UseVirtualPeerLinkMixin, + ViewMixin, ) - -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 SwitchIdMixin(BaseModel): - """Mixin for endpoints that require switch_id parameter.""" - - switch_id: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") - - -class PeerSwitchIdMixin(BaseModel): - """Mixin for endpoints that require peer_switch_id parameter.""" - - peer_switch_id: Optional[str] = Field(default=None, min_length=1, description="Peer switch serial number") - - -class UseVirtualPeerLinkMixin(BaseModel): - """Mixin for endpoints that require use_virtual_peer_link parameter.""" - - use_virtual_peer_link: Optional[bool] = Field(default=False, description="Indicates whether a virtual peer link is present") - - -class FromClusterMixin(BaseModel): - """Mixin for endpoints that support fromCluster query parameter.""" - - from_cluster: Optional[str] = Field(default=None, description="Optional cluster name") - - -class TicketIdMixin(BaseModel): - """Mixin for endpoints that support ticketId query parameter.""" - - ticket_id: Optional[str] = Field(default=None, description="Change ticket ID") - - -class ComponentTypeMixin(BaseModel): - """Mixin for endpoints that require componentType query parameter.""" - - component_type: Optional[str] = Field(default=None, description="Component type for filtering response") - - -class FilterMixin(BaseModel): - """Mixin for endpoints that support filter query parameter.""" - - filter: Optional[str] = Field(default=None, description="Filter expression for results") - - -class PaginationMixin(BaseModel): - """Mixin for endpoints that support pagination parameters.""" - - max: Optional[int] = Field(default=None, ge=1, description="Maximum number of results") - offset: Optional[int] = Field(default=None, ge=0, description="Offset for pagination") - - -class SortMixin(BaseModel): - """Mixin for endpoints that support sort parameter.""" - - sort: Optional[str] = Field(default=None, description="Sort field and direction (e.g., 'name:asc')") - - -class ViewMixin(BaseModel): - """Mixin for endpoints that support view parameter.""" - - view: Optional[str] = Field(default=None, description="Optional view type for filtering results") +__all__ = [ + "FabricNameMixin", + "SwitchIdMixin", + "PeerSwitchIdMixin", + "UseVirtualPeerLinkMixin", + "FromClusterMixin", + "TicketIdMixin", + "ComponentTypeMixin", + "FilterMixin", + "PaginationMixin", + "SortMixin", + "ViewMixin", +] diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py index 770e3e25..da5fa608 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py @@ -20,11 +20,13 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type __author__ = "Sivakami Sivaraman" -from typing import TYPE_CHECKING, Literal, Optional +from typing import Literal +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.base_paths import ( VpcPairBasePath, ) @@ -37,11 +39,11 @@ SortMixin, SwitchIdMixin, TicketIdMixin, + UseVirtualPeerLinkMixin, ViewMixin, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, ConfigDict, Field, ) @@ -59,7 +61,7 @@ class _EpVpcPairBase( FabricNameMixin, SwitchIdMixin, FromClusterMixin, - BaseModel, + NDEndpointBaseModel, ): """ Base class for VPC pair details endpoints. @@ -179,7 +181,7 @@ class EpVpcPairSupportGet( SwitchIdMixin, FromClusterMixin, ComponentTypeMixin, - BaseModel, + NDEndpointBaseModel, ): """ # Summary @@ -244,7 +246,7 @@ class EpVpcPairOverviewGet( SwitchIdMixin, FromClusterMixin, ComponentTypeMixin, - BaseModel, + NDEndpointBaseModel, ): """ # Summary @@ -308,7 +310,8 @@ class EpVpcPairRecommendationGet( FabricNameMixin, SwitchIdMixin, FromClusterMixin, - BaseModel, + UseVirtualPeerLinkMixin, + NDEndpointBaseModel, ): """ # Summary @@ -349,8 +352,6 @@ class EpVpcPairRecommendationGet( min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") class_name: Literal["EpVpcPairRecommendationGet"] = Field(default="EpVpcPairRecommendationGet", description="Class name for backward compatibility") - use_virtual_peer_link: Optional[bool] = Field(default=None, description="Virtual peer link available") - @property def path(self) -> str: """Build the endpoint path.""" @@ -373,7 +374,7 @@ class EpVpcPairConsistencyGet( FabricNameMixin, SwitchIdMixin, FromClusterMixin, - BaseModel, + NDEndpointBaseModel, ): """ # Summary @@ -434,7 +435,7 @@ class EpVpcPairsListGet( PaginationMixin, SortMixin, ViewMixin, - BaseModel, + NDEndpointBaseModel, ): """ # Summary diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py index 3b4d2992..a845a02e 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py @@ -5,15 +5,13 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type - from typing import Any, Callable, Dict, List, Optional from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import ( NDStateMachine, ) -from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.nd_vpc_pair_orchestrator import ( +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_pair import ( VpcPairOrchestrator, ) from pydantic import ValidationError diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py index 27f1d518..eb8de0b4 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py @@ -6,8 +6,6 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type - """ Backward-compatible export surface for vPC pair schemas. diff --git a/plugins/module_utils/vpc_pair/common.py b/plugins/module_utils/vpc_pair/common.py deleted file mode 100644 index 9e7e2a67..00000000 --- a/plugins/module_utils/vpc_pair/common.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Sivakami S -# 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 - - -def build_path(base: str, *segments: str) -> str: - """Build a path from a base and optional path segments.""" - if not segments: - return base - return f"{base}/{'/'.join(segments)}" - - -def require_non_empty_str(name: str, value: Any, owner: str) -> str: - """Validate a required non-empty string parameter and return its stripped value.""" - if not value or not isinstance(value, str) or not value.strip(): - raise ValueError( - f"{owner}: {name} must be a non-empty string. " - f"Got: {value!r} (type: {type(value).__name__})" - ) - return value.strip() From 2eb73e0ea0e4b97d12e96abc841dd63bc5cf63fd Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Fri, 13 Mar 2026 14:07:26 +0530 Subject: [PATCH 22/39] Aligning with the latest modularisation --- plugins/module_utils/endpoints/mixins.py | 64 ++ .../endpoints/v1/manage_vpc_pair/__init__.py | 8 + .../v1/manage_vpc_pair/base_paths.py | 6 +- .../v1/manage_vpc_pair/vpc_pair_resources.py | 6 +- plugins/module_utils/vpc_pair/__init__.py | 19 + .../vpc_pair/vpc_pair_module_model.py | 111 ++++ .../vpc_pair/vpc_pair_runtime_endpoints.py | 144 +++++ .../vpc_pair/vpc_pair_runtime_payloads.py | 74 +++ plugins/modules/nd_manage_vpc_pair.py | 589 +----------------- 9 files changed, 437 insertions(+), 584 deletions(-) create mode 100644 plugins/module_utils/vpc_pair/__init__.py create mode 100644 plugins/module_utils/vpc_pair/vpc_pair_module_model.py create mode 100644 plugins/module_utils/vpc_pair/vpc_pair_runtime_endpoints.py create mode 100644 plugins/module_utils/vpc_pair/vpc_pair_runtime_payloads.py diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index 47695611..b390f87a 100644 --- a/plugins/module_utils/endpoints/mixins.py +++ b/plugins/module_utils/endpoints/mixins.py @@ -84,3 +84,67 @@ class VrfNameMixin(BaseModel): """Mixin for endpoints that require vrf_name parameter.""" vrf_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="VRF name") + + +class SwitchIdMixin(BaseModel): + """Mixin for endpoints that require switch_id parameter.""" + + switch_id: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") + + +class PeerSwitchIdMixin(BaseModel): + """Mixin for endpoints that require peer_switch_id parameter.""" + + peer_switch_id: Optional[str] = Field(default=None, min_length=1, description="Peer switch serial number") + + +class UseVirtualPeerLinkMixin(BaseModel): + """Mixin for endpoints that require use_virtual_peer_link parameter.""" + + use_virtual_peer_link: Optional[bool] = Field( + default=False, + description="Indicates whether a virtual peer link is present", + ) + + +class FromClusterMixin(BaseModel): + """Mixin for endpoints that support fromCluster query parameter.""" + + from_cluster: Optional[str] = Field(default=None, description="Optional cluster name") + + +class TicketIdMixin(BaseModel): + """Mixin for endpoints that support ticketId query parameter.""" + + ticket_id: Optional[str] = Field(default=None, description="Change ticket ID") + + +class ComponentTypeMixin(BaseModel): + """Mixin for endpoints that require componentType query parameter.""" + + component_type: Optional[str] = Field(default=None, description="Component type for filtering response") + + +class FilterMixin(BaseModel): + """Mixin for endpoints that support filter query parameter.""" + + filter: Optional[str] = Field(default=None, description="Filter expression for results") + + +class PaginationMixin(BaseModel): + """Mixin for endpoints that support pagination parameters.""" + + max: Optional[int] = Field(default=None, ge=1, description="Maximum number of results") + offset: Optional[int] = Field(default=None, ge=0, description="Offset for pagination") + + +class SortMixin(BaseModel): + """Mixin for endpoints that support sort parameter.""" + + sort: Optional[str] = Field(default=None, description="Sort field and direction (e.g., 'name:asc')") + + +class ViewMixin(BaseModel): + """Mixin for endpoints that support view parameter.""" + + view: Optional[str] = Field(default=None, description="Optional view type for filtering results") diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py index 5b368d2e..016b242b 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py @@ -36,6 +36,7 @@ "EpVpcPairRecommendationGet", "EpVpcPairConsistencyGet", "EpVpcPairsListGet", + "VpcPairEndpoints", # Schemas "VpcPairDetailsDefault", "VpcPairDetailsCustom", @@ -44,6 +45,7 @@ "VpcPairBase", "VpcPairConsistency", "VpcPairRecommendation", + "VpcPairModel", # Enums "VerbEnum", "VpcActionEnum", @@ -73,6 +75,12 @@ EpVpcPairConsistencyGet, EpVpcPairsListGet, ) + from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_runtime_endpoints import ( + VpcPairEndpoints, + ) + from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_module_model import ( + VpcPairModel, + ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_schemas import ( VpcPairDetailsDefault, VpcPairDetailsCustom, diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py index 558caf4f..ae165128 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py @@ -26,12 +26,12 @@ from typing import Final try: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( BasePath as _ManageBasePath, ) except Exception: - # Forward-compat with the smart-endpoints package layout. - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( # type: ignore + # Backward-compat for older endpoint layouts. + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( # type: ignore BasePath as _ManageBasePath, ) diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py index a845a02e..d941aea4 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py @@ -11,10 +11,12 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import ( NDStateMachine, ) -from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_pair import ( +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.nd_vpc_pair_orchestrator import ( VpcPairOrchestrator, ) -from pydantic import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ValidationError, +) ActionHandler = Callable[[Any], Any] diff --git a/plugins/module_utils/vpc_pair/__init__.py b/plugins/module_utils/vpc_pair/__init__.py new file mode 100644 index 00000000..9e2cab51 --- /dev/null +++ b/plugins/module_utils/vpc_pair/__init__.py @@ -0,0 +1,19 @@ +from __future__ import absolute_import, division, print_function + +from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_module_model import ( + VpcPairModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_runtime_payloads import ( + _build_vpc_pair_payload, + _get_api_field_value, +) + +__all__ = [ + "VpcPairModel", + "VpcPairEndpoints", + "_build_vpc_pair_payload", + "_get_api_field_value", +] diff --git a/plugins/module_utils/vpc_pair/vpc_pair_module_model.py b/plugins/module_utils/vpc_pair/vpc_pair_module_model.py new file mode 100644 index 00000000..0fbe0b29 --- /dev/null +++ b/plugins/module_utils/vpc_pair/vpc_pair_module_model.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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, ClassVar, Dict, List, Literal, Optional, Union + +try: + from ansible_collections.cisco.nd.plugins.models.base import NDVpcPairBaseModel as _VpcPairBaseModel +except ImportError: + from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( # type: ignore + BaseModel as _VpcPairBaseModel, + ) + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_validator, + model_validator, +) + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + VpcFieldNames, +) + +try: + from ansible_collections.cisco.nd.plugins.models.vpc_pair_models import ( + VpcPairDetailsDefault, + VpcPairDetailsCustom, + ) +except ImportError: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_schemas import ( + VpcPairDetailsDefault, + VpcPairDetailsCustom, + ) + + +class VpcPairModel(_VpcPairBaseModel): + """ + Pydantic model for nd_manage_vpc_pair input. + + Uses a composite identifier `(switch_id, peer_switch_id)` and module-oriented + defaults/validation behavior. + """ + + identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] + identifier_strategy: ClassVar[Literal["composite"]] = "composite" + + switch_id: str = Field( + alias=VpcFieldNames.SWITCH_ID, + description="Peer-1 switch serial number", + min_length=3, + max_length=64, + ) + peer_switch_id: str = Field( + alias=VpcFieldNames.PEER_SWITCH_ID, + description="Peer-2 switch serial number", + min_length=3, + max_length=64, + ) + use_virtual_peer_link: bool = Field( + default=True, + alias=VpcFieldNames.USE_VIRTUAL_PEER_LINK, + description="Virtual peer link enabled", + ) + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, + discriminator="type", + alias=VpcFieldNames.VPC_PAIR_DETAILS, + description="VPC pair configuration details (default or custom template)", + ) + + @field_validator("switch_id", "peer_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> "VpcPairModel": + if self.switch_id == self.peer_switch_id: + raise ValueError( + f"switch_id and peer_switch_id must be different: {self.switch_id}" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + return self.model_dump(by_alias=True, exclude_none=True) + + def get_identifier_value(self): + return tuple(sorted([self.switch_id, self.peer_switch_id])) + + def to_config(self, **kwargs) -> Dict[str, Any]: + return self.model_dump(by_alias=False, exclude_none=True, **kwargs) + + @classmethod + def from_config(cls, ansible_config: Dict[str, Any]) -> "VpcPairModel": + return cls.model_validate(ansible_config, by_name=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> "VpcPairModel": + data = { + VpcFieldNames.SWITCH_ID: response.get(VpcFieldNames.SWITCH_ID), + VpcFieldNames.PEER_SWITCH_ID: response.get(VpcFieldNames.PEER_SWITCH_ID), + VpcFieldNames.USE_VIRTUAL_PEER_LINK: response.get( + VpcFieldNames.USE_VIRTUAL_PEER_LINK, True + ), + } + return cls.model_validate(data) diff --git a/plugins/module_utils/vpc_pair/vpc_pair_runtime_endpoints.py b/plugins/module_utils/vpc_pair/vpc_pair_runtime_endpoints.py new file mode 100644 index 00000000..6b238ab5 --- /dev/null +++ b/plugins/module_utils/vpc_pair/vpc_pair_runtime_endpoints.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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 + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + CompositeQueryParams, + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + ComponentTypeSupportEnum, +) + +try: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_endpoints import ( + EpVpcPairConsistencyGet, + EpVpcPairGet, + EpVpcPairPut, + EpVpcPairOverviewGet, + EpVpcPairRecommendationGet, + EpVpcPairSupportGet, + EpVpcPairsListGet, + VpcPairBasePath, + ) +except ImportError: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair import ( + EpVpcPairConsistencyGet, + EpVpcPairGet, + EpVpcPairPut, + EpVpcPairOverviewGet, + EpVpcPairRecommendationGet, + EpVpcPairSupportGet, + EpVpcPairsListGet, + VpcPairBasePath, + ) + + +class _ComponentTypeQueryParams(EndpointQueryParams): + """Query params for endpoints that require componentType.""" + + component_type: Optional[str] = None + + +class _ForceShowRunQueryParams(EndpointQueryParams): + """Query params for deploy endpoint.""" + + force_show_run: Optional[bool] = None + + +class VpcPairEndpoints: + """Centralized endpoint builders for vPC pair runtime operations.""" + + NDFC_BASE = "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest" + MANAGE_BASE = "/api/v1/manage" + VPC_PAIR_BASE = f"{NDFC_BASE}/vpcpair/fabrics/{{fabric_name}}" + VPC_PAIR_SWITCH = f"{NDFC_BASE}/vpcpair/fabrics/{{fabric_name}}/switches/{{switch_id}}" + FABRIC_CONFIG_SAVE = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/actions/configSave" + FABRIC_CONFIG_DEPLOY = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/actions/deploy" + FABRIC_SWITCHES = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches" + SWITCH_VPC_PAIR = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPair" + SWITCH_VPC_RECOMMENDATIONS = ( + f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPairRecommendation" + ) + SWITCH_VPC_OVERVIEW = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPairOverview" + + @staticmethod + def _append_query(path: str, *query_groups: EndpointQueryParams) -> str: + composite_params = CompositeQueryParams() + for query_group in query_groups: + composite_params.add(query_group) + query_string = composite_params.to_query_string(url_encode=False) + return f"{path}?{query_string}" if query_string else path + + @staticmethod + def vpc_pair_base(fabric_name: str) -> str: + endpoint = EpVpcPairsListGet(fabric_name=fabric_name) + return endpoint.path + + @staticmethod + def vpc_pairs_list(fabric_name: str) -> str: + endpoint = EpVpcPairsListGet(fabric_name=fabric_name) + return endpoint.path + + @staticmethod + def vpc_pair_put(fabric_name: str, switch_id: str) -> str: + endpoint = EpVpcPairPut(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def fabric_switches(fabric_name: str) -> str: + return VpcPairBasePath.fabrics(fabric_name, "switches") + + @staticmethod + def switch_vpc_pair(fabric_name: str, switch_id: str) -> str: + endpoint = EpVpcPairGet(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def switch_vpc_recommendations(fabric_name: str, switch_id: str) -> str: + endpoint = EpVpcPairRecommendationGet(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def switch_vpc_overview(fabric_name: str, switch_id: str, component_type: str = "full") -> str: + endpoint = EpVpcPairOverviewGet(fabric_name=fabric_name, switch_id=switch_id) + base_path = endpoint.path + query_params = _ComponentTypeQueryParams(component_type=component_type) + return VpcPairEndpoints._append_query(base_path, query_params) + + @staticmethod + def switch_vpc_support( + fabric_name: str, + switch_id: str, + component_type: str = ComponentTypeSupportEnum.CHECK_PAIRING.value, + ) -> str: + endpoint = EpVpcPairSupportGet( + fabric_name=fabric_name, + switch_id=switch_id, + component_type=component_type, + ) + base_path = endpoint.path + query_params = _ComponentTypeQueryParams(component_type=component_type) + return VpcPairEndpoints._append_query(base_path, query_params) + + @staticmethod + def switch_vpc_consistency(fabric_name: str, switch_id: str) -> str: + endpoint = EpVpcPairConsistencyGet(fabric_name=fabric_name, switch_id=switch_id) + return endpoint.path + + @staticmethod + def fabric_config_save(fabric_name: str) -> str: + return VpcPairBasePath.fabrics(fabric_name, "actions", "configSave") + + @staticmethod + def fabric_config_deploy(fabric_name: str, force_show_run: bool = True) -> str: + base_path = VpcPairBasePath.fabrics(fabric_name, "actions", "deploy") + query_params = _ForceShowRunQueryParams( + force_show_run=True if force_show_run else None + ) + return VpcPairEndpoints._append_query(base_path, query_params) diff --git a/plugins/module_utils/vpc_pair/vpc_pair_runtime_payloads.py b/plugins/module_utils/vpc_pair/vpc_pair_runtime_payloads.py new file mode 100644 index 00000000..040a89b9 --- /dev/null +++ b/plugins/module_utils/vpc_pair/vpc_pair_runtime_payloads.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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, Dict, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + VpcActionEnum, + VpcFieldNames, +) + + +def _get_template_config(vpc_pair_model) -> Optional[Dict[str, Any]]: + """Extract template configuration from a vPC pair model if present.""" + if not hasattr(vpc_pair_model, "vpc_pair_details"): + return None + + vpc_pair_details = vpc_pair_model.vpc_pair_details + if not vpc_pair_details: + return None + + return vpc_pair_details.model_dump(by_alias=True, exclude_none=True) + + +def _build_vpc_pair_payload(vpc_pair_model) -> Dict[str, Any]: + """Build pair payload with vpcAction discriminator for ND 4.2 APIs.""" + if isinstance(vpc_pair_model, dict): + switch_id = vpc_pair_model.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = vpc_pair_model.get(VpcFieldNames.PEER_SWITCH_ID) + use_virtual_peer_link = vpc_pair_model.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + else: + switch_id = vpc_pair_model.switch_id + peer_switch_id = vpc_pair_model.peer_switch_id + use_virtual_peer_link = vpc_pair_model.use_virtual_peer_link + + payload = { + VpcFieldNames.VPC_ACTION: VpcActionEnum.PAIR.value, + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_virtual_peer_link, + } + + if not isinstance(vpc_pair_model, dict): + template_config = _get_template_config(vpc_pair_model) + if template_config: + payload[VpcFieldNames.VPC_PAIR_DETAILS] = template_config + + return payload + + +# ND API versions use inconsistent field names. This mapping keeps one lookup API. +API_FIELD_ALIASES = { + "useVirtualPeerLink": ["useVirtualPeerlink"], + "serialNumber": ["serial_number", "serialNo"], +} + + +def _get_api_field_value(api_response: Dict[str, Any], field_name: str, default=None): + """Get a field value across known ND API naming aliases.""" + if not isinstance(api_response, dict): + return default + + if field_name in api_response: + return api_response[field_name] + + aliases = API_FIELD_ALIASES.get(field_name, []) + for alias in aliases: + if alias in api_response: + return api_response[alias] + + return default diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index 4405fab9..ab14a27e 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -5,7 +5,6 @@ from __future__ import absolute_import, division, print_function -__metaclass__ = type __copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." __author__ = "Sivakami S" @@ -272,7 +271,7 @@ import logging import sys import traceback -from typing import Any, ClassVar, Dict, List, Literal, Optional, Union +from typing import Any, Dict, List, Optional from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging @@ -292,14 +291,6 @@ _nd_config_collection = None # noqa: F841 _nd_utils = None # noqa: F841 -try: - from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDNestedModel -except Exception: - try: - from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel - except Exception: - from pydantic import BaseModel as NDNestedModel - # Enum imports from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( @@ -307,32 +298,15 @@ VpcActionEnum, VpcFieldNames, ) - -try: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_endpoints import ( - EpVpcPairConsistencyGet, - EpVpcPairGet, - EpVpcPairPut, - EpVpcPairOverviewGet, - EpVpcPairRecommendationGet, - EpVpcPairSupportGet, - EpVpcPairsListGet, - VpcPairBasePath, - ) -except Exception: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair import ( - EpVpcPairConsistencyGet, - EpVpcPairGet, - EpVpcPairPut, - EpVpcPairOverviewGet, - EpVpcPairRecommendationGet, - EpVpcPairSupportGet, - EpVpcPairsListGet, - VpcPairBasePath, - ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( - CompositeQueryParams, - EndpointQueryParams, +from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_module_model import ( + VpcPairModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_runtime_payloads import ( + _build_vpc_pair_payload, + _get_api_field_value, ) # RestSend imports @@ -345,21 +319,6 @@ except Exception: from ansible_collections.cisco.nd.plugins.module_utils.results import Results -# Pydantic imports -from pydantic import Field, field_validator, model_validator - -# VPC Pair schema imports (for vpc_pair_details support) -try: - from ansible_collections.cisco.nd.plugins.models.model_playbook_vpc_pair import ( - VpcPairDetailsDefault, - VpcPairDetailsCustom, - ) -except Exception: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_schemas import ( - VpcPairDetailsDefault, - VpcPairDetailsCustom, - ) - # DeepDiff for intelligent change detection try: from deepdiff import DeepDiff @@ -390,399 +349,6 @@ def _raise_vpc_error(msg: str, **details: Any) -> None: raise VpcPairResourceError(msg=msg, **details) -# ===== API Endpoints ===== - - -class _ComponentTypeQueryParams(EndpointQueryParams): - """Query params for endpoints that require componentType.""" - - component_type: Optional[str] = None - - -class _ForceShowRunQueryParams(EndpointQueryParams): - """Query params for deploy endpoint.""" - - force_show_run: Optional[bool] = None - - -class VpcPairEndpoints: - """ - Centralized API endpoint path management for VPC pair operations. - - All API endpoint paths are defined here to: - - Eliminate scattered path definitions - - Make API evolution easier - - Enable easy endpoint discovery - - Support multiple API versions - - Usage: - # Get a path with parameters - path = VpcPairEndpoints.vpc_pair_put(fabric_name="myFabric", switch_id="FDO123") - # Returns: "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair/fabrics/myFabric/switches/FDO123" - """ - - # Base paths - NDFC_BASE = "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest" - MANAGE_BASE = "/api/v1/manage" - - # Path templates for VPC pair operations (NDFC API) - VPC_PAIR_BASE = f"{NDFC_BASE}/vpcpair/fabrics/{{fabric_name}}" - VPC_PAIR_SWITCH = f"{NDFC_BASE}/vpcpair/fabrics/{{fabric_name}}/switches/{{switch_id}}" - - # Path templates for fabric operations (Manage API - for config save/deploy actions) - FABRIC_CONFIG_SAVE = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/actions/configSave" - FABRIC_CONFIG_DEPLOY = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/actions/deploy" - - # Path templates for switch/inventory operations (Manage API) - FABRIC_SWITCHES = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches" - SWITCH_VPC_PAIR = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPair" - SWITCH_VPC_RECOMMENDATIONS = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPairRecommendations" - SWITCH_VPC_OVERVIEW = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPairOverview" - - @staticmethod - def _append_query(path: str, *query_groups: EndpointQueryParams) -> str: - """Compose query params using shared query param utilities.""" - composite_params = CompositeQueryParams() - for query_group in query_groups: - composite_params.add(query_group) - query_string = composite_params.to_query_string(url_encode=False) - return f"{path}?{query_string}" if query_string else path - - @staticmethod - def vpc_pair_base(fabric_name: str) -> str: - """ - Get base path for VPC pair operations. - - Args: - fabric_name: Fabric name - - Returns: - Base VPC pairs list path - - Example: - >>> VpcPairEndpoints.vpc_pair_base("myFabric") - '/api/v1/manage/fabrics/myFabric/vpcPairs' - """ - endpoint = EpVpcPairsListGet(fabric_name=fabric_name) - return endpoint.path - - @staticmethod - def vpc_pairs_list(fabric_name: str) -> str: - """ - Get path for querying VPC pairs list in a fabric. - - Args: - fabric_name: Fabric name - - Returns: - VPC pairs list path - """ - endpoint = EpVpcPairsListGet(fabric_name=fabric_name) - return endpoint.path - - @staticmethod - def vpc_pair_put(fabric_name: str, switch_id: str) -> str: - """ - Get path for VPC pair PUT operations (create/update/delete). - - Args: - fabric_name: Fabric name - switch_id: Switch serial number - - Returns: - VPC pair PUT path - - Example: - >>> VpcPairEndpoints.vpc_pair_put("myFabric", "FDO123") - '/api/v1/manage/fabrics/myFabric/switches/FDO123/vpcPair' - """ - endpoint = EpVpcPairPut(fabric_name=fabric_name, switch_id=switch_id) - return endpoint.path - - @staticmethod - def fabric_switches(fabric_name: str) -> str: - """ - Get path for querying fabric switch inventory. - - Args: - fabric_name: Fabric name - - Returns: - Fabric switches path - - Example: - >>> VpcPairEndpoints.fabric_switches("myFabric") - '/api/v1/manage/fabrics/myFabric/switches' - """ - return VpcPairBasePath.fabrics(fabric_name, "switches") - - @staticmethod - def switch_vpc_pair(fabric_name: str, switch_id: str) -> str: - """ - Get path for querying specific switch VPC pair. - - Args: - fabric_name: Fabric name - switch_id: Switch serial number - - Returns: - Switch VPC pair path - - Example: - >>> VpcPairEndpoints.switch_vpc_pair("myFabric", "FDO123") - '/api/v1/manage/fabrics/myFabric/switches/FDO123/vpcPair' - """ - endpoint = EpVpcPairGet(fabric_name=fabric_name, switch_id=switch_id) - return endpoint.path - - @staticmethod - def switch_vpc_recommendations(fabric_name: str, switch_id: str) -> str: - """ - Get path for querying VPC pair recommendations for a switch. - - Args: - fabric_name: Fabric name - switch_id: Switch serial number - - Returns: - VPC recommendations path - - Example: - >>> VpcPairEndpoints.switch_vpc_recommendations("myFabric", "FDO123") - '/api/v1/manage/fabrics/myFabric/switches/FDO123/vpcPairRecommendations' - """ - endpoint = EpVpcPairRecommendationGet(fabric_name=fabric_name, switch_id=switch_id) - return endpoint.path - - @staticmethod - def switch_vpc_overview(fabric_name: str, switch_id: str, component_type: str = "full") -> str: - """ - Get path for querying VPC pair overview (for pre-deletion validation). - - Args: - fabric_name: Fabric name - switch_id: Switch serial number - component_type: Component type ("full" or "minimal"), default "full" - - Returns: - VPC overview path with query parameters - - Example: - >>> VpcPairEndpoints.switch_vpc_overview("myFabric", "FDO123") - '/api/v1/manage/fabrics/myFabric/switches/FDO123/vpcPairOverview?componentType=full' - """ - endpoint = EpVpcPairOverviewGet(fabric_name=fabric_name, switch_id=switch_id) - base_path = endpoint.path - query_params = _ComponentTypeQueryParams(component_type=component_type) - return VpcPairEndpoints._append_query(base_path, query_params) - - @staticmethod - def switch_vpc_support( - fabric_name: str, - switch_id: str, - component_type: str = ComponentTypeSupportEnum.CHECK_PAIRING.value, - ) -> str: - """ - Get path for querying VPC pair support details. - - Args: - fabric_name: Fabric name - switch_id: Switch serial number - component_type: Support check type - - Returns: - VPC support path with query parameters - """ - endpoint = EpVpcPairSupportGet( - fabric_name=fabric_name, - switch_id=switch_id, - component_type=component_type, - ) - base_path = endpoint.path - query_params = _ComponentTypeQueryParams(component_type=component_type) - return VpcPairEndpoints._append_query(base_path, query_params) - - @staticmethod - def switch_vpc_consistency(fabric_name: str, switch_id: str) -> str: - """ - Get path for querying VPC pair consistency details. - - Args: - fabric_name: Fabric name - switch_id: Switch serial number - - Returns: - VPC consistency path - """ - endpoint = EpVpcPairConsistencyGet(fabric_name=fabric_name, switch_id=switch_id) - return endpoint.path - - @staticmethod - def fabric_config_save(fabric_name: str) -> str: - """ - Get path for saving fabric configuration. - - Args: - fabric_name: Fabric name - - Returns: - Fabric config save path - - Example: - >>> VpcPairEndpoints.fabric_config_save("myFabric") - '/api/v1/manage/fabrics/myFabric/actions/configSave' - """ - return VpcPairBasePath.fabrics(fabric_name, "actions", "configSave") - - @staticmethod - def fabric_config_deploy(fabric_name: str, force_show_run: bool = True) -> str: - """ - Get path for deploying fabric configuration. - - Args: - fabric_name: Fabric name - force_show_run: Include forceShowRun query parameter, default True - - Returns: - Fabric config deploy path with query parameters - - Example: - >>> VpcPairEndpoints.fabric_config_deploy("myFabric") - '/api/v1/manage/fabrics/myFabric/actions/deploy?forceShowRun=true' - """ - base_path = VpcPairBasePath.fabrics(fabric_name, "actions", "deploy") - query_params = _ForceShowRunQueryParams( - force_show_run=True if force_show_run else None - ) - return VpcPairEndpoints._append_query(base_path, query_params) - - -# ===== VPC Pair Model ===== - - -class VpcPairModel(NDNestedModel): - """ - Pydantic model for VPC pair configuration specific to nd_manage_vpc_pair module. - - Uses composite identifier: (switch_id, peer_switch_id) - - Note: This model is separate from VpcPairBase in model_playbook_vpc_pair.py because: - 1. Different base class: NDNestedModel (module-specific) vs NDVpcPairBaseModel (API-generic) - 2. Different defaults: use_virtual_peer_link=True (module default) vs False (API default) - 3. Different type coercion: bool (strict) vs FlexibleBool (flexible API input) - 4. Module-specific validation and error messages tailored to Ansible user experience - - These models serve different purposes: - - VpcPairModel: Ansible module input validation and framework integration - - VpcPairBase: Generic API schema for broader vpc_pair functionality - - DO NOT consolidate without ensuring all tests pass and defaults match module documentation. - """ - - # Identifier configuration - identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] - identifier_strategy: ClassVar[Literal["composite"]] = "composite" - - # Fields (Ansible names -> API aliases) - switch_id: str = Field( - alias=VpcFieldNames.SWITCH_ID, - description="Peer-1 switch serial number", - min_length=3, - max_length=64 - ) - peer_switch_id: str = Field( - alias=VpcFieldNames.PEER_SWITCH_ID, - description="Peer-2 switch serial number", - min_length=3, - max_length=64 - ) - use_virtual_peer_link: bool = Field( - default=True, - alias=VpcFieldNames.USE_VIRTUAL_PEER_LINK, - description="Virtual peer link enabled" - ) - vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( - default=None, - discriminator="type", - alias=VpcFieldNames.VPC_PAIR_DETAILS, - description="VPC pair configuration details (default or custom template)" - ) - - @field_validator("switch_id", "peer_switch_id") - @classmethod - def validate_switch_id_format(cls, v: str) -> str: - """ - Validate switch ID is not empty or whitespace. - - Args: - v: Switch ID value - - Returns: - Stripped switch ID - - Raises: - ValueError: If switch ID is empty or whitespace - """ - if not v or not v.strip(): - raise ValueError("Switch ID cannot be empty or whitespace") - return v.strip() - - @model_validator(mode="after") - def validate_different_switches(self) -> "VpcPairModel": - """ - Ensure switch_id and peer_switch_id are different. - - Returns: - Validated model instance - - Raises: - ValueError: If switch_id equals peer_switch_id - """ - if self.switch_id == self.peer_switch_id: - raise ValueError( - f"switch_id and peer_switch_id must be different: {self.switch_id}" - ) - return self - - def to_payload(self) -> Dict[str, Any]: - """ - Convert to API payload format. - - Note: vpcAction is added by custom functions, not here. - """ - return self.model_dump(by_alias=True, exclude_none=True) - - def get_identifier_value(self): - """ - Return a stable composite identifier for VPC pair operations. - - Sort switch IDs to treat (A,B) and (B,A) as the same logical pair. - """ - return tuple(sorted([self.switch_id, self.peer_switch_id])) - - def to_config(self, **kwargs) -> Dict[str, Any]: - """ - Convert to Ansible config shape with snake_case field names. - """ - return self.model_dump(by_alias=False, exclude_none=True, **kwargs) - - @classmethod - def from_response(cls, response: Dict[str, Any]) -> "VpcPairModel": - """ - Parse VPC pair from API response. - - Handles API field name variations. - """ - data = { - VpcFieldNames.SWITCH_ID: response.get(VpcFieldNames.SWITCH_ID), - VpcFieldNames.PEER_SWITCH_ID: response.get(VpcFieldNames.PEER_SWITCH_ID), - VpcFieldNames.USE_VIRTUAL_PEER_LINK: response.get( - VpcFieldNames.USE_VIRTUAL_PEER_LINK, True - ), - } - return cls.model_validate(data) - - # ===== Helper Functions ===== @@ -824,141 +390,6 @@ def _is_update_needed(want: Dict[str, Any], have: Dict[str, Any]) -> bool: return want != have -def _get_template_config(vpc_pair_model) -> Optional[Dict[str, Any]]: - """ - Extract template configuration from VPC pair model if present. - - Supports both default and custom template types: - - default: Standard parameters (domainId, keepAliveVrf, etc.) - - custom: User-defined template with custom fields - - Args: - vpc_pair_model: VpcPairModel instance - - Returns: - dict: Template configuration or None if not provided - - Example: - # For default template: - config = _get_template_config(model) - # Returns: {"type": "default", "domainId": 100, ...} - - # For custom template: - config = _get_template_config(model) - # Returns: {"type": "custom", "templateName": "my_template", ...} - """ - # Check if model has vpc_pair_details - if not hasattr(vpc_pair_model, "vpc_pair_details"): - return None - - vpc_pair_details = vpc_pair_model.vpc_pair_details - if not vpc_pair_details: - return None - - # Return the validated Pydantic model as dict - return vpc_pair_details.model_dump(by_alias=True, exclude_none=True) - - -def _build_vpc_pair_payload(vpc_pair_model) -> Dict[str, Any]: - """ - Build the 4.2 API payload for pairing a VPC. - - Constructs payload according to OpenAPI spec with vpcAction - discriminator and optional template details. - - Args: - vpc_pair_model: VpcPairModel instance with configuration - - Returns: - dict: Complete payload for PUT request in 4.2 format - - Example: - payload = _build_vpc_pair_payload(vpc_pair_model) - # Returns: - # { - # "vpcAction": "pair", - # "switchId": "FDO123", - # "peerSwitchId": "FDO456", - # "useVirtualPeerLink": True, - # "vpcPairDetails": {...} # Optional - # } - """ - # Handle both dict and model object inputs - if isinstance(vpc_pair_model, dict): - switch_id = vpc_pair_model.get(VpcFieldNames.SWITCH_ID) - peer_switch_id = vpc_pair_model.get(VpcFieldNames.PEER_SWITCH_ID) - use_virtual_peer_link = vpc_pair_model.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) - else: - switch_id = vpc_pair_model.switch_id - peer_switch_id = vpc_pair_model.peer_switch_id - use_virtual_peer_link = vpc_pair_model.use_virtual_peer_link - - # Base payload with vpcAction discriminator - payload = { - VpcFieldNames.VPC_ACTION: VpcActionEnum.PAIR.value, - VpcFieldNames.SWITCH_ID: switch_id, - VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_virtual_peer_link, - } - - # Add template configuration if provided (only for model objects) - if not isinstance(vpc_pair_model, dict): - template_config = _get_template_config(vpc_pair_model) - if template_config: - payload[VpcFieldNames.VPC_PAIR_DETAILS] = template_config - - return payload - - -# API field compatibility mapping -# ND API versions use inconsistent field names - this mapping provides a canonical interface -API_FIELD_ALIASES = { - # Primary field name -> list of alternative field names to check - "useVirtualPeerLink": ["useVirtualPeerlink"], # ND 4.2+ uses camelCase "Link", older versions use lowercase "link" - "serialNumber": ["serial_number", "serialNo"], # Alternative serial number field names -} - - -def _get_api_field_value(api_response: Dict, field_name: str, default=None): - """ - Get field value from API response handling inconsistent field naming across ND API versions. - - Different ND API versions use inconsistent field names (useVirtualPeerLink vs useVirtualPeerlink). - This function checks the primary field name and all known aliases. - - Args: - api_response: API response dictionary - field_name: Primary field name to retrieve - default: Default value if field not found - - Returns: - Field value or default if not found - - Example: - >>> recommendation = {"useVirtualPeerlink": True} # Old API format - >>> _get_api_field_value(recommendation, "useVirtualPeerLink", False) - True # Found via alias mapping - - >>> recommendation = {"useVirtualPeerLink": True} # New API format - >>> _get_api_field_value(recommendation, "useVirtualPeerLink", False) - True # Found via primary field name - """ - if not isinstance(api_response, dict): - return default - - # Check primary field name first - if field_name in api_response: - return api_response[field_name] - - # Check aliases - aliases = API_FIELD_ALIASES.get(field_name, []) - for alias in aliases: - if alias in api_response: - return api_response[alias] - - return default - - def _get_recommendation_details(nd_v2, fabric_name: str, switch_id: str, timeout: Optional[int] = None) -> Optional[Dict]: """ Get VPC pair recommendation details from ND for a specific switch. From 7190b74b9681340b5a7446239d219f2a63536fcd Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Fri, 13 Mar 2026 14:58:55 +0530 Subject: [PATCH 23/39] Integ test fixes --- .../vpc_pair/vpc_pair_module_model.py | 48 ++++++++++++++++++- plugins/modules/nd_manage_vpc_pair.py | 17 +++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/plugins/module_utils/vpc_pair/vpc_pair_module_model.py b/plugins/module_utils/vpc_pair/vpc_pair_module_model.py index 0fbe0b29..23a78435 100644 --- a/plugins/module_utils/vpc_pair/vpc_pair_module_model.py +++ b/plugins/module_utils/vpc_pair/vpc_pair_module_model.py @@ -15,6 +15,7 @@ ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, Field, field_validator, model_validator, @@ -46,6 +47,17 @@ class VpcPairModel(_VpcPairBaseModel): identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] identifier_strategy: ClassVar[Literal["composite"]] = "composite" + exclude_from_diff: ClassVar[List[str]] = [] + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, + extra="ignore", + ) switch_id: str = Field( alias=VpcFieldNames.SWITCH_ID, @@ -89,6 +101,13 @@ def validate_different_switches(self) -> "VpcPairModel": def to_payload(self) -> Dict[str, Any]: return self.model_dump(by_alias=True, exclude_none=True) + def to_diff_dict(self) -> Dict[str, Any]: + return self.model_dump( + by_alias=True, + exclude_none=True, + exclude=set(self.exclude_from_diff), + ) + def get_identifier_value(self): return tuple(sorted([self.switch_id, self.peer_switch_id])) @@ -97,7 +116,34 @@ def to_config(self, **kwargs) -> Dict[str, Any]: @classmethod def from_config(cls, ansible_config: Dict[str, Any]) -> "VpcPairModel": - return cls.model_validate(ansible_config, by_name=True) + data = dict(ansible_config or {}) + + # Accept both snake_case module input and API camelCase aliases. + if VpcFieldNames.SWITCH_ID not in data and "switch_id" in data: + data[VpcFieldNames.SWITCH_ID] = data.get("switch_id") + if VpcFieldNames.PEER_SWITCH_ID not in data and "peer_switch_id" in data: + data[VpcFieldNames.PEER_SWITCH_ID] = data.get("peer_switch_id") + if ( + VpcFieldNames.USE_VIRTUAL_PEER_LINK not in data + and "use_virtual_peer_link" in data + ): + data[VpcFieldNames.USE_VIRTUAL_PEER_LINK] = data.get("use_virtual_peer_link") + if VpcFieldNames.VPC_PAIR_DETAILS not in data and "vpc_pair_details" in data: + data[VpcFieldNames.VPC_PAIR_DETAILS] = data.get("vpc_pair_details") + + return cls.model_validate(data, by_alias=True, by_name=True) + + def merge(self, other_model: "VpcPairModel") -> "VpcPairModel": + if not isinstance(other_model, type(self)): + raise TypeError( + "VpcPairModel.merge requires both models to be the same type" + ) + + for field, value in other_model: + if value is None: + continue + setattr(self, field, value) + return self @classmethod def from_response(cls, response: Dict[str, Any]) -> "VpcPairModel": diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index ab14a27e..e100d65e 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -2415,11 +2415,20 @@ def main(): normalized_config = [] for item in config: + switch_id = item.get("peer1_switch_id") or item.get("switch_id") + peer_switch_id = item.get("peer2_switch_id") or item.get("peer_switch_id") + use_virtual_peer_link = item.get("use_virtual_peer_link", True) + vpc_pair_details = item.get("vpc_pair_details") normalized = { - "switch_id": item.get("peer1_switch_id") or item.get("switch_id"), - "peer_switch_id": item.get("peer2_switch_id") or item.get("peer_switch_id"), - "use_virtual_peer_link": item.get("use_virtual_peer_link", True), - "vpc_pair_details": item.get("vpc_pair_details"), + "switch_id": switch_id, + "peer_switch_id": peer_switch_id, + "use_virtual_peer_link": use_virtual_peer_link, + "vpc_pair_details": vpc_pair_details, + # Defensive dual-shape normalization for state-machine/model variants. + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_virtual_peer_link, + VpcFieldNames.VPC_PAIR_DETAILS: vpc_pair_details, } normalized_config.append(normalized) From 40dac2c99b012e35f1f5f15024e1773641151528 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Fri, 13 Mar 2026 16:25:33 +0530 Subject: [PATCH 24/39] Interim changes --- .../endpoints/v1/manage_vpc_pair/__init__.py | 4 ++-- .../manage_vpc_pair}/vpc_pair_module_model.py | 0 .../vpc_pair_runtime_endpoints.py | 0 .../vpc_pair_runtime_payloads.py | 0 plugins/module_utils/vpc_pair/__init__.py | 19 ------------------- plugins/modules/nd_manage_vpc_pair.py | 6 +++--- 6 files changed, 5 insertions(+), 24 deletions(-) rename plugins/module_utils/{vpc_pair => endpoints/v1/manage_vpc_pair}/vpc_pair_module_model.py (100%) rename plugins/module_utils/{vpc_pair => endpoints/v1/manage_vpc_pair}/vpc_pair_runtime_endpoints.py (100%) rename plugins/module_utils/{vpc_pair => endpoints/v1/manage_vpc_pair}/vpc_pair_runtime_payloads.py (100%) delete mode 100644 plugins/module_utils/vpc_pair/__init__.py diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py index 016b242b..90510486 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py @@ -75,10 +75,10 @@ EpVpcPairConsistencyGet, EpVpcPairsListGet, ) - from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_runtime_endpoints import ( + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( VpcPairEndpoints, ) - from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_module_model import ( + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_module_model import ( VpcPairModel, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_schemas import ( diff --git a/plugins/module_utils/vpc_pair/vpc_pair_module_model.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_module_model.py similarity index 100% rename from plugins/module_utils/vpc_pair/vpc_pair_module_model.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_module_model.py diff --git a/plugins/module_utils/vpc_pair/vpc_pair_runtime_endpoints.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_runtime_endpoints.py similarity index 100% rename from plugins/module_utils/vpc_pair/vpc_pair_runtime_endpoints.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_runtime_endpoints.py diff --git a/plugins/module_utils/vpc_pair/vpc_pair_runtime_payloads.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_runtime_payloads.py similarity index 100% rename from plugins/module_utils/vpc_pair/vpc_pair_runtime_payloads.py rename to plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_runtime_payloads.py diff --git a/plugins/module_utils/vpc_pair/__init__.py b/plugins/module_utils/vpc_pair/__init__.py deleted file mode 100644 index 9e2cab51..00000000 --- a/plugins/module_utils/vpc_pair/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import absolute_import, division, print_function - -from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_module_model import ( - VpcPairModel, -) -from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_runtime_endpoints import ( - VpcPairEndpoints, -) -from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_runtime_payloads import ( - _build_vpc_pair_payload, - _get_api_field_value, -) - -__all__ = [ - "VpcPairModel", - "VpcPairEndpoints", - "_build_vpc_pair_payload", - "_get_api_field_value", -] diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index e100d65e..c6fa7358 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -298,13 +298,13 @@ VpcActionEnum, VpcFieldNames, ) -from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_module_model import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_module_model import ( VpcPairModel, ) -from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_runtime_endpoints import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( VpcPairEndpoints, ) -from ansible_collections.cisco.nd.plugins.module_utils.vpc_pair.vpc_pair_runtime_payloads import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_payloads import ( _build_vpc_pair_payload, _get_api_field_value, ) From 1dd26684d98ed2eeb9ecd5280508fb60907427c7 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Fri, 13 Mar 2026 19:51:36 +0530 Subject: [PATCH 25/39] Fragmenting the module. --- .../nd_manage_vpc_pair_actions.py | 463 ++++ .../module_utils/nd_manage_vpc_pair_common.py | 84 + .../module_utils/nd_manage_vpc_pair_deploy.py | 225 ++ .../module_utils/nd_manage_vpc_pair_query.py | 676 ++++++ .../module_utils/nd_manage_vpc_pair_runner.py | 99 + .../nd_manage_vpc_pair_validation.py | 604 +++++ plugins/modules/nd_manage_vpc_pair.py | 2041 +---------------- 7 files changed, 2169 insertions(+), 2023 deletions(-) create mode 100644 plugins/module_utils/nd_manage_vpc_pair_actions.py create mode 100644 plugins/module_utils/nd_manage_vpc_pair_common.py create mode 100644 plugins/module_utils/nd_manage_vpc_pair_deploy.py create mode 100644 plugins/module_utils/nd_manage_vpc_pair_query.py create mode 100644 plugins/module_utils/nd_manage_vpc_pair_runner.py create mode 100644 plugins/module_utils/nd_manage_vpc_pair_validation.py diff --git a/plugins/module_utils/nd_manage_vpc_pair_actions.py b/plugins/module_utils/nd_manage_vpc_pair_actions.py new file mode 100644 index 00000000..668de805 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_actions.py @@ -0,0 +1,463 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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, Dict, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + ComponentTypeSupportEnum, + VpcActionEnum, + VpcFieldNames, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + _is_update_needed, + _raise_vpc_error, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_validation import ( + _get_pairing_support_details, + _validate_fabric_peering_support, + _validate_switch_conflicts, + _validate_switches_exist_in_fabric, + _validate_vpc_pair_deletion, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_resources import ( + VpcPairResourceError, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_payloads import ( + _build_vpc_pair_payload, + _get_api_field_value, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule as NDModuleV2, + NDModuleError, +) + +def custom_vpc_create(nrm) -> Optional[Dict[str, Any]]: + """ + Custom create function for VPC pairs using RestSend with PUT + discriminator. + - Validates switches exist in fabric (Common.validate_switches_exist) + - Checks for switch conflicts (Common.validate_no_switch_conflicts) + - Uses PUT instead of POST (non-RESTful API) + - Adds vpcAction: "pair" discriminator + - Proper error handling with NDModuleError + - Results aggregation + + Args: + nrm: NDStateMachine instance + + Returns: + API response dictionary or None + + Raises: + ValueError: If fabric_name or switch_id is not provided + AnsibleModule.fail_json: If validation fails + """ + if nrm.module.check_mode: + return nrm.proposed_config + + fabric_name = nrm.module.params.get("fabric_name") + switch_id = nrm.proposed_config.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = nrm.proposed_config.get(VpcFieldNames.PEER_SWITCH_ID) + + # Path validation + if not fabric_name: + raise ValueError("fabric_name is required but was not provided") + if not switch_id: + raise ValueError("switch_id is required but was not provided") + if not peer_switch_id: + raise ValueError("peer_switch_id is required but was not provided") + + # Validation Step 1: both switches must exist in discovered fabric inventory. + _validate_switches_exist_in_fabric( + nrm=nrm, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + ) + + # Validation Step 2: Check for switch conflicts (from Common.validate_no_switch_conflicts) + have_vpc_pairs = nrm.module.params.get("_have", []) + if have_vpc_pairs: + _validate_switch_conflicts([nrm.proposed_config], have_vpc_pairs, nrm.module) + + # Validation Step 3: Check if create is actually needed (idempotence check) + if nrm.existing_config: + want_dict = nrm.proposed_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.proposed_config, 'model_dump') else nrm.proposed_config + have_dict = nrm.existing_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.existing_config, 'model_dump') else nrm.existing_config + + if not _is_update_needed(want_dict, have_dict): + # Already exists in desired state - return existing config without changes + nrm.module.warn( + f"VPC pair {nrm.current_identifier} already exists in desired state - skipping create" + ) + return nrm.existing_config + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + use_virtual_peer_link = nrm.proposed_config.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + # Validate pairing support using dedicated endpoint. + # Only fail when API explicitly states pairing is not allowed. + try: + support_details = _get_pairing_support_details( + nd_v2, + fabric_name=fabric_name, + switch_id=switch_id, + component_type=ComponentTypeSupportEnum.CHECK_PAIRING.value, + ) + if support_details: + is_pairing_allowed = _get_api_field_value( + support_details, "isPairingAllowed", None + ) + if is_pairing_allowed is False: + reason = _get_api_field_value( + support_details, "reason", "pairing blocked by support checks" + ) + _raise_vpc_error( + msg=f"VPC pairing is not allowed for switch {switch_id}: {reason}", + fabric=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + support_details=support_details, + ) + except VpcPairResourceError: + raise + except Exception as support_error: + nrm.module.warn( + f"Pairing support check failed for switch {switch_id}: " + f"{str(support_error).splitlines()[0]}. Continuing with create operation." + ) + + # Validate fabric peering support if virtual peer link is requested. + _validate_fabric_peering_support( + nrm=nrm, + nd_v2=nd_v2, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + use_virtual_peer_link=use_virtual_peer_link, + ) + + # Build path with switch ID using Manage API (not NDFC API) + # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available + # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + + # Build payload with discriminator using helper (supports vpc_pair_details) + payload = _build_vpc_pair_payload(nrm.proposed_config) + + # Log the operation + nrm.format_log( + identifier=nrm.current_identifier, + status="created", + after_data=payload, + sent_payload_data=payload + ) + + try: + # Use PUT (not POST!) for create via RestSend + response = nd_v2.request(path, HttpVerbEnum.PUT, payload) + return response + + except NDModuleError as error: + error_dict = error.to_dict() + # Preserve original API error message with different key to avoid conflict + if 'msg' in error_dict: + error_dict['api_error_msg'] = error_dict.pop('msg') + _raise_vpc_error( + msg=f"Failed to create VPC pair {nrm.current_identifier}: {error.msg}", + fabric=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + path=path, + **error_dict + ) + except VpcPairResourceError: + raise + except Exception as e: + _raise_vpc_error( + msg=f"Failed to create VPC pair {nrm.current_identifier}: {str(e)}", + fabric=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + path=path, + exception_type=type(e).__name__ + ) + + +def custom_vpc_update(nrm) -> Optional[Dict[str, Any]]: + """ + Custom update function for VPC pairs using RestSend. + + - Uses PUT with discriminator (same as create) + - Validates switches exist in fabric + - Checks for switch conflicts + - Uses DeepDiff to detect if update is actually needed + - Proper error handling + + Args: + nrm: NDStateMachine instance + + Returns: + API response dictionary or None + + Raises: + ValueError: If fabric_name or switch_id is not provided + """ + if nrm.module.check_mode: + return nrm.proposed_config + + fabric_name = nrm.module.params.get("fabric_name") + switch_id = nrm.proposed_config.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = nrm.proposed_config.get(VpcFieldNames.PEER_SWITCH_ID) + + # Path validation + if not fabric_name: + raise ValueError("fabric_name is required but was not provided") + if not switch_id: + raise ValueError("switch_id is required but was not provided") + if not peer_switch_id: + raise ValueError("peer_switch_id is required but was not provided") + + # Validation Step 1: both switches must exist in discovered fabric inventory. + _validate_switches_exist_in_fabric( + nrm=nrm, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + ) + + # Validation Step 2: Check for switch conflicts (from Common.validate_no_switch_conflicts) + have_vpc_pairs = nrm.module.params.get("_have", []) + if have_vpc_pairs: + # Filter out the current VPC pair being updated + other_vpc_pairs = [ + vpc for vpc in have_vpc_pairs + if vpc.get(VpcFieldNames.SWITCH_ID) != switch_id + ] + if other_vpc_pairs: + _validate_switch_conflicts([nrm.proposed_config], other_vpc_pairs, nrm.module) + + # Validation Step 3: Check if update is actually needed using DeepDiff + if nrm.existing_config: + want_dict = nrm.proposed_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.proposed_config, 'model_dump') else nrm.proposed_config + have_dict = nrm.existing_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.existing_config, 'model_dump') else nrm.existing_config + + if not _is_update_needed(want_dict, have_dict): + # No changes needed - return existing config + nrm.module.warn( + f"VPC pair {nrm.current_identifier} is already in desired state - skipping update" + ) + return nrm.existing_config + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + use_virtual_peer_link = nrm.proposed_config.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + # Validate fabric peering support if virtual peer link is requested. + _validate_fabric_peering_support( + nrm=nrm, + nd_v2=nd_v2, + fabric_name=fabric_name, + switch_id=switch_id, + peer_switch_id=peer_switch_id, + use_virtual_peer_link=use_virtual_peer_link, + ) + + # Build path with switch ID using Manage API (not NDFC API) + # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available + # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + + # Build payload with discriminator using helper (supports vpc_pair_details) + payload = _build_vpc_pair_payload(nrm.proposed_config) + + # Log the operation + nrm.format_log( + identifier=nrm.current_identifier, + status="updated", + after_data=payload, + sent_payload_data=payload + ) + + try: + # Use PUT for update via RestSend + response = nd_v2.request(path, HttpVerbEnum.PUT, payload) + return response + + except NDModuleError as error: + error_dict = error.to_dict() + # Preserve original API error message with different key to avoid conflict + if 'msg' in error_dict: + error_dict['api_error_msg'] = error_dict.pop('msg') + _raise_vpc_error( + msg=f"Failed to update VPC pair {nrm.current_identifier}: {error.msg}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + **error_dict + ) + except VpcPairResourceError: + raise + except Exception as e: + _raise_vpc_error( + msg=f"Failed to update VPC pair {nrm.current_identifier}: {str(e)}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + exception_type=type(e).__name__ + ) + + +def custom_vpc_delete(nrm) -> None: + """ + Custom delete function for VPC pairs using RestSend with PUT + discriminator. + + - Pre-deletion validation (network/VRF/interface checks) + - Uses PUT instead of DELETE (non-RESTful API) + - Adds vpcAction: "unpair" discriminator + - Proper error handling with NDModuleError + + Args: + nrm: NDStateMachine instance + + Raises: + ValueError: If fabric_name or switch_id is not provided + AnsibleModule.fail_json: If validation fails (networks/VRFs attached) + """ + if nrm.module.check_mode: + return + + fabric_name = nrm.module.params.get("fabric_name") + switch_id = nrm.existing_config.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = nrm.existing_config.get(VpcFieldNames.PEER_SWITCH_ID) + + # Path validation + if not fabric_name: + raise ValueError("fabric_name is required but was not provided") + if not switch_id: + raise ValueError("switch_id is required but was not provided") + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + + # CRITICAL: Pre-deletion validation to prevent data loss + # Checks for active networks, VRFs, and warns about vPC interfaces + vpc_pair_key = f"{switch_id}-{peer_switch_id}" if peer_switch_id else switch_id + + # Track whether force parameter was actually needed + force_delete = nrm.module.params.get("force", False) + validation_succeeded = False + + # Perform validation with timeout protection + try: + _validate_vpc_pair_deletion(nd_v2, fabric_name, switch_id, vpc_pair_key, nrm.module) + validation_succeeded = True + + # If force was enabled but validation succeeded, inform user it wasn't needed + if force_delete: + nrm.module.warn( + f"Force deletion was enabled for {vpc_pair_key}, but pre-deletion validation succeeded. " + f"The 'force: true' parameter was not necessary in this case. " + f"Consider removing 'force: true' to benefit from safety checks in future runs." + ) + + except ValueError as already_unpaired: + # Sentinel from _validate_vpc_pair_deletion: pair no longer exists. + # Treat as idempotent success — nothing to delete. + nrm.module.warn(str(already_unpaired)) + return + + except (NDModuleError, Exception) as validation_error: + # Validation failed - check if force deletion is enabled + if not force_delete: + _raise_vpc_error( + msg=( + f"Pre-deletion validation failed for VPC pair {vpc_pair_key}. " + f"Error: {str(validation_error)}. " + f"If you're certain the VPC pair can be safely deleted, use 'force: true' parameter. " + f"WARNING: Force deletion bypasses safety checks and may cause data loss." + ), + vpc_pair_key=vpc_pair_key, + validation_error=str(validation_error), + force_available=True + ) + else: + # Force enabled and validation failed - this is when force was actually needed + nrm.module.warn( + f"Force deletion enabled for {vpc_pair_key} - bypassing pre-deletion validation. " + f"Validation error was: {str(validation_error)}. " + f"WARNING: Proceeding without safety checks - ensure no data loss will occur." + ) + + # Build path with switch ID using Manage API (not NDFC API) + # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available + # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + + # Build minimal payload with discriminator for delete + payload = { + VpcFieldNames.VPC_ACTION: VpcActionEnum.UNPAIR.value, # ← Discriminator for DELETE + VpcFieldNames.SWITCH_ID: nrm.existing_config.get(VpcFieldNames.SWITCH_ID), + VpcFieldNames.PEER_SWITCH_ID: nrm.existing_config.get(VpcFieldNames.PEER_SWITCH_ID) + } + + # Log the operation + nrm.format_log( + identifier=nrm.current_identifier, + status="deleted", + sent_payload_data=payload + ) + + try: + # Use PUT (not DELETE!) for unpair via RestSend + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = nrm.module.params.get("api_timeout", 30) + try: + nd_v2.request(path, HttpVerbEnum.PUT, payload) + finally: + rest_send.restore_settings() + + except NDModuleError as error: + error_msg = str(error.msg).lower() if error.msg else "" + status_code = error.status or 0 + + # Idempotent handling: if the API says the switch is not part of any + # vPC pair, the pair is already gone — treat as a successful no-op. + if status_code == 400 and "not a part of" in error_msg: + nrm.module.warn( + f"VPC pair {nrm.current_identifier} is already unpaired on the controller. " + f"Treating as idempotent success. API response: {error.msg}" + ) + return + + error_dict = error.to_dict() + # Preserve original API error message with different key to avoid conflict + if 'msg' in error_dict: + error_dict['api_error_msg'] = error_dict.pop('msg') + _raise_vpc_error( + msg=f"Failed to delete VPC pair {nrm.current_identifier}: {error.msg}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + **error_dict + ) + except VpcPairResourceError: + raise + except Exception as e: + _raise_vpc_error( + msg=f"Failed to delete VPC pair {nrm.current_identifier}: {str(e)}", + fabric=fabric_name, + switch_id=switch_id, + path=path, + exception_type=type(e).__name__ + ) + diff --git a/plugins/module_utils/nd_manage_vpc_pair_common.py b/plugins/module_utils/nd_manage_vpc_pair_common.py new file mode 100644 index 00000000..a1d51b62 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_common.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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 traceback +from typing import Any, Dict, List + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_resources import ( + VpcPairResourceError, +) + +# DeepDiff for intelligent change detection +try: + from deepdiff import DeepDiff + HAS_DEEPDIFF = True + DEEPDIFF_IMPORT_ERROR = None +except ImportError: + HAS_DEEPDIFF = False + DEEPDIFF_IMPORT_ERROR = traceback.format_exc() + +def _collection_to_list_flex(collection) -> List[Dict[str, Any]]: + """ + Serialize NDConfigCollection across old/new framework variants. + """ + if collection is None: + return [] + if hasattr(collection, "to_list"): + return collection.to_list() + if hasattr(collection, "to_payload_list"): + return collection.to_payload_list() + if hasattr(collection, "to_ansible_config"): + return collection.to_ansible_config() + return [] + + +def _raise_vpc_error(msg: str, **details: Any) -> None: + """Raise a structured vpc_pair error for main() to format via fail_json.""" + raise VpcPairResourceError(msg=msg, **details) + + +# ===== Helper Functions ===== + + +def _is_update_needed(want: Dict[str, Any], have: Dict[str, Any]) -> bool: + """ + Determine if an update is needed by comparing want and have using DeepDiff. + + Uses DeepDiff for intelligent comparison that handles: + - Field additions + - Value changes + - Nested structure changes + - Ignores field order + + Falls back to simple comparison if DeepDiff is unavailable. + + Args: + want: Desired VPC pair configuration (dict) + have: Current VPC pair configuration (dict) + + Returns: + bool: True if update is needed, False if already in desired state + + Example: + >>> want = {"switchId": "FDO123", "useVirtualPeerLink": True} + >>> have = {"switchId": "FDO123", "useVirtualPeerLink": False} + >>> _is_update_needed(want, have) + True + """ + if not HAS_DEEPDIFF: + # Fallback to simple comparison + return want != have + + try: + # Use DeepDiff for intelligent comparison + diff = DeepDiff(have, want, ignore_order=True) + return bool(diff) + except Exception: + # Fallback to simple comparison if DeepDiff fails + return want != have + + diff --git a/plugins/module_utils/nd_manage_vpc_pair_deploy.py b/plugins/module_utils/nd_manage_vpc_pair_deploy.py new file mode 100644 index 00000000..7c90d302 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_deploy.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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, Dict + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + _raise_vpc_error, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule as NDModuleV2, + NDModuleError, +) + +try: + from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results +except Exception: + from ansible_collections.cisco.nd.plugins.module_utils.results import Results + +def _needs_deployment(result: Dict, nrm) -> bool: + """ + Determine if deployment is needed based on changes and pending operations. + + Deployment is needed if any of: + 1. There are items in the diff (configuration changes) + 2. There are pending create VPC pairs + 3. There are pending delete VPC pairs + + Args: + result: Module result dictionary with diff info + nrm: NDStateMachine instance + + Returns: + True if deployment is needed, False otherwise + """ + # Check if there are any changes in the result + has_changes = result.get("changed", False) + + # Check diff - framework stores before/after + before = result.get("before", []) + after = result.get("after", []) + has_diff_changes = before != after + + # Check pending operations + pending_create = nrm.module.params.get("_pending_create", []) + pending_delete = nrm.module.params.get("_pending_delete", []) + has_pending = bool(pending_create or pending_delete) + + needs_deploy = has_changes or has_diff_changes or has_pending + + return needs_deploy + + +def _is_non_fatal_config_save_error(error: NDModuleError) -> bool: + """ + Return True only for known non-fatal configSave platform limitations. + """ + if not isinstance(error, NDModuleError): + return False + + # Keep this allowlist tight to avoid masking real config-save failures. + if error.status != 500: + return False + + message = (error.msg or "").lower() + non_fatal_signatures = ( + "vpc fabric peering is not supported", + "vpcsanitycheck", + "unexpected error generating vpc configuration", + ) + return any(signature in message for signature in non_fatal_signatures) + + +def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: + """ + Custom deploy function for fabric configuration changes using RestSend. + + - Smart deployment decision (Common.needs_deployment) + - Step 1: Save fabric configuration + - Step 2: Deploy fabric with forceShowRun=true + - Proper error handling with NDModuleError + - Results aggregation + - Only deploys if there are actual changes or pending operations + + Args: + nrm: NDStateMachine instance + fabric_name: Fabric name to deploy + result: Module result dictionary to check for changes + + Returns: + Deployment result dictionary + + Raises: + NDModuleError: If deployment fails + """ + # Smart deployment decision (from Common.needs_deployment) + if not _needs_deployment(result, nrm): + return { + "msg": "No configuration changes or pending operations detected, skipping deployment", + "fabric": fabric_name, + "deployment_needed": False, + "changed": False + } + + if nrm.module.check_mode: + # Dry run deployment info (similar to show_dry_run_deployment_info) + before = result.get("before", []) + after = result.get("after", []) + pending_create = nrm.module.params.get("_pending_create", []) + pending_delete = nrm.module.params.get("_pending_delete", []) + + deployment_info = { + "msg": "CHECK MODE: Would save and deploy fabric configuration", + "fabric": fabric_name, + "deployment_needed": True, + "changed": True, + "would_deploy": True, + "deployment_decision_factors": { + "diff_has_changes": before != after, + "pending_create_operations": len(pending_create), + "pending_delete_operations": len(pending_delete), + "actual_changes": result.get("changed", False) + }, + "planned_actions": [ + f"POST {VpcPairEndpoints.fabric_config_save(fabric_name)}", + f"POST {VpcPairEndpoints.fabric_config_deploy(fabric_name, force_show_run=True)}" + ] + } + return deployment_info + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + results = Results() + + # Step 1: Save config + save_path = VpcPairEndpoints.fabric_config_save(fabric_name) + + try: + nd_v2.request(save_path, HttpVerbEnum.POST, {}) + + results.response_current = { + "RETURN_CODE": nd_v2.status, + "METHOD": "POST", + "REQUEST_PATH": save_path, + "MESSAGE": "Config saved successfully", + "DATA": {}, + } + results.result_current = {"success": True, "changed": True} + results.register_api_call() + + except NDModuleError as error: + if _is_non_fatal_config_save_error(error): + # Known platform limitation warning; continue to deploy step. + nrm.module.warn(f"Config save failed: {error.msg}") + + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "REQUEST_PATH": save_path, + "METHOD": "POST", + "DATA": {}, + } + results.result_current = {"success": True, "changed": False} + results.register_api_call() + else: + # Unknown config-save failures are fatal. + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "REQUEST_PATH": save_path, + "METHOD": "POST", + "DATA": {}, + } + results.result_current = {"success": False, "changed": False} + results.register_api_call() + results.build_final_result() + final_result = dict(results.final_result) + final_msg = final_result.pop("msg", f"Config save failed: {error.msg}") + _raise_vpc_error(msg=final_msg, **final_result) + + # Step 2: Deploy + deploy_path = VpcPairEndpoints.fabric_config_deploy(fabric_name, force_show_run=True) + + try: + nd_v2.request(deploy_path, HttpVerbEnum.POST, {}) + + results.response_current = { + "RETURN_CODE": nd_v2.status, + "METHOD": "POST", + "REQUEST_PATH": deploy_path, + "MESSAGE": "Deployment successful", + "DATA": {}, + } + results.result_current = {"success": True, "changed": True} + results.register_api_call() + + except NDModuleError as error: + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "REQUEST_PATH": deploy_path, + "METHOD": "POST", + "DATA": {}, + } + results.result_current = {"success": False, "changed": False} + results.register_api_call() + + # Build final result and fail + results.build_final_result() + final_result = dict(results.final_result) + final_msg = final_result.pop("msg", "Fabric deployment failed") + _raise_vpc_error(msg=final_msg, **final_result) + + # Build final result + results.build_final_result() + return results.final_result + + diff --git a/plugins/module_utils/nd_manage_vpc_pair_query.py b/plugins/module_utils/nd_manage_vpc_pair_query.py new file mode 100644 index 00000000..0cd23c98 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_query.py @@ -0,0 +1,676 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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, Dict, List, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + VpcFieldNames, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_validation import ( + _is_switch_in_vpc_pair, + _validate_fabric_switches, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_payloads import ( + _get_api_field_value, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule as NDModuleV2, + NDModuleError, +) + +def _get_recommendation_details(nd_v2, fabric_name: str, switch_id: str, timeout: Optional[int] = None) -> Optional[Dict]: + """ + Get VPC pair recommendation details from ND for a specific switch. + + Returns peer switch info and useVirtualPeerLink status. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + timeout: Optional timeout override (uses module param if not specified) + + Returns: + Dict with peer info or None if not found (404) + + Raises: + NDModuleError: On API errors other than 404 (timeouts, 500s, etc.) + """ + # Validate inputs to prevent injection + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: + raise ValueError(f"Invalid switch_id: {switch_id}") + + try: + path = VpcPairEndpoints.switch_vpc_recommendations(fabric_name, switch_id) + + # Use query timeout from module params or override + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + vpc_recommendations = nd_v2.request(path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if vpc_recommendations is None or vpc_recommendations == {}: + return None + + # Validate response structure and look for current peer + if isinstance(vpc_recommendations, list): + for sw in vpc_recommendations: + # Validate each entry + if not isinstance(sw, dict): + nd_v2.module.warn( + f"Skipping invalid recommendation entry for switch {switch_id}: " + f"expected dict, got {type(sw).__name__}" + ) + continue + + # Check for current peer indicators + if sw.get(VpcFieldNames.CURRENT_PEER) or sw.get(VpcFieldNames.IS_CURRENT_PEER): + # Validate required fields exist + if VpcFieldNames.SERIAL_NUMBER not in sw: + nd_v2.module.warn( + f"Recommendation missing serialNumber field for switch {switch_id}" + ) + continue + return sw + elif vpc_recommendations: + # Unexpected response format + nd_v2.module.warn( + f"Unexpected recommendation response format for switch {switch_id}: " + f"expected list, got {type(vpc_recommendations).__name__}" + ) + + return None + except NDModuleError as error: + # Handle expected error codes gracefully + if error.status == 404: + # No recommendations exist (expected for switches without VPC) + return None + elif error.status == 500: + # Server error - recommendation API may be unstable + # Treat as "no recommendations available" to allow graceful degradation + nd_v2.module.warn( + f"VPC recommendation API returned 500 error for switch {switch_id} - " + f"treating as no recommendations available" + ) + return None + # Let other errors (timeouts, rate limits) propagate + raise + + +def _extract_vpc_pairs_from_list_response(vpc_pairs_response: Any) -> List[Dict[str, Any]]: + """ + Extract VPC pair list entries from /vpcPairs response payload. + + Supports common response wrappers used by ND API. + """ + if not isinstance(vpc_pairs_response, dict): + return [] + + candidates = None + for key in (VpcFieldNames.VPC_PAIRS, "items", VpcFieldNames.DATA): + value = vpc_pairs_response.get(key) + if isinstance(value, list): + candidates = value + break + + if not isinstance(candidates, list): + return [] + + extracted_pairs = [] + for item in candidates: + if not isinstance(item, dict): + continue + + switch_id = item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = item.get(VpcFieldNames.PEER_SWITCH_ID) + + # Handle alternate response shape if switch IDs are nested under "switch"/"peerSwitch" + if isinstance(switch_id, dict) and isinstance(peer_switch_id, dict): + switch_id = switch_id.get("switch") + peer_switch_id = peer_switch_id.get("peerSwitch") + + if not switch_id or not peer_switch_id: + continue + + extracted_pairs.append( + { + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: item.get( + VpcFieldNames.USE_VIRTUAL_PEER_LINK, True + ), + } + ) + + return extracted_pairs + + +def _enrich_pairs_from_direct_vpc( + nd_v2, + fabric_name: str, + pairs: List[Dict[str, Any]], + timeout: int = 5, +) -> List[Dict[str, Any]]: + """ + Enrich pair fields from per-switch /vpcPair endpoint when available. + + The /vpcPairs list response may omit fields like useVirtualPeerLink. + This helper preserves lightweight list discovery while improving field + accuracy for gathered output. + """ + if not pairs: + return [] + + enriched_pairs: List[Dict[str, Any]] = [] + for pair in pairs: + enriched = dict(pair) + switch_id = enriched.get(VpcFieldNames.SWITCH_ID) + if not switch_id: + enriched_pairs.append(enriched) + continue + + direct_vpc = None + path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + direct_vpc = nd_v2.request(path, HttpVerbEnum.GET) + except (NDModuleError, Exception): + direct_vpc = None + finally: + rest_send.restore_settings() + + if isinstance(direct_vpc, dict): + peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) + if peer_switch_id: + enriched[VpcFieldNames.PEER_SWITCH_ID] = peer_switch_id + + use_virtual_peer_link = _get_api_field_value( + direct_vpc, + "useVirtualPeerLink", + enriched.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK), + ) + if use_virtual_peer_link is not None: + enriched[VpcFieldNames.USE_VIRTUAL_PEER_LINK] = use_virtual_peer_link + + enriched_pairs.append(enriched) + + return enriched_pairs + + +def _filter_stale_vpc_pairs( + nd_v2, + fabric_name: str, + pairs: List[Dict[str, Any]], + module, +) -> List[Dict[str, Any]]: + """ + Remove stale pairs using overview membership checks. + + `/vpcPairs` can briefly lag after unpair operations. We perform a lightweight + best-effort membership check and drop entries that are explicitly reported as + not part of a vPC pair. + """ + if not pairs: + return [] + + pruned_pairs: List[Dict[str, Any]] = [] + for pair in pairs: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) + if not switch_id: + pruned_pairs.append(pair) + continue + + membership = _is_switch_in_vpc_pair(nd_v2, fabric_name, switch_id, timeout=5) + if membership is False: + module.warn( + f"Excluding stale vPC pair entry for switch {switch_id} " + "because overview reports it is not in a vPC pair." + ) + continue + pruned_pairs.append(pair) + + return pruned_pairs + + +def _filter_vpc_pairs_by_requested_config( + pairs: List[Dict[str, Any]], + config: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """ + Filter queried VPC pairs by explicit pair keys provided in gathered config. + + If gathered config is empty or does not contain complete switch pairs, return + the unfiltered pair list. + """ + if not pairs or not config: + return list(pairs or []) + + requested_pair_keys = set() + for item in config: + switch_id = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if switch_id and peer_switch_id: + requested_pair_keys.add(tuple(sorted([switch_id, peer_switch_id]))) + + if not requested_pair_keys: + return list(pairs) + + filtered_pairs = [] + for item in pairs: + switch_id = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if switch_id and peer_switch_id: + pair_key = tuple(sorted([switch_id, peer_switch_id])) + if pair_key in requested_pair_keys: + filtered_pairs.append(item) + + return filtered_pairs + + +def custom_vpc_query_all(nrm) -> List[Dict]: + """ + Query existing VPC pairs with state-aware enrichment. + + Flow: + - Base query from /vpcPairs list (always attempted first) + - gathered/deleted: use lightweight list-only data when available + - merged/replaced/overridden: enrich with switch inventory and recommendation + APIs to build have/pending_create/pending_delete sets + """ + fabric_name = nrm.module.params.get("fabric_name") + + if not fabric_name or not isinstance(fabric_name, str) or not fabric_name.strip(): + raise ValueError(f"fabric_name must be a non-empty string. Got: {fabric_name!r}") + + state = nrm.module.params.get("state", "merged") + if state == "gathered": + config = nrm.module.params.get("_gather_filter_config") or [] + else: + config = nrm.module.params.get("config") or [] + + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + + def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + nrm.module.params["_fabric_switches"] = [] + nrm.module.params["_fabric_switches_count"] = 0 + nrm.module.params["_ip_to_sn_mapping"] = {} + nrm.module.params["_have"] = lightweight_have + nrm.module.params["_pending_create"] = [] + nrm.module.params["_pending_delete"] = [] + return lightweight_have + + try: + # Step 1: Base query from list endpoint (/vpcPairs) + have = [] + list_query_succeeded = False + try: + list_path = VpcPairEndpoints.vpc_pairs_list(fabric_name) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = nrm.module.params.get("query_timeout", 10) + try: + vpc_pairs_response = nd_v2.request(list_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + have.extend(_extract_vpc_pairs_from_list_response(vpc_pairs_response)) + list_query_succeeded = True + except Exception as list_error: + nrm.module.warn( + f"VPC pairs list query failed for fabric {fabric_name}: " + f"{str(list_error).splitlines()[0]}." + ) + + # Lightweight path for read-only and delete workflows. + # Keep heavy discovery/enrichment only for write states. + if state in ("deleted", "gathered"): + if list_query_succeeded: + if state == "gathered": + have = _filter_vpc_pairs_by_requested_config(have, config) + have = _enrich_pairs_from_direct_vpc( + nd_v2=nd_v2, + fabric_name=fabric_name, + pairs=have, + timeout=5, + ) + have = _filter_stale_vpc_pairs( + nd_v2=nd_v2, + fabric_name=fabric_name, + pairs=have, + module=nrm.module, + ) + return _set_lightweight_context(have) + + nrm.module.warn( + "Skipping switch-level discovery for read-only/delete workflow because " + "the vPC list endpoint is unavailable." + ) + + if state == "gathered": + return _set_lightweight_context([]) + + # Preserve explicit delete intent without full-fabric discovery. + # This keeps delete deterministic and avoids expensive inventory calls. + fallback_have = [] + for item in config: + switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id_val or not peer_switch_id_val: + continue + + use_vpl_val = item.get("use_virtual_peer_link") + if use_vpl_val is None: + use_vpl_val = item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + fallback_have.append( + { + VpcFieldNames.SWITCH_ID: switch_id_val, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id_val, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl_val, + } + ) + + if fallback_have: + nrm.module.warn( + "Using requested delete config as fallback existing set because " + "vPC list query failed." + ) + return _set_lightweight_context(fallback_have) + + if config: + nrm.module.warn( + "Delete config did not contain complete vPC pairs. " + "No delete intents can be built from list-query fallback." + ) + return _set_lightweight_context([]) + + nrm.module.warn( + "Delete-all requested with no explicit pairs and unavailable list endpoint. " + "Falling back to switch-level discovery." + ) + + # Step 2 (write-state enrichment): Query and validate fabric switches. + fabric_switches = _validate_fabric_switches(nd_v2, fabric_name) + + if not fabric_switches: + nrm.module.warn(f"No switches found in fabric {fabric_name}") + nrm.module.params["_fabric_switches"] = [] + nrm.module.params["_fabric_switches_count"] = 0 + nrm.module.params["_have"] = [] + nrm.module.params["_pending_create"] = [] + nrm.module.params["_pending_delete"] = [] + return [] + + # Keep only switch IDs for validation and serialize safely in module params. + fabric_switches_list = list(fabric_switches.keys()) + nrm.module.params["_fabric_switches"] = fabric_switches_list + nrm.module.params["_fabric_switches_count"] = len(fabric_switches) + + # Build IP-to-SN mapping (extract before dict is discarded). + ip_to_sn = { + sw.get(VpcFieldNames.FABRIC_MGMT_IP): sw.get(VpcFieldNames.SERIAL_NUMBER) + for sw in fabric_switches.values() + if VpcFieldNames.FABRIC_MGMT_IP in sw + } + nrm.module.params["_ip_to_sn_mapping"] = ip_to_sn + + # Step 3: Track 3-state VPC pairs (have/pending_create/pending_delete). + pending_create = [] + pending_delete = [] + processed_switches = set() + + desired_pairs = {} + config_switch_ids = set() + for item in config: + # Config items are normalized to snake_case in main(). + switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + + if switch_id_val: + config_switch_ids.add(switch_id_val) + if peer_switch_id_val: + config_switch_ids.add(peer_switch_id_val) + + if switch_id_val and peer_switch_id_val: + desired_pairs[tuple(sorted([switch_id_val, peer_switch_id_val]))] = item + + for switch_id, switch in fabric_switches.items(): + if switch_id in processed_switches: + continue + + vpc_configured = switch.get(VpcFieldNames.VPC_CONFIGURED, False) + vpc_data = switch.get("vpcData", {}) + + if vpc_configured and vpc_data: + peer_switch_id = vpc_data.get("peerSwitchId") + processed_switches.add(switch_id) + processed_switches.add(peer_switch_id) + + # For configured pairs, prefer direct vPC query as source of truth. + try: + vpc_pair_path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = 5 + try: + direct_vpc = nd_v2.request(vpc_pair_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + except (NDModuleError, Exception): + direct_vpc = None + + if direct_vpc: + resolved_peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) or peer_switch_id + if resolved_peer_switch_id: + processed_switches.add(resolved_peer_switch_id) + use_vpl = _get_api_field_value(direct_vpc, "useVirtualPeerLink", False) + + # Direct /vpcPair can be stale for a short period after delete. + # Cross-check overview to avoid reporting stale active pairs. + membership = _is_switch_in_vpc_pair( + nd_v2, fabric_name, switch_id, timeout=5 + ) + if membership is False: + pair_key = None + if resolved_peer_switch_id: + pair_key = tuple(sorted([switch_id, resolved_peer_switch_id])) + desired_item = desired_pairs.get(pair_key) if pair_key else None + desired_use_vpl = None + if desired_item: + desired_use_vpl = desired_item.get("use_virtual_peer_link") + if desired_use_vpl is None: + desired_use_vpl = desired_item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) + + # Narrow override: trust direct payload only for write states + # when it matches desired pair intent. + if state in ("merged", "replaced", "overridden") and desired_item is not None: + if desired_use_vpl is None or bool(desired_use_vpl) == bool(use_vpl): + nrm.module.warn( + f"Overview membership check returned 'not paired' for switch {switch_id}, " + "but direct /vpcPair matched requested config. Treating pair as active." + ) + membership = True + if membership is False: + pending_delete.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + have.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + # Direct query failed - fall back to recommendation. + try: + recommendation = _get_recommendation_details(nd_v2, fabric_name, switch_id) + except Exception as rec_error: + error_msg = str(rec_error).splitlines()[0] + nrm.module.warn( + f"Recommendation query failed for switch {switch_id}: {error_msg}. " + f"Unable to read configured vPC pair details." + ) + recommendation = None + + if recommendation: + resolved_peer_switch_id = _get_api_field_value(recommendation, "serialNumber") or peer_switch_id + if resolved_peer_switch_id: + processed_switches.add(resolved_peer_switch_id) + use_vpl = _get_api_field_value(recommendation, "useVirtualPeerLink", False) + have.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + # VPC configured but query failed - mark as pending delete. + pending_delete.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: False, + }) + elif not config_switch_ids or switch_id in config_switch_ids: + # For unconfigured switches, prefer direct vPC pair query first. + try: + vpc_pair_path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = 5 + try: + direct_vpc = nd_v2.request(vpc_pair_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + except (NDModuleError, Exception): + direct_vpc = None + + if direct_vpc: + peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) + if peer_switch_id: + processed_switches.add(switch_id) + processed_switches.add(peer_switch_id) + + use_vpl = _get_api_field_value(direct_vpc, "useVirtualPeerLink", False) + membership = _is_switch_in_vpc_pair( + nd_v2, fabric_name, switch_id, timeout=5 + ) + if membership is False: + pair_key = tuple(sorted([switch_id, peer_switch_id])) + desired_item = desired_pairs.get(pair_key) + desired_use_vpl = None + if desired_item: + desired_use_vpl = desired_item.get("use_virtual_peer_link") + if desired_use_vpl is None: + desired_use_vpl = desired_item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) + + if state in ("merged", "replaced", "overridden") and desired_item is not None: + if desired_use_vpl is None or bool(desired_use_vpl) == bool(use_vpl): + nrm.module.warn( + f"Overview membership check returned 'not paired' for switch {switch_id}, " + "but direct /vpcPair matched requested config. Treating pair as active." + ) + membership = True + if membership is False: + pending_delete.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + have.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + else: + # No direct pair; check recommendation for pending create candidates. + try: + recommendation = _get_recommendation_details(nd_v2, fabric_name, switch_id) + except Exception as rec_error: + error_msg = str(rec_error).splitlines()[0] + nrm.module.warn( + f"Recommendation query failed for switch {switch_id}: {error_msg}. " + f"No recommendation details available." + ) + recommendation = None + + if recommendation: + peer_switch_id = _get_api_field_value(recommendation, "serialNumber") + if peer_switch_id: + processed_switches.add(switch_id) + processed_switches.add(peer_switch_id) + + use_vpl = _get_api_field_value(recommendation, "useVirtualPeerLink", False) + pending_create.append({ + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, + }) + + # Step 4: Store all states for use in create/update/delete. + nrm.module.params["_have"] = have + nrm.module.params["_pending_create"] = pending_create + nrm.module.params["_pending_delete"] = pending_delete + + # Build effective existing set for state reconciliation: + # - Include active pairs (have) and pending-create pairs. + # - Exclude pending-delete pairs from active set to avoid stale + # idempotence false-negatives right after unpair operations. + pair_by_key = {} + for pair in pending_create + have: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id or not peer_switch_id: + continue + key = tuple(sorted([switch_id, peer_switch_id])) + pair_by_key[key] = pair + + for pair in pending_delete: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id or not peer_switch_id: + continue + key = tuple(sorted([switch_id, peer_switch_id])) + pair_by_key.pop(key, None) + + existing_pairs = list(pair_by_key.values()) + return existing_pairs + + except NDModuleError as error: + error_dict = error.to_dict() + if "msg" in error_dict: + error_dict["api_error_msg"] = error_dict.pop("msg") + _raise_vpc_error( + msg=f"Failed to query VPC pairs: {error.msg}", + fabric=fabric_name, + **error_dict + ) + except VpcPairResourceError: + raise + except Exception as e: + _raise_vpc_error( + msg=f"Failed to query VPC pairs: {str(e)}", + fabric=fabric_name, + exception_type=type(e).__name__ + ) + + diff --git a/plugins/module_utils/nd_manage_vpc_pair_runner.py b/plugins/module_utils/nd_manage_vpc_pair_runner.py new file mode 100644 index 00000000..085f39d7 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_runner.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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, Dict + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + VpcFieldNames, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + _collection_to_list_flex, +) + +def run_vpc_module(nrm) -> Dict[str, Any]: + """ + Run VPC module state machine with VPC-specific gathered output. + + gathered is the query/read-only mode for VPC pairs. + """ + state = nrm.module.params.get("state", "merged") + config = nrm.module.params.get("config", []) + + if state == "gathered": + nrm.add_logs_and_outputs() + nrm.result["changed"] = False + + current_pairs = nrm.result.get("current", []) or [] + pending_delete = nrm.module.params.get("_pending_delete", []) or [] + + # Exclude pairs in pending-delete from active gathered set. + pending_delete_keys = set() + for pair in pending_delete: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") + if switch_id and peer_switch_id: + pending_delete_keys.add(tuple(sorted([switch_id, peer_switch_id]))) + + filtered_current = [] + for pair in current_pairs: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") + if switch_id and peer_switch_id: + pair_key = tuple(sorted([switch_id, peer_switch_id])) + if pair_key in pending_delete_keys: + continue + filtered_current.append(pair) + + nrm.result["current"] = filtered_current + nrm.result["gathered"] = { + "vpc_pairs": filtered_current, + "pending_create_vpc_pairs": nrm.module.params.get("_pending_create", []), + "pending_delete_vpc_pairs": pending_delete, + } + return nrm.result + + # state=deleted with empty config means "delete all existing pairs in this fabric". + # + # state=overridden with empty config has the same user intent (TC4): + # remove all existing pairs from this fabric. + if state in ("deleted", "overridden") and not config: + # Use the live existing collection from NDStateMachine. + # nrm.result["current"] is only populated after add_logs_and_outputs(), so relying on + # it here would incorrectly produce an empty delete list. + existing_pairs = _collection_to_list_flex(getattr(nrm, "existing", None)) + if not existing_pairs: + existing_pairs = nrm.result.get("current", []) or [] + + delete_all_config = [] + for pair in existing_pairs: + switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") + peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") + if switch_id and peer_switch_id: + use_vpl = pair.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) + if use_vpl is None: + use_vpl = pair.get("use_virtual_peer_link", True) + delete_all_config.append( + { + "switch_id": switch_id, + "peer_switch_id": peer_switch_id, + "use_virtual_peer_link": use_vpl, + } + ) + config = delete_all_config + # Force explicit delete operations instead of relying on overridden-state + # reconciliation behavior with empty desired config. + if state == "overridden": + state = "deleted" + + nrm.manage_state(state=state, new_configs=config) + nrm.add_logs_and_outputs() + return nrm.result + + +# ===== Module Entry Point ===== + + diff --git a/plugins/module_utils/nd_manage_vpc_pair_validation.py b/plugins/module_utils/nd_manage_vpc_pair_validation.py new file mode 100644 index 00000000..af1a9d75 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_validation.py @@ -0,0 +1,604 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami S +# 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, Dict, List, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + ComponentTypeSupportEnum, + VpcFieldNames, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + _raise_vpc_error, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_payloads import ( + _get_api_field_value, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModuleError + +def _get_pairing_support_details( + nd_v2, + fabric_name: str, + switch_id: str, + component_type: str = ComponentTypeSupportEnum.CHECK_PAIRING.value, + timeout: Optional[int] = None, +) -> Optional[Dict[str, Any]]: + """ + Query /vpcPairSupport endpoint to validate pairing support. + """ + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: + raise ValueError(f"Invalid switch_id: {switch_id}") + + path = VpcPairEndpoints.switch_vpc_support( + fabric_name=fabric_name, + switch_id=switch_id, + component_type=component_type, + ) + + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + support_details = nd_v2.request(path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if isinstance(support_details, dict): + return support_details + return None + + +def _validate_fabric_peering_support( + nrm, + nd_v2, + fabric_name: str, + switch_id: str, + peer_switch_id: str, + use_virtual_peer_link: bool, +) -> None: + """ + Validate fabric peering support when virtual peer link is requested. + + If API explicitly reports unsupported fabric peering, logs warning and + continues. If support API is unavailable, logs warning and continues. + """ + if not use_virtual_peer_link: + return + + switches_to_check = [switch_id, peer_switch_id] + for support_switch_id in switches_to_check: + if not support_switch_id: + continue + + try: + support_details = _get_pairing_support_details( + nd_v2, + fabric_name=fabric_name, + switch_id=support_switch_id, + component_type=ComponentTypeSupportEnum.CHECK_FABRIC_PEERING_SUPPORT.value, + ) + if not support_details: + continue + + is_supported = _get_api_field_value( + support_details, "isVpcFabricPeeringSupported", None + ) + if is_supported is False: + status = _get_api_field_value( + support_details, "status", "Fabric peering not supported" + ) + nrm.module.warn( + f"VPC fabric peering is not supported for switch {support_switch_id}: {status}. " + f"Continuing, but config save/deploy may report a platform limitation. " + f"Consider setting use_virtual_peer_link=false for this platform." + ) + except Exception as support_error: + nrm.module.warn( + f"Fabric peering support check failed for switch {support_switch_id}: " + f"{str(support_error).splitlines()[0]}. Continuing with create/update operation." + ) + + +def _get_consistency_details( + nd_v2, + fabric_name: str, + switch_id: str, + timeout: Optional[int] = None, +) -> Optional[Dict[str, Any]]: + """ + Query /vpcPairConsistency endpoint for consistency diagnostics. + """ + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: + raise ValueError(f"Invalid switch_id: {switch_id}") + + path = VpcPairEndpoints.switch_vpc_consistency(fabric_name, switch_id) + + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + consistency_details = nd_v2.request(path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if isinstance(consistency_details, dict): + return consistency_details + return None + + +def _is_switch_in_vpc_pair( + nd_v2, + fabric_name: str, + switch_id: str, + timeout: Optional[int] = None, +) -> Optional[bool]: + """ + Best-effort active-membership check via vPC overview endpoint. + + Returns: + - True: overview query succeeded (switch is part of a vPC pair) + - False: API explicitly reports switch is not in a vPC pair + - None: unknown/error (do not block caller logic) + """ + if not fabric_name or not switch_id: + return None + + path = VpcPairEndpoints.switch_vpc_overview( + fabric_name, switch_id, component_type="full" + ) + + if timeout is None: + timeout = nd_v2.module.params.get("query_timeout", 10) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + nd_v2.request(path, HttpVerbEnum.GET) + return True + except NDModuleError as error: + error_msg = (error.msg or "").lower() + if error.status == 400 and "not a part of vpc pair" in error_msg: + return False + return None + except Exception: + return None + finally: + rest_send.restore_settings() + + +def _validate_fabric_switches(nd_v2, fabric_name: str) -> Dict[str, Dict]: + """ + Query and validate fabric switch inventory. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + + Returns: + Dict mapping switch serial number to switch info + + Raises: + ValueError: If inputs are invalid + NDModuleError: If fabric switch query fails + """ + # Input validation + if not fabric_name or not isinstance(fabric_name, str): + raise ValueError(f"Invalid fabric_name: {fabric_name}") + + # Use api_timeout from module params + timeout = nd_v2.module.params.get("api_timeout", 30) + + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = timeout + try: + switches_path = VpcPairEndpoints.fabric_switches(fabric_name) + switches_response = nd_v2.request(switches_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + if not switches_response: + return {} + + # Validate response structure + if not isinstance(switches_response, dict): + nd_v2.module.warn( + f"Unexpected switches response format: expected dict, got {type(switches_response).__name__}" + ) + return {} + + switches = switches_response.get(VpcFieldNames.SWITCHES, []) + + # Validate switches is a list + if not isinstance(switches, list): + nd_v2.module.warn( + f"Unexpected switches format: expected list, got {type(switches).__name__}" + ) + return {} + + # Build validated switch dictionary + result = {} + for sw in switches: + if not isinstance(sw, dict): + nd_v2.module.warn(f"Skipping invalid switch entry: expected dict, got {type(sw).__name__}") + continue + + serial_number = sw.get(VpcFieldNames.SERIAL_NUMBER) + if not serial_number: + continue + + # Validate serial number format + if not isinstance(serial_number, str) or len(serial_number) < 3: + nd_v2.module.warn(f"Skipping switch with invalid serial number: {serial_number}") + continue + + result[serial_number] = sw + + return result + + +def _validate_switch_conflicts(want_configs: List[Dict], have_vpc_pairs: List[Dict], module) -> None: + """ + Validate that switches in want configs aren't already in different VPC pairs. + + Optimized implementation using index-based lookup for O(n) time complexity instead of O(n²). + + Args: + want_configs: List of desired VPC pair configs + have_vpc_pairs: List of existing VPC pairs + module: AnsibleModule instance for fail_json + + Raises: + AnsibleModule.fail_json: If switch conflicts detected + """ + conflicts = [] + + # Build index of existing VPC pairs by switch ID - O(m) where m = len(have_vpc_pairs) + # Maps switch_id -> list of VPC pairs containing that switch + switch_to_vpc_index = {} + for have in have_vpc_pairs: + have_switch_id = have.get(VpcFieldNames.SWITCH_ID) + have_peer_id = have.get(VpcFieldNames.PEER_SWITCH_ID) + + if have_switch_id: + if have_switch_id not in switch_to_vpc_index: + switch_to_vpc_index[have_switch_id] = [] + switch_to_vpc_index[have_switch_id].append(have) + + if have_peer_id: + if have_peer_id not in switch_to_vpc_index: + switch_to_vpc_index[have_peer_id] = [] + switch_to_vpc_index[have_peer_id].append(have) + + # Check each want config for conflicts - O(n) where n = len(want_configs) + for want in want_configs: + want_switches = {want.get(VpcFieldNames.SWITCH_ID), want.get(VpcFieldNames.PEER_SWITCH_ID)} + want_switches.discard(None) + + # Build set of all VPC pairs that contain any switch from want_switches - O(1) lookup per switch + # Use set to track VPC IDs we've already checked to avoid duplicate processing + conflicting_vpcs = {} # vpc_id -> vpc dict + for switch in want_switches: + if switch in switch_to_vpc_index: + for vpc in switch_to_vpc_index[switch]: + # Use tuple of sorted switch IDs as unique identifier + vpc_id = tuple(sorted([vpc.get(VpcFieldNames.SWITCH_ID), vpc.get(VpcFieldNames.PEER_SWITCH_ID)])) + # Only add if we haven't seen this VPC ID before (avoids duplicate processing) + if vpc_id not in conflicting_vpcs: + conflicting_vpcs[vpc_id] = vpc + + # Check each potentially conflicting VPC pair + for vpc_id, have in conflicting_vpcs.items(): + have_switches = {have.get(VpcFieldNames.SWITCH_ID), have.get(VpcFieldNames.PEER_SWITCH_ID)} + have_switches.discard(None) + + # Same VPC pair is OK + if want_switches == have_switches: + continue + + # Check for switch overlap with different pairs + switch_overlap = want_switches & have_switches + if switch_overlap: + # Filter out None values and ensure strings for joining + overlap_list = [str(s) for s in switch_overlap if s is not None] + want_key = f"{want.get(VpcFieldNames.SWITCH_ID)}-{want.get(VpcFieldNames.PEER_SWITCH_ID)}" + have_key = f"{have.get(VpcFieldNames.SWITCH_ID)}-{have.get(VpcFieldNames.PEER_SWITCH_ID)}" + conflicts.append( + f"Switch(es) {', '.join(overlap_list)} in wanted VPC pair {want_key} " + f"are already part of existing VPC pair {have_key}" + ) + + if conflicts: + _raise_vpc_error( + msg="Switch conflicts detected. A switch can only be part of one VPC pair at a time.", + conflicts=conflicts + ) + + +def _validate_switches_exist_in_fabric( + nrm, + fabric_name: str, + switch_id: str, + peer_switch_id: str, +) -> None: + """ + Validate both switches exist in discovered fabric inventory. + + This check is mandatory for create/update. Empty inventory is treated as + a validation error to avoid bypassing guardrails and failing later with a + less actionable API error. + """ + fabric_switches = nrm.module.params.get("_fabric_switches") + + if fabric_switches is None: + _raise_vpc_error( + msg=( + f"Switch validation failed for fabric '{fabric_name}': switch inventory " + "was not loaded from query_all. Unable to validate requested vPC pair." + ), + vpc_pair_key=nrm.current_identifier, + fabric=fabric_name, + ) + + valid_switches = sorted(list(fabric_switches)) + if not valid_switches: + _raise_vpc_error( + msg=( + f"Switch validation failed for fabric '{fabric_name}': no switches were " + "discovered in fabric inventory. Cannot create/update vPC pairs without " + "validated switch membership." + ), + vpc_pair_key=nrm.current_identifier, + fabric=fabric_name, + total_valid_switches=0, + ) + + missing_switches = [] + if switch_id not in fabric_switches: + missing_switches.append(switch_id) + if peer_switch_id not in fabric_switches: + missing_switches.append(peer_switch_id) + + if not missing_switches: + return + + max_switches_in_error = 10 + error_msg = ( + f"Switch validation failed: The following switch(es) do not exist in fabric '{fabric_name}':\n" + f" Missing switches: {', '.join(missing_switches)}\n" + f" Affected vPC pair: {nrm.current_identifier}\n\n" + "Please ensure:\n" + " 1. Switch serial numbers are correct (not IP addresses)\n" + " 2. Switches are discovered and present in the fabric\n" + " 3. You have the correct fabric name specified\n\n" + ) + + if len(valid_switches) <= max_switches_in_error: + error_msg += f"Valid switches in fabric: {', '.join(valid_switches)}" + else: + error_msg += ( + f"Valid switches in fabric (first {max_switches_in_error}): " + f"{', '.join(valid_switches[:max_switches_in_error])} ... and " + f"{len(valid_switches) - max_switches_in_error} more" + ) + + _raise_vpc_error( + msg=error_msg, + missing_switches=missing_switches, + vpc_pair_key=nrm.current_identifier, + total_valid_switches=len(valid_switches), + ) + + +def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pair_key: str, module) -> None: + """ + Validate VPC pair can be safely deleted by checking for dependencies. + + This function prevents data loss by ensuring the VPC pair has no active: + 1. Networks (networkCount must be 0 for all statuses) + 2. VRFs (vrfCount must be 0 for all statuses) + 3. Warns if vPC interfaces exist (vpcInterfaceCount > 0) + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + vpc_pair_key: VPC pair identifier (e.g., "FDO123-FDO456") for error messages + module: AnsibleModule instance for fail_json/warn + + Raises: + AnsibleModule.fail_json: If VPC pair has active networks or VRFs + + Example: + _validate_vpc_pair_deletion(nd_v2, "myFabric", "FDO123", "FDO123-FDO456", module) + """ + try: + # Query overview endpoint with full component data + overview_path = VpcPairEndpoints.switch_vpc_overview(fabric_name, switch_id, component_type="full") + + # Bound overview validation call by query_timeout for deterministic behavior. + rest_send = nd_v2._get_rest_send() + rest_send.save_settings() + rest_send.timeout = nd_v2.module.params.get("query_timeout", 10) + try: + response = nd_v2.request(overview_path, HttpVerbEnum.GET) + finally: + rest_send.restore_settings() + + # If no response, VPC pair doesn't exist - deletion not needed + if not response: + module.warn( + f"VPC pair {vpc_pair_key} not found in overview query. " + f"It may not exist or may have already been deleted." + ) + return + + # Query consistency endpoint for additional diagnostics before deletion. + # This is best effort and should not block deletion workflows. + try: + consistency = _get_consistency_details(nd_v2, fabric_name, switch_id) + if consistency: + type2_consistency = _get_api_field_value(consistency, "type2Consistency", None) + if type2_consistency is False: + reason = _get_api_field_value( + consistency, "type2ConsistencyReason", "unknown reason" + ) + module.warn( + f"VPC pair {vpc_pair_key} reports type2 consistency issue: {reason}" + ) + except Exception as consistency_error: + module.warn( + f"Failed to query consistency details for VPC pair {vpc_pair_key}: " + f"{str(consistency_error).splitlines()[0]}" + ) + + # Validate response structure + if not isinstance(response, dict): + _raise_vpc_error( + msg=f"Expected dict response from vPC pair overview for {vpc_pair_key}, got {type(response).__name__}", + response=response + ) + + # Validate overlay data exists + overlay = response.get(VpcFieldNames.OVERLAY) + if not overlay: + _raise_vpc_error( + msg=( + f"vPC pair {vpc_pair_key} might not exist or overlay data unavailable. " + f"Cannot safely validate deletion." + ), + vpc_pair_key=vpc_pair_key, + response=response + ) + + # Check 1: Validate no networks are attached + network_count = overlay.get(VpcFieldNames.NETWORK_COUNT, {}) + if isinstance(network_count, dict): + for status, count in network_count.items(): + try: + count_int = int(count) + if count_int != 0: + _raise_vpc_error( + msg=( + f"Cannot delete vPC pair {vpc_pair_key}. " + f"{count_int} network(s) with status '{status}' still exist. " + f"Remove all networks from this vPC pair before deleting it." + ), + vpc_pair_key=vpc_pair_key, + network_count=network_count, + blocking_status=status, + blocking_count=count_int + ) + except (ValueError, TypeError) as e: + # Best effort - log warning and continue + module.warn(f"Error parsing network count for status '{status}': {e}") + elif network_count: + # Non-dict format - log warning + module.warn( + f"networkCount is not a dict for {vpc_pair_key}: {type(network_count).__name__}. " + f"Skipping network validation." + ) + + # Check 2: Validate no VRFs are attached + vrf_count = overlay.get(VpcFieldNames.VRF_COUNT, {}) + if isinstance(vrf_count, dict): + for status, count in vrf_count.items(): + try: + count_int = int(count) + if count_int != 0: + _raise_vpc_error( + msg=( + f"Cannot delete vPC pair {vpc_pair_key}. " + f"{count_int} VRF(s) with status '{status}' still exist. " + f"Remove all VRFs from this vPC pair before deleting it." + ), + vpc_pair_key=vpc_pair_key, + vrf_count=vrf_count, + blocking_status=status, + blocking_count=count_int + ) + except (ValueError, TypeError) as e: + # Best effort - log warning and continue + module.warn(f"Error parsing VRF count for status '{status}': {e}") + elif vrf_count: + # Non-dict format - log warning + module.warn( + f"vrfCount is not a dict for {vpc_pair_key}: {type(vrf_count).__name__}. " + f"Skipping VRF validation." + ) + + # Check 3: Warn if vPC interfaces exist (non-blocking) + inventory = response.get(VpcFieldNames.INVENTORY, {}) + if inventory and isinstance(inventory, dict): + vpc_interface_count = inventory.get(VpcFieldNames.VPC_INTERFACE_COUNT) + if vpc_interface_count: + try: + count_int = int(vpc_interface_count) + if count_int > 0: + module.warn( + f"vPC pair {vpc_pair_key} has {count_int} vPC interface(s). " + f"Deletion may fail or require manual cleanup of interfaces. " + f"Consider removing vPC interfaces before deleting the vPC pair." + ) + except (ValueError, TypeError) as e: + # Best effort - just log debug message + pass + elif not inventory: + # No inventory data - warn user + module.warn( + f"Inventory data not available in overview response for {vpc_pair_key}. " + f"Proceeding with deletion, but it may fail if vPC interfaces exist." + ) + + except VpcPairResourceError: + raise + except NDModuleError as error: + error_msg = str(error.msg).lower() if error.msg else "" + status_code = error.status or 0 + + # If the overview query returns 400 with "not a part of" it means + # the pair no longer exists on the controller. Signal the caller + # by raising a ValueError with a sentinel message so that the + # delete function can treat this as an idempotent no-op. + if status_code == 400 and "not a part of" in error_msg: + raise ValueError( + f"VPC pair {vpc_pair_key} is already unpaired on the controller. " + f"No deletion required." + ) + + # Best effort validation - if overview query fails, log warning and proceed + # The API will still reject deletion if dependencies exist + module.warn( + f"Could not validate vPC pair {vpc_pair_key} for deletion: {error.msg}. " + f"Proceeding with deletion attempt. API will reject if dependencies exist." + ) + + except Exception as e: + # Best effort validation - log warning and continue + module.warn( + f"Unexpected error validating VPC pair {vpc_pair_key} for deletion: {str(e)}. " + f"Proceeding with deletion attempt." + ) + + +# ===== Custom Action Functions (used by VpcPairResourceService via orchestrator) ===== + + diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index c6fa7358..334fc987 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -267,11 +267,7 @@ sample: [] """ -import json -import logging import sys -import traceback -from typing import Any, Dict, List, Optional from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging @@ -291,2032 +287,31 @@ _nd_config_collection = None # noqa: F841 _nd_utils = None # noqa: F841 -# Enum imports -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( - ComponentTypeSupportEnum, - VpcActionEnum, VpcFieldNames, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_module_model import ( - VpcPairModel, +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_actions import ( + custom_vpc_create, + custom_vpc_delete, + custom_vpc_update, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( - VpcPairEndpoints, +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + DEEPDIFF_IMPORT_ERROR, + HAS_DEEPDIFF, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_payloads import ( - _build_vpc_pair_payload, - _get_api_field_value, +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_deploy import ( + _needs_deployment, + custom_vpc_deploy, ) - -# RestSend imports -from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( - NDModule as NDModuleV2, - NDModuleError, +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_query import ( + custom_vpc_query_all, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_runner import ( + run_vpc_module, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_module_model import ( + VpcPairModel, ) -try: - from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results -except Exception: - from ansible_collections.cisco.nd.plugins.module_utils.results import Results - -# DeepDiff for intelligent change detection -try: - from deepdiff import DeepDiff - HAS_DEEPDIFF = True - DEEPDIFF_IMPORT_ERROR = None -except ImportError: - HAS_DEEPDIFF = False - DEEPDIFF_IMPORT_ERROR = traceback.format_exc() - - -def _collection_to_list_flex(collection) -> List[Dict[str, Any]]: - """ - Serialize NDConfigCollection across old/new framework variants. - """ - if collection is None: - return [] - if hasattr(collection, "to_list"): - return collection.to_list() - if hasattr(collection, "to_payload_list"): - return collection.to_payload_list() - if hasattr(collection, "to_ansible_config"): - return collection.to_ansible_config() - return [] - - -def _raise_vpc_error(msg: str, **details: Any) -> None: - """Raise a structured vpc_pair error for main() to format via fail_json.""" - raise VpcPairResourceError(msg=msg, **details) - - -# ===== Helper Functions ===== - - -def _is_update_needed(want: Dict[str, Any], have: Dict[str, Any]) -> bool: - """ - Determine if an update is needed by comparing want and have using DeepDiff. - - Uses DeepDiff for intelligent comparison that handles: - - Field additions - - Value changes - - Nested structure changes - - Ignores field order - - Falls back to simple comparison if DeepDiff is unavailable. - - Args: - want: Desired VPC pair configuration (dict) - have: Current VPC pair configuration (dict) - - Returns: - bool: True if update is needed, False if already in desired state - - Example: - >>> want = {"switchId": "FDO123", "useVirtualPeerLink": True} - >>> have = {"switchId": "FDO123", "useVirtualPeerLink": False} - >>> _is_update_needed(want, have) - True - """ - if not HAS_DEEPDIFF: - # Fallback to simple comparison - return want != have - - try: - # Use DeepDiff for intelligent comparison - diff = DeepDiff(have, want, ignore_order=True) - return bool(diff) - except Exception: - # Fallback to simple comparison if DeepDiff fails - return want != have - - -def _get_recommendation_details(nd_v2, fabric_name: str, switch_id: str, timeout: Optional[int] = None) -> Optional[Dict]: - """ - Get VPC pair recommendation details from ND for a specific switch. - - Returns peer switch info and useVirtualPeerLink status. - - Args: - nd_v2: NDModuleV2 instance for RestSend - fabric_name: Fabric name - switch_id: Switch serial number - timeout: Optional timeout override (uses module param if not specified) - - Returns: - Dict with peer info or None if not found (404) - - Raises: - NDModuleError: On API errors other than 404 (timeouts, 500s, etc.) - """ - # Validate inputs to prevent injection - if not fabric_name or not isinstance(fabric_name, str): - raise ValueError(f"Invalid fabric_name: {fabric_name}") - if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: - raise ValueError(f"Invalid switch_id: {switch_id}") - - try: - path = VpcPairEndpoints.switch_vpc_recommendations(fabric_name, switch_id) - - # Use query timeout from module params or override - if timeout is None: - timeout = nd_v2.module.params.get("query_timeout", 10) - - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = timeout - try: - vpc_recommendations = nd_v2.request(path, HttpVerbEnum.GET) - finally: - rest_send.restore_settings() - - if vpc_recommendations is None or vpc_recommendations == {}: - return None - - # Validate response structure and look for current peer - if isinstance(vpc_recommendations, list): - for sw in vpc_recommendations: - # Validate each entry - if not isinstance(sw, dict): - nd_v2.module.warn( - f"Skipping invalid recommendation entry for switch {switch_id}: " - f"expected dict, got {type(sw).__name__}" - ) - continue - - # Check for current peer indicators - if sw.get(VpcFieldNames.CURRENT_PEER) or sw.get(VpcFieldNames.IS_CURRENT_PEER): - # Validate required fields exist - if VpcFieldNames.SERIAL_NUMBER not in sw: - nd_v2.module.warn( - f"Recommendation missing serialNumber field for switch {switch_id}" - ) - continue - return sw - elif vpc_recommendations: - # Unexpected response format - nd_v2.module.warn( - f"Unexpected recommendation response format for switch {switch_id}: " - f"expected list, got {type(vpc_recommendations).__name__}" - ) - - return None - except NDModuleError as error: - # Handle expected error codes gracefully - if error.status == 404: - # No recommendations exist (expected for switches without VPC) - return None - elif error.status == 500: - # Server error - recommendation API may be unstable - # Treat as "no recommendations available" to allow graceful degradation - nd_v2.module.warn( - f"VPC recommendation API returned 500 error for switch {switch_id} - " - f"treating as no recommendations available" - ) - return None - # Let other errors (timeouts, rate limits) propagate - raise - - -def _extract_vpc_pairs_from_list_response(vpc_pairs_response: Any) -> List[Dict[str, Any]]: - """ - Extract VPC pair list entries from /vpcPairs response payload. - - Supports common response wrappers used by ND API. - """ - if not isinstance(vpc_pairs_response, dict): - return [] - - candidates = None - for key in (VpcFieldNames.VPC_PAIRS, "items", VpcFieldNames.DATA): - value = vpc_pairs_response.get(key) - if isinstance(value, list): - candidates = value - break - - if not isinstance(candidates, list): - return [] - - extracted_pairs = [] - for item in candidates: - if not isinstance(item, dict): - continue - - switch_id = item.get(VpcFieldNames.SWITCH_ID) - peer_switch_id = item.get(VpcFieldNames.PEER_SWITCH_ID) - - # Handle alternate response shape if switch IDs are nested under "switch"/"peerSwitch" - if isinstance(switch_id, dict) and isinstance(peer_switch_id, dict): - switch_id = switch_id.get("switch") - peer_switch_id = peer_switch_id.get("peerSwitch") - - if not switch_id or not peer_switch_id: - continue - - extracted_pairs.append( - { - VpcFieldNames.SWITCH_ID: switch_id, - VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: item.get( - VpcFieldNames.USE_VIRTUAL_PEER_LINK, True - ), - } - ) - - return extracted_pairs - - -def _enrich_pairs_from_direct_vpc( - nd_v2, - fabric_name: str, - pairs: List[Dict[str, Any]], - timeout: int = 5, -) -> List[Dict[str, Any]]: - """ - Enrich pair fields from per-switch /vpcPair endpoint when available. - - The /vpcPairs list response may omit fields like useVirtualPeerLink. - This helper preserves lightweight list discovery while improving field - accuracy for gathered output. - """ - if not pairs: - return [] - - enriched_pairs: List[Dict[str, Any]] = [] - for pair in pairs: - enriched = dict(pair) - switch_id = enriched.get(VpcFieldNames.SWITCH_ID) - if not switch_id: - enriched_pairs.append(enriched) - continue - - direct_vpc = None - path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = timeout - try: - direct_vpc = nd_v2.request(path, HttpVerbEnum.GET) - except (NDModuleError, Exception): - direct_vpc = None - finally: - rest_send.restore_settings() - - if isinstance(direct_vpc, dict): - peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) - if peer_switch_id: - enriched[VpcFieldNames.PEER_SWITCH_ID] = peer_switch_id - - use_virtual_peer_link = _get_api_field_value( - direct_vpc, - "useVirtualPeerLink", - enriched.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK), - ) - if use_virtual_peer_link is not None: - enriched[VpcFieldNames.USE_VIRTUAL_PEER_LINK] = use_virtual_peer_link - - enriched_pairs.append(enriched) - - return enriched_pairs - - -def _filter_stale_vpc_pairs( - nd_v2, - fabric_name: str, - pairs: List[Dict[str, Any]], - module, -) -> List[Dict[str, Any]]: - """ - Remove stale pairs using overview membership checks. - - `/vpcPairs` can briefly lag after unpair operations. We perform a lightweight - best-effort membership check and drop entries that are explicitly reported as - not part of a vPC pair. - """ - if not pairs: - return [] - - pruned_pairs: List[Dict[str, Any]] = [] - for pair in pairs: - switch_id = pair.get(VpcFieldNames.SWITCH_ID) - if not switch_id: - pruned_pairs.append(pair) - continue - - membership = _is_switch_in_vpc_pair(nd_v2, fabric_name, switch_id, timeout=5) - if membership is False: - module.warn( - f"Excluding stale vPC pair entry for switch {switch_id} " - "because overview reports it is not in a vPC pair." - ) - continue - pruned_pairs.append(pair) - - return pruned_pairs - - -def _get_pairing_support_details( - nd_v2, - fabric_name: str, - switch_id: str, - component_type: str = ComponentTypeSupportEnum.CHECK_PAIRING.value, - timeout: Optional[int] = None, -) -> Optional[Dict[str, Any]]: - """ - Query /vpcPairSupport endpoint to validate pairing support. - """ - if not fabric_name or not isinstance(fabric_name, str): - raise ValueError(f"Invalid fabric_name: {fabric_name}") - if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: - raise ValueError(f"Invalid switch_id: {switch_id}") - - path = VpcPairEndpoints.switch_vpc_support( - fabric_name=fabric_name, - switch_id=switch_id, - component_type=component_type, - ) - - if timeout is None: - timeout = nd_v2.module.params.get("query_timeout", 10) - - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = timeout - try: - support_details = nd_v2.request(path, HttpVerbEnum.GET) - finally: - rest_send.restore_settings() - - if isinstance(support_details, dict): - return support_details - return None - - -def _validate_fabric_peering_support( - nrm, - nd_v2, - fabric_name: str, - switch_id: str, - peer_switch_id: str, - use_virtual_peer_link: bool, -) -> None: - """ - Validate fabric peering support when virtual peer link is requested. - - If API explicitly reports unsupported fabric peering, logs warning and - continues. If support API is unavailable, logs warning and continues. - """ - if not use_virtual_peer_link: - return - - switches_to_check = [switch_id, peer_switch_id] - for support_switch_id in switches_to_check: - if not support_switch_id: - continue - - try: - support_details = _get_pairing_support_details( - nd_v2, - fabric_name=fabric_name, - switch_id=support_switch_id, - component_type=ComponentTypeSupportEnum.CHECK_FABRIC_PEERING_SUPPORT.value, - ) - if not support_details: - continue - - is_supported = _get_api_field_value( - support_details, "isVpcFabricPeeringSupported", None - ) - if is_supported is False: - status = _get_api_field_value( - support_details, "status", "Fabric peering not supported" - ) - nrm.module.warn( - f"VPC fabric peering is not supported for switch {support_switch_id}: {status}. " - f"Continuing, but config save/deploy may report a platform limitation. " - f"Consider setting use_virtual_peer_link=false for this platform." - ) - except Exception as support_error: - nrm.module.warn( - f"Fabric peering support check failed for switch {support_switch_id}: " - f"{str(support_error).splitlines()[0]}. Continuing with create/update operation." - ) - - -def _get_consistency_details( - nd_v2, - fabric_name: str, - switch_id: str, - timeout: Optional[int] = None, -) -> Optional[Dict[str, Any]]: - """ - Query /vpcPairConsistency endpoint for consistency diagnostics. - """ - if not fabric_name or not isinstance(fabric_name, str): - raise ValueError(f"Invalid fabric_name: {fabric_name}") - if not switch_id or not isinstance(switch_id, str) or len(switch_id) < 3: - raise ValueError(f"Invalid switch_id: {switch_id}") - - path = VpcPairEndpoints.switch_vpc_consistency(fabric_name, switch_id) - - if timeout is None: - timeout = nd_v2.module.params.get("query_timeout", 10) - - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = timeout - try: - consistency_details = nd_v2.request(path, HttpVerbEnum.GET) - finally: - rest_send.restore_settings() - - if isinstance(consistency_details, dict): - return consistency_details - return None - - -def _is_switch_in_vpc_pair( - nd_v2, - fabric_name: str, - switch_id: str, - timeout: Optional[int] = None, -) -> Optional[bool]: - """ - Best-effort active-membership check via vPC overview endpoint. - - Returns: - - True: overview query succeeded (switch is part of a vPC pair) - - False: API explicitly reports switch is not in a vPC pair - - None: unknown/error (do not block caller logic) - """ - if not fabric_name or not switch_id: - return None - - path = VpcPairEndpoints.switch_vpc_overview( - fabric_name, switch_id, component_type="full" - ) - - if timeout is None: - timeout = nd_v2.module.params.get("query_timeout", 10) - - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = timeout - try: - nd_v2.request(path, HttpVerbEnum.GET) - return True - except NDModuleError as error: - error_msg = (error.msg or "").lower() - if error.status == 400 and "not a part of vpc pair" in error_msg: - return False - return None - except Exception: - return None - finally: - rest_send.restore_settings() - - -def _validate_fabric_switches(nd_v2, fabric_name: str) -> Dict[str, Dict]: - """ - Query and validate fabric switch inventory. - - Args: - nd_v2: NDModuleV2 instance for RestSend - fabric_name: Fabric name - - Returns: - Dict mapping switch serial number to switch info - - Raises: - ValueError: If inputs are invalid - NDModuleError: If fabric switch query fails - """ - # Input validation - if not fabric_name or not isinstance(fabric_name, str): - raise ValueError(f"Invalid fabric_name: {fabric_name}") - - # Use api_timeout from module params - timeout = nd_v2.module.params.get("api_timeout", 30) - - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = timeout - try: - switches_path = VpcPairEndpoints.fabric_switches(fabric_name) - switches_response = nd_v2.request(switches_path, HttpVerbEnum.GET) - finally: - rest_send.restore_settings() - - if not switches_response: - return {} - - # Validate response structure - if not isinstance(switches_response, dict): - nd_v2.module.warn( - f"Unexpected switches response format: expected dict, got {type(switches_response).__name__}" - ) - return {} - - switches = switches_response.get(VpcFieldNames.SWITCHES, []) - - # Validate switches is a list - if not isinstance(switches, list): - nd_v2.module.warn( - f"Unexpected switches format: expected list, got {type(switches).__name__}" - ) - return {} - - # Build validated switch dictionary - result = {} - for sw in switches: - if not isinstance(sw, dict): - nd_v2.module.warn(f"Skipping invalid switch entry: expected dict, got {type(sw).__name__}") - continue - - serial_number = sw.get(VpcFieldNames.SERIAL_NUMBER) - if not serial_number: - continue - - # Validate serial number format - if not isinstance(serial_number, str) or len(serial_number) < 3: - nd_v2.module.warn(f"Skipping switch with invalid serial number: {serial_number}") - continue - - result[serial_number] = sw - - return result - - -def _validate_switch_conflicts(want_configs: List[Dict], have_vpc_pairs: List[Dict], module) -> None: - """ - Validate that switches in want configs aren't already in different VPC pairs. - - Optimized implementation using index-based lookup for O(n) time complexity instead of O(n²). - - Args: - want_configs: List of desired VPC pair configs - have_vpc_pairs: List of existing VPC pairs - module: AnsibleModule instance for fail_json - - Raises: - AnsibleModule.fail_json: If switch conflicts detected - """ - conflicts = [] - - # Build index of existing VPC pairs by switch ID - O(m) where m = len(have_vpc_pairs) - # Maps switch_id -> list of VPC pairs containing that switch - switch_to_vpc_index = {} - for have in have_vpc_pairs: - have_switch_id = have.get(VpcFieldNames.SWITCH_ID) - have_peer_id = have.get(VpcFieldNames.PEER_SWITCH_ID) - - if have_switch_id: - if have_switch_id not in switch_to_vpc_index: - switch_to_vpc_index[have_switch_id] = [] - switch_to_vpc_index[have_switch_id].append(have) - - if have_peer_id: - if have_peer_id not in switch_to_vpc_index: - switch_to_vpc_index[have_peer_id] = [] - switch_to_vpc_index[have_peer_id].append(have) - - # Check each want config for conflicts - O(n) where n = len(want_configs) - for want in want_configs: - want_switches = {want.get(VpcFieldNames.SWITCH_ID), want.get(VpcFieldNames.PEER_SWITCH_ID)} - want_switches.discard(None) - - # Build set of all VPC pairs that contain any switch from want_switches - O(1) lookup per switch - # Use set to track VPC IDs we've already checked to avoid duplicate processing - conflicting_vpcs = {} # vpc_id -> vpc dict - for switch in want_switches: - if switch in switch_to_vpc_index: - for vpc in switch_to_vpc_index[switch]: - # Use tuple of sorted switch IDs as unique identifier - vpc_id = tuple(sorted([vpc.get(VpcFieldNames.SWITCH_ID), vpc.get(VpcFieldNames.PEER_SWITCH_ID)])) - # Only add if we haven't seen this VPC ID before (avoids duplicate processing) - if vpc_id not in conflicting_vpcs: - conflicting_vpcs[vpc_id] = vpc - - # Check each potentially conflicting VPC pair - for vpc_id, have in conflicting_vpcs.items(): - have_switches = {have.get(VpcFieldNames.SWITCH_ID), have.get(VpcFieldNames.PEER_SWITCH_ID)} - have_switches.discard(None) - - # Same VPC pair is OK - if want_switches == have_switches: - continue - - # Check for switch overlap with different pairs - switch_overlap = want_switches & have_switches - if switch_overlap: - # Filter out None values and ensure strings for joining - overlap_list = [str(s) for s in switch_overlap if s is not None] - want_key = f"{want.get(VpcFieldNames.SWITCH_ID)}-{want.get(VpcFieldNames.PEER_SWITCH_ID)}" - have_key = f"{have.get(VpcFieldNames.SWITCH_ID)}-{have.get(VpcFieldNames.PEER_SWITCH_ID)}" - conflicts.append( - f"Switch(es) {', '.join(overlap_list)} in wanted VPC pair {want_key} " - f"are already part of existing VPC pair {have_key}" - ) - - if conflicts: - _raise_vpc_error( - msg="Switch conflicts detected. A switch can only be part of one VPC pair at a time.", - conflicts=conflicts - ) - - -def _validate_switches_exist_in_fabric( - nrm, - fabric_name: str, - switch_id: str, - peer_switch_id: str, -) -> None: - """ - Validate both switches exist in discovered fabric inventory. - - This check is mandatory for create/update. Empty inventory is treated as - a validation error to avoid bypassing guardrails and failing later with a - less actionable API error. - """ - fabric_switches = nrm.module.params.get("_fabric_switches") - - if fabric_switches is None: - _raise_vpc_error( - msg=( - f"Switch validation failed for fabric '{fabric_name}': switch inventory " - "was not loaded from query_all. Unable to validate requested vPC pair." - ), - vpc_pair_key=nrm.current_identifier, - fabric=fabric_name, - ) - - valid_switches = sorted(list(fabric_switches)) - if not valid_switches: - _raise_vpc_error( - msg=( - f"Switch validation failed for fabric '{fabric_name}': no switches were " - "discovered in fabric inventory. Cannot create/update vPC pairs without " - "validated switch membership." - ), - vpc_pair_key=nrm.current_identifier, - fabric=fabric_name, - total_valid_switches=0, - ) - - missing_switches = [] - if switch_id not in fabric_switches: - missing_switches.append(switch_id) - if peer_switch_id not in fabric_switches: - missing_switches.append(peer_switch_id) - - if not missing_switches: - return - - max_switches_in_error = 10 - error_msg = ( - f"Switch validation failed: The following switch(es) do not exist in fabric '{fabric_name}':\n" - f" Missing switches: {', '.join(missing_switches)}\n" - f" Affected vPC pair: {nrm.current_identifier}\n\n" - "Please ensure:\n" - " 1. Switch serial numbers are correct (not IP addresses)\n" - " 2. Switches are discovered and present in the fabric\n" - " 3. You have the correct fabric name specified\n\n" - ) - - if len(valid_switches) <= max_switches_in_error: - error_msg += f"Valid switches in fabric: {', '.join(valid_switches)}" - else: - error_msg += ( - f"Valid switches in fabric (first {max_switches_in_error}): " - f"{', '.join(valid_switches[:max_switches_in_error])} ... and " - f"{len(valid_switches) - max_switches_in_error} more" - ) - - _raise_vpc_error( - msg=error_msg, - missing_switches=missing_switches, - vpc_pair_key=nrm.current_identifier, - total_valid_switches=len(valid_switches), - ) - - -def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pair_key: str, module) -> None: - """ - Validate VPC pair can be safely deleted by checking for dependencies. - - This function prevents data loss by ensuring the VPC pair has no active: - 1. Networks (networkCount must be 0 for all statuses) - 2. VRFs (vrfCount must be 0 for all statuses) - 3. Warns if vPC interfaces exist (vpcInterfaceCount > 0) - - Args: - nd_v2: NDModuleV2 instance for RestSend - fabric_name: Fabric name - switch_id: Switch serial number - vpc_pair_key: VPC pair identifier (e.g., "FDO123-FDO456") for error messages - module: AnsibleModule instance for fail_json/warn - - Raises: - AnsibleModule.fail_json: If VPC pair has active networks or VRFs - - Example: - _validate_vpc_pair_deletion(nd_v2, "myFabric", "FDO123", "FDO123-FDO456", module) - """ - try: - # Query overview endpoint with full component data - overview_path = VpcPairEndpoints.switch_vpc_overview(fabric_name, switch_id, component_type="full") - - # Bound overview validation call by query_timeout for deterministic behavior. - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = nd_v2.module.params.get("query_timeout", 10) - try: - response = nd_v2.request(overview_path, HttpVerbEnum.GET) - finally: - rest_send.restore_settings() - - # If no response, VPC pair doesn't exist - deletion not needed - if not response: - module.warn( - f"VPC pair {vpc_pair_key} not found in overview query. " - f"It may not exist or may have already been deleted." - ) - return - - # Query consistency endpoint for additional diagnostics before deletion. - # This is best effort and should not block deletion workflows. - try: - consistency = _get_consistency_details(nd_v2, fabric_name, switch_id) - if consistency: - type2_consistency = _get_api_field_value(consistency, "type2Consistency", None) - if type2_consistency is False: - reason = _get_api_field_value( - consistency, "type2ConsistencyReason", "unknown reason" - ) - module.warn( - f"VPC pair {vpc_pair_key} reports type2 consistency issue: {reason}" - ) - except Exception as consistency_error: - module.warn( - f"Failed to query consistency details for VPC pair {vpc_pair_key}: " - f"{str(consistency_error).splitlines()[0]}" - ) - - # Validate response structure - if not isinstance(response, dict): - _raise_vpc_error( - msg=f"Expected dict response from vPC pair overview for {vpc_pair_key}, got {type(response).__name__}", - response=response - ) - - # Validate overlay data exists - overlay = response.get(VpcFieldNames.OVERLAY) - if not overlay: - _raise_vpc_error( - msg=( - f"vPC pair {vpc_pair_key} might not exist or overlay data unavailable. " - f"Cannot safely validate deletion." - ), - vpc_pair_key=vpc_pair_key, - response=response - ) - - # Check 1: Validate no networks are attached - network_count = overlay.get(VpcFieldNames.NETWORK_COUNT, {}) - if isinstance(network_count, dict): - for status, count in network_count.items(): - try: - count_int = int(count) - if count_int != 0: - _raise_vpc_error( - msg=( - f"Cannot delete vPC pair {vpc_pair_key}. " - f"{count_int} network(s) with status '{status}' still exist. " - f"Remove all networks from this vPC pair before deleting it." - ), - vpc_pair_key=vpc_pair_key, - network_count=network_count, - blocking_status=status, - blocking_count=count_int - ) - except (ValueError, TypeError) as e: - # Best effort - log warning and continue - module.warn(f"Error parsing network count for status '{status}': {e}") - elif network_count: - # Non-dict format - log warning - module.warn( - f"networkCount is not a dict for {vpc_pair_key}: {type(network_count).__name__}. " - f"Skipping network validation." - ) - - # Check 2: Validate no VRFs are attached - vrf_count = overlay.get(VpcFieldNames.VRF_COUNT, {}) - if isinstance(vrf_count, dict): - for status, count in vrf_count.items(): - try: - count_int = int(count) - if count_int != 0: - _raise_vpc_error( - msg=( - f"Cannot delete vPC pair {vpc_pair_key}. " - f"{count_int} VRF(s) with status '{status}' still exist. " - f"Remove all VRFs from this vPC pair before deleting it." - ), - vpc_pair_key=vpc_pair_key, - vrf_count=vrf_count, - blocking_status=status, - blocking_count=count_int - ) - except (ValueError, TypeError) as e: - # Best effort - log warning and continue - module.warn(f"Error parsing VRF count for status '{status}': {e}") - elif vrf_count: - # Non-dict format - log warning - module.warn( - f"vrfCount is not a dict for {vpc_pair_key}: {type(vrf_count).__name__}. " - f"Skipping VRF validation." - ) - - # Check 3: Warn if vPC interfaces exist (non-blocking) - inventory = response.get(VpcFieldNames.INVENTORY, {}) - if inventory and isinstance(inventory, dict): - vpc_interface_count = inventory.get(VpcFieldNames.VPC_INTERFACE_COUNT) - if vpc_interface_count: - try: - count_int = int(vpc_interface_count) - if count_int > 0: - module.warn( - f"vPC pair {vpc_pair_key} has {count_int} vPC interface(s). " - f"Deletion may fail or require manual cleanup of interfaces. " - f"Consider removing vPC interfaces before deleting the vPC pair." - ) - except (ValueError, TypeError) as e: - # Best effort - just log debug message - pass - elif not inventory: - # No inventory data - warn user - module.warn( - f"Inventory data not available in overview response for {vpc_pair_key}. " - f"Proceeding with deletion, but it may fail if vPC interfaces exist." - ) - - except VpcPairResourceError: - raise - except NDModuleError as error: - error_msg = str(error.msg).lower() if error.msg else "" - status_code = error.status or 0 - - # If the overview query returns 400 with "not a part of" it means - # the pair no longer exists on the controller. Signal the caller - # by raising a ValueError with a sentinel message so that the - # delete function can treat this as an idempotent no-op. - if status_code == 400 and "not a part of" in error_msg: - raise ValueError( - f"VPC pair {vpc_pair_key} is already unpaired on the controller. " - f"No deletion required." - ) - - # Best effort validation - if overview query fails, log warning and proceed - # The API will still reject deletion if dependencies exist - module.warn( - f"Could not validate vPC pair {vpc_pair_key} for deletion: {error.msg}. " - f"Proceeding with deletion attempt. API will reject if dependencies exist." - ) - - except Exception as e: - # Best effort validation - log warning and continue - module.warn( - f"Unexpected error validating VPC pair {vpc_pair_key} for deletion: {str(e)}. " - f"Proceeding with deletion attempt." - ) - - -# ===== Custom Action Functions (used by VpcPairResourceService via orchestrator) ===== - - -def _filter_vpc_pairs_by_requested_config( - pairs: List[Dict[str, Any]], - config: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: - """ - Filter queried VPC pairs by explicit pair keys provided in gathered config. - - If gathered config is empty or does not contain complete switch pairs, return - the unfiltered pair list. - """ - if not pairs or not config: - return list(pairs or []) - - requested_pair_keys = set() - for item in config: - switch_id = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) - peer_switch_id = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) - if switch_id and peer_switch_id: - requested_pair_keys.add(tuple(sorted([switch_id, peer_switch_id]))) - - if not requested_pair_keys: - return list(pairs) - - filtered_pairs = [] - for item in pairs: - switch_id = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) - peer_switch_id = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) - if switch_id and peer_switch_id: - pair_key = tuple(sorted([switch_id, peer_switch_id])) - if pair_key in requested_pair_keys: - filtered_pairs.append(item) - - return filtered_pairs - - -def custom_vpc_query_all(nrm) -> List[Dict]: - """ - Query existing VPC pairs with state-aware enrichment. - - Flow: - - Base query from /vpcPairs list (always attempted first) - - gathered/deleted: use lightweight list-only data when available - - merged/replaced/overridden: enrich with switch inventory and recommendation - APIs to build have/pending_create/pending_delete sets - """ - fabric_name = nrm.module.params.get("fabric_name") - - if not fabric_name or not isinstance(fabric_name, str) or not fabric_name.strip(): - raise ValueError(f"fabric_name must be a non-empty string. Got: {fabric_name!r}") - - state = nrm.module.params.get("state", "merged") - if state == "gathered": - config = nrm.module.params.get("_gather_filter_config") or [] - else: - config = nrm.module.params.get("config") or [] - - # Initialize RestSend via NDModuleV2 - nd_v2 = NDModuleV2(nrm.module) - - def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - nrm.module.params["_fabric_switches"] = [] - nrm.module.params["_fabric_switches_count"] = 0 - nrm.module.params["_ip_to_sn_mapping"] = {} - nrm.module.params["_have"] = lightweight_have - nrm.module.params["_pending_create"] = [] - nrm.module.params["_pending_delete"] = [] - return lightweight_have - - try: - # Step 1: Base query from list endpoint (/vpcPairs) - have = [] - list_query_succeeded = False - try: - list_path = VpcPairEndpoints.vpc_pairs_list(fabric_name) - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = nrm.module.params.get("query_timeout", 10) - try: - vpc_pairs_response = nd_v2.request(list_path, HttpVerbEnum.GET) - finally: - rest_send.restore_settings() - have.extend(_extract_vpc_pairs_from_list_response(vpc_pairs_response)) - list_query_succeeded = True - except Exception as list_error: - nrm.module.warn( - f"VPC pairs list query failed for fabric {fabric_name}: " - f"{str(list_error).splitlines()[0]}." - ) - - # Lightweight path for read-only and delete workflows. - # Keep heavy discovery/enrichment only for write states. - if state in ("deleted", "gathered"): - if list_query_succeeded: - if state == "gathered": - have = _filter_vpc_pairs_by_requested_config(have, config) - have = _enrich_pairs_from_direct_vpc( - nd_v2=nd_v2, - fabric_name=fabric_name, - pairs=have, - timeout=5, - ) - have = _filter_stale_vpc_pairs( - nd_v2=nd_v2, - fabric_name=fabric_name, - pairs=have, - module=nrm.module, - ) - return _set_lightweight_context(have) - - nrm.module.warn( - "Skipping switch-level discovery for read-only/delete workflow because " - "the vPC list endpoint is unavailable." - ) - - if state == "gathered": - return _set_lightweight_context([]) - - # Preserve explicit delete intent without full-fabric discovery. - # This keeps delete deterministic and avoids expensive inventory calls. - fallback_have = [] - for item in config: - switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) - peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) - if not switch_id_val or not peer_switch_id_val: - continue - - use_vpl_val = item.get("use_virtual_peer_link") - if use_vpl_val is None: - use_vpl_val = item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) - - fallback_have.append( - { - VpcFieldNames.SWITCH_ID: switch_id_val, - VpcFieldNames.PEER_SWITCH_ID: peer_switch_id_val, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl_val, - } - ) - - if fallback_have: - nrm.module.warn( - "Using requested delete config as fallback existing set because " - "vPC list query failed." - ) - return _set_lightweight_context(fallback_have) - - if config: - nrm.module.warn( - "Delete config did not contain complete vPC pairs. " - "No delete intents can be built from list-query fallback." - ) - return _set_lightweight_context([]) - - nrm.module.warn( - "Delete-all requested with no explicit pairs and unavailable list endpoint. " - "Falling back to switch-level discovery." - ) - - # Step 2 (write-state enrichment): Query and validate fabric switches. - fabric_switches = _validate_fabric_switches(nd_v2, fabric_name) - - if not fabric_switches: - nrm.module.warn(f"No switches found in fabric {fabric_name}") - nrm.module.params["_fabric_switches"] = [] - nrm.module.params["_fabric_switches_count"] = 0 - nrm.module.params["_have"] = [] - nrm.module.params["_pending_create"] = [] - nrm.module.params["_pending_delete"] = [] - return [] - - # Keep only switch IDs for validation and serialize safely in module params. - fabric_switches_list = list(fabric_switches.keys()) - nrm.module.params["_fabric_switches"] = fabric_switches_list - nrm.module.params["_fabric_switches_count"] = len(fabric_switches) - - # Build IP-to-SN mapping (extract before dict is discarded). - ip_to_sn = { - sw.get(VpcFieldNames.FABRIC_MGMT_IP): sw.get(VpcFieldNames.SERIAL_NUMBER) - for sw in fabric_switches.values() - if VpcFieldNames.FABRIC_MGMT_IP in sw - } - nrm.module.params["_ip_to_sn_mapping"] = ip_to_sn - - # Step 3: Track 3-state VPC pairs (have/pending_create/pending_delete). - pending_create = [] - pending_delete = [] - processed_switches = set() - - desired_pairs = {} - config_switch_ids = set() - for item in config: - # Config items are normalized to snake_case in main(). - switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) - peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) - - if switch_id_val: - config_switch_ids.add(switch_id_val) - if peer_switch_id_val: - config_switch_ids.add(peer_switch_id_val) - - if switch_id_val and peer_switch_id_val: - desired_pairs[tuple(sorted([switch_id_val, peer_switch_id_val]))] = item - - for switch_id, switch in fabric_switches.items(): - if switch_id in processed_switches: - continue - - vpc_configured = switch.get(VpcFieldNames.VPC_CONFIGURED, False) - vpc_data = switch.get("vpcData", {}) - - if vpc_configured and vpc_data: - peer_switch_id = vpc_data.get("peerSwitchId") - processed_switches.add(switch_id) - processed_switches.add(peer_switch_id) - - # For configured pairs, prefer direct vPC query as source of truth. - try: - vpc_pair_path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = 5 - try: - direct_vpc = nd_v2.request(vpc_pair_path, HttpVerbEnum.GET) - finally: - rest_send.restore_settings() - except (NDModuleError, Exception): - direct_vpc = None - - if direct_vpc: - resolved_peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) or peer_switch_id - if resolved_peer_switch_id: - processed_switches.add(resolved_peer_switch_id) - use_vpl = _get_api_field_value(direct_vpc, "useVirtualPeerLink", False) - - # Direct /vpcPair can be stale for a short period after delete. - # Cross-check overview to avoid reporting stale active pairs. - membership = _is_switch_in_vpc_pair( - nd_v2, fabric_name, switch_id, timeout=5 - ) - if membership is False: - pair_key = None - if resolved_peer_switch_id: - pair_key = tuple(sorted([switch_id, resolved_peer_switch_id])) - desired_item = desired_pairs.get(pair_key) if pair_key else None - desired_use_vpl = None - if desired_item: - desired_use_vpl = desired_item.get("use_virtual_peer_link") - if desired_use_vpl is None: - desired_use_vpl = desired_item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) - - # Narrow override: trust direct payload only for write states - # when it matches desired pair intent. - if state in ("merged", "replaced", "overridden") and desired_item is not None: - if desired_use_vpl is None or bool(desired_use_vpl) == bool(use_vpl): - nrm.module.warn( - f"Overview membership check returned 'not paired' for switch {switch_id}, " - "but direct /vpcPair matched requested config. Treating pair as active." - ) - membership = True - if membership is False: - pending_delete.append({ - VpcFieldNames.SWITCH_ID: switch_id, - VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, - }) - else: - have.append({ - VpcFieldNames.SWITCH_ID: switch_id, - VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, - }) - else: - # Direct query failed - fall back to recommendation. - try: - recommendation = _get_recommendation_details(nd_v2, fabric_name, switch_id) - except Exception as rec_error: - error_msg = str(rec_error).splitlines()[0] - nrm.module.warn( - f"Recommendation query failed for switch {switch_id}: {error_msg}. " - f"Unable to read configured vPC pair details." - ) - recommendation = None - - if recommendation: - resolved_peer_switch_id = _get_api_field_value(recommendation, "serialNumber") or peer_switch_id - if resolved_peer_switch_id: - processed_switches.add(resolved_peer_switch_id) - use_vpl = _get_api_field_value(recommendation, "useVirtualPeerLink", False) - have.append({ - VpcFieldNames.SWITCH_ID: switch_id, - VpcFieldNames.PEER_SWITCH_ID: resolved_peer_switch_id, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, - }) - else: - # VPC configured but query failed - mark as pending delete. - pending_delete.append({ - VpcFieldNames.SWITCH_ID: switch_id, - VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: False, - }) - elif not config_switch_ids or switch_id in config_switch_ids: - # For unconfigured switches, prefer direct vPC pair query first. - try: - vpc_pair_path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = 5 - try: - direct_vpc = nd_v2.request(vpc_pair_path, HttpVerbEnum.GET) - finally: - rest_send.restore_settings() - except (NDModuleError, Exception): - direct_vpc = None - - if direct_vpc: - peer_switch_id = direct_vpc.get(VpcFieldNames.PEER_SWITCH_ID) - if peer_switch_id: - processed_switches.add(switch_id) - processed_switches.add(peer_switch_id) - - use_vpl = _get_api_field_value(direct_vpc, "useVirtualPeerLink", False) - membership = _is_switch_in_vpc_pair( - nd_v2, fabric_name, switch_id, timeout=5 - ) - if membership is False: - pair_key = tuple(sorted([switch_id, peer_switch_id])) - desired_item = desired_pairs.get(pair_key) - desired_use_vpl = None - if desired_item: - desired_use_vpl = desired_item.get("use_virtual_peer_link") - if desired_use_vpl is None: - desired_use_vpl = desired_item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) - - if state in ("merged", "replaced", "overridden") and desired_item is not None: - if desired_use_vpl is None or bool(desired_use_vpl) == bool(use_vpl): - nrm.module.warn( - f"Overview membership check returned 'not paired' for switch {switch_id}, " - "but direct /vpcPair matched requested config. Treating pair as active." - ) - membership = True - if membership is False: - pending_delete.append({ - VpcFieldNames.SWITCH_ID: switch_id, - VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, - }) - else: - have.append({ - VpcFieldNames.SWITCH_ID: switch_id, - VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, - }) - else: - # No direct pair; check recommendation for pending create candidates. - try: - recommendation = _get_recommendation_details(nd_v2, fabric_name, switch_id) - except Exception as rec_error: - error_msg = str(rec_error).splitlines()[0] - nrm.module.warn( - f"Recommendation query failed for switch {switch_id}: {error_msg}. " - f"No recommendation details available." - ) - recommendation = None - - if recommendation: - peer_switch_id = _get_api_field_value(recommendation, "serialNumber") - if peer_switch_id: - processed_switches.add(switch_id) - processed_switches.add(peer_switch_id) - - use_vpl = _get_api_field_value(recommendation, "useVirtualPeerLink", False) - pending_create.append({ - VpcFieldNames.SWITCH_ID: switch_id, - VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl, - }) - - # Step 4: Store all states for use in create/update/delete. - nrm.module.params["_have"] = have - nrm.module.params["_pending_create"] = pending_create - nrm.module.params["_pending_delete"] = pending_delete - - # Build effective existing set for state reconciliation: - # - Include active pairs (have) and pending-create pairs. - # - Exclude pending-delete pairs from active set to avoid stale - # idempotence false-negatives right after unpair operations. - pair_by_key = {} - for pair in pending_create + have: - switch_id = pair.get(VpcFieldNames.SWITCH_ID) - peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) - if not switch_id or not peer_switch_id: - continue - key = tuple(sorted([switch_id, peer_switch_id])) - pair_by_key[key] = pair - - for pair in pending_delete: - switch_id = pair.get(VpcFieldNames.SWITCH_ID) - peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) - if not switch_id or not peer_switch_id: - continue - key = tuple(sorted([switch_id, peer_switch_id])) - pair_by_key.pop(key, None) - - existing_pairs = list(pair_by_key.values()) - return existing_pairs - - except NDModuleError as error: - error_dict = error.to_dict() - if "msg" in error_dict: - error_dict["api_error_msg"] = error_dict.pop("msg") - _raise_vpc_error( - msg=f"Failed to query VPC pairs: {error.msg}", - fabric=fabric_name, - **error_dict - ) - except VpcPairResourceError: - raise - except Exception as e: - _raise_vpc_error( - msg=f"Failed to query VPC pairs: {str(e)}", - fabric=fabric_name, - exception_type=type(e).__name__ - ) - - -def custom_vpc_create(nrm) -> Optional[Dict[str, Any]]: - """ - Custom create function for VPC pairs using RestSend with PUT + discriminator. - - Validates switches exist in fabric (Common.validate_switches_exist) - - Checks for switch conflicts (Common.validate_no_switch_conflicts) - - Uses PUT instead of POST (non-RESTful API) - - Adds vpcAction: "pair" discriminator - - Proper error handling with NDModuleError - - Results aggregation - - Args: - nrm: NDStateMachine instance - - Returns: - API response dictionary or None - - Raises: - ValueError: If fabric_name or switch_id is not provided - AnsibleModule.fail_json: If validation fails - """ - if nrm.module.check_mode: - return nrm.proposed_config - - fabric_name = nrm.module.params.get("fabric_name") - switch_id = nrm.proposed_config.get(VpcFieldNames.SWITCH_ID) - peer_switch_id = nrm.proposed_config.get(VpcFieldNames.PEER_SWITCH_ID) - - # Path validation - if not fabric_name: - raise ValueError("fabric_name is required but was not provided") - if not switch_id: - raise ValueError("switch_id is required but was not provided") - if not peer_switch_id: - raise ValueError("peer_switch_id is required but was not provided") - - # Validation Step 1: both switches must exist in discovered fabric inventory. - _validate_switches_exist_in_fabric( - nrm=nrm, - fabric_name=fabric_name, - switch_id=switch_id, - peer_switch_id=peer_switch_id, - ) - - # Validation Step 2: Check for switch conflicts (from Common.validate_no_switch_conflicts) - have_vpc_pairs = nrm.module.params.get("_have", []) - if have_vpc_pairs: - _validate_switch_conflicts([nrm.proposed_config], have_vpc_pairs, nrm.module) - - # Validation Step 3: Check if create is actually needed (idempotence check) - if nrm.existing_config: - want_dict = nrm.proposed_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.proposed_config, 'model_dump') else nrm.proposed_config - have_dict = nrm.existing_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.existing_config, 'model_dump') else nrm.existing_config - - if not _is_update_needed(want_dict, have_dict): - # Already exists in desired state - return existing config without changes - nrm.module.warn( - f"VPC pair {nrm.current_identifier} already exists in desired state - skipping create" - ) - return nrm.existing_config - - # Initialize RestSend via NDModuleV2 - nd_v2 = NDModuleV2(nrm.module) - use_virtual_peer_link = nrm.proposed_config.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) - - # Validate pairing support using dedicated endpoint. - # Only fail when API explicitly states pairing is not allowed. - try: - support_details = _get_pairing_support_details( - nd_v2, - fabric_name=fabric_name, - switch_id=switch_id, - component_type=ComponentTypeSupportEnum.CHECK_PAIRING.value, - ) - if support_details: - is_pairing_allowed = _get_api_field_value( - support_details, "isPairingAllowed", None - ) - if is_pairing_allowed is False: - reason = _get_api_field_value( - support_details, "reason", "pairing blocked by support checks" - ) - _raise_vpc_error( - msg=f"VPC pairing is not allowed for switch {switch_id}: {reason}", - fabric=fabric_name, - switch_id=switch_id, - peer_switch_id=peer_switch_id, - support_details=support_details, - ) - except VpcPairResourceError: - raise - except Exception as support_error: - nrm.module.warn( - f"Pairing support check failed for switch {switch_id}: " - f"{str(support_error).splitlines()[0]}. Continuing with create operation." - ) - - # Validate fabric peering support if virtual peer link is requested. - _validate_fabric_peering_support( - nrm=nrm, - nd_v2=nd_v2, - fabric_name=fabric_name, - switch_id=switch_id, - peer_switch_id=peer_switch_id, - use_virtual_peer_link=use_virtual_peer_link, - ) - - # Build path with switch ID using Manage API (not NDFC API) - # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available - # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead - path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) - - # Build payload with discriminator using helper (supports vpc_pair_details) - payload = _build_vpc_pair_payload(nrm.proposed_config) - - # Log the operation - nrm.format_log( - identifier=nrm.current_identifier, - status="created", - after_data=payload, - sent_payload_data=payload - ) - - try: - # Use PUT (not POST!) for create via RestSend - response = nd_v2.request(path, HttpVerbEnum.PUT, payload) - return response - - except NDModuleError as error: - error_dict = error.to_dict() - # Preserve original API error message with different key to avoid conflict - if 'msg' in error_dict: - error_dict['api_error_msg'] = error_dict.pop('msg') - _raise_vpc_error( - msg=f"Failed to create VPC pair {nrm.current_identifier}: {error.msg}", - fabric=fabric_name, - switch_id=switch_id, - peer_switch_id=peer_switch_id, - path=path, - **error_dict - ) - except VpcPairResourceError: - raise - except Exception as e: - _raise_vpc_error( - msg=f"Failed to create VPC pair {nrm.current_identifier}: {str(e)}", - fabric=fabric_name, - switch_id=switch_id, - peer_switch_id=peer_switch_id, - path=path, - exception_type=type(e).__name__ - ) - - -def custom_vpc_update(nrm) -> Optional[Dict[str, Any]]: - """ - Custom update function for VPC pairs using RestSend. - - - Uses PUT with discriminator (same as create) - - Validates switches exist in fabric - - Checks for switch conflicts - - Uses DeepDiff to detect if update is actually needed - - Proper error handling - - Args: - nrm: NDStateMachine instance - - Returns: - API response dictionary or None - - Raises: - ValueError: If fabric_name or switch_id is not provided - """ - if nrm.module.check_mode: - return nrm.proposed_config - - fabric_name = nrm.module.params.get("fabric_name") - switch_id = nrm.proposed_config.get(VpcFieldNames.SWITCH_ID) - peer_switch_id = nrm.proposed_config.get(VpcFieldNames.PEER_SWITCH_ID) - - # Path validation - if not fabric_name: - raise ValueError("fabric_name is required but was not provided") - if not switch_id: - raise ValueError("switch_id is required but was not provided") - if not peer_switch_id: - raise ValueError("peer_switch_id is required but was not provided") - - # Validation Step 1: both switches must exist in discovered fabric inventory. - _validate_switches_exist_in_fabric( - nrm=nrm, - fabric_name=fabric_name, - switch_id=switch_id, - peer_switch_id=peer_switch_id, - ) - - # Validation Step 2: Check for switch conflicts (from Common.validate_no_switch_conflicts) - have_vpc_pairs = nrm.module.params.get("_have", []) - if have_vpc_pairs: - # Filter out the current VPC pair being updated - other_vpc_pairs = [ - vpc for vpc in have_vpc_pairs - if vpc.get(VpcFieldNames.SWITCH_ID) != switch_id - ] - if other_vpc_pairs: - _validate_switch_conflicts([nrm.proposed_config], other_vpc_pairs, nrm.module) - - # Validation Step 3: Check if update is actually needed using DeepDiff - if nrm.existing_config: - want_dict = nrm.proposed_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.proposed_config, 'model_dump') else nrm.proposed_config - have_dict = nrm.existing_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.existing_config, 'model_dump') else nrm.existing_config - - if not _is_update_needed(want_dict, have_dict): - # No changes needed - return existing config - nrm.module.warn( - f"VPC pair {nrm.current_identifier} is already in desired state - skipping update" - ) - return nrm.existing_config - - # Initialize RestSend via NDModuleV2 - nd_v2 = NDModuleV2(nrm.module) - use_virtual_peer_link = nrm.proposed_config.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) - - # Validate fabric peering support if virtual peer link is requested. - _validate_fabric_peering_support( - nrm=nrm, - nd_v2=nd_v2, - fabric_name=fabric_name, - switch_id=switch_id, - peer_switch_id=peer_switch_id, - use_virtual_peer_link=use_virtual_peer_link, - ) - - # Build path with switch ID using Manage API (not NDFC API) - # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available - # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead - path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) - - # Build payload with discriminator using helper (supports vpc_pair_details) - payload = _build_vpc_pair_payload(nrm.proposed_config) - - # Log the operation - nrm.format_log( - identifier=nrm.current_identifier, - status="updated", - after_data=payload, - sent_payload_data=payload - ) - - try: - # Use PUT for update via RestSend - response = nd_v2.request(path, HttpVerbEnum.PUT, payload) - return response - - except NDModuleError as error: - error_dict = error.to_dict() - # Preserve original API error message with different key to avoid conflict - if 'msg' in error_dict: - error_dict['api_error_msg'] = error_dict.pop('msg') - _raise_vpc_error( - msg=f"Failed to update VPC pair {nrm.current_identifier}: {error.msg}", - fabric=fabric_name, - switch_id=switch_id, - path=path, - **error_dict - ) - except VpcPairResourceError: - raise - except Exception as e: - _raise_vpc_error( - msg=f"Failed to update VPC pair {nrm.current_identifier}: {str(e)}", - fabric=fabric_name, - switch_id=switch_id, - path=path, - exception_type=type(e).__name__ - ) - - -def custom_vpc_delete(nrm) -> None: - """ - Custom delete function for VPC pairs using RestSend with PUT + discriminator. - - - Pre-deletion validation (network/VRF/interface checks) - - Uses PUT instead of DELETE (non-RESTful API) - - Adds vpcAction: "unpair" discriminator - - Proper error handling with NDModuleError - - Args: - nrm: NDStateMachine instance - - Raises: - ValueError: If fabric_name or switch_id is not provided - AnsibleModule.fail_json: If validation fails (networks/VRFs attached) - """ - if nrm.module.check_mode: - return - - fabric_name = nrm.module.params.get("fabric_name") - switch_id = nrm.existing_config.get(VpcFieldNames.SWITCH_ID) - peer_switch_id = nrm.existing_config.get(VpcFieldNames.PEER_SWITCH_ID) - - # Path validation - if not fabric_name: - raise ValueError("fabric_name is required but was not provided") - if not switch_id: - raise ValueError("switch_id is required but was not provided") - - # Initialize RestSend via NDModuleV2 - nd_v2 = NDModuleV2(nrm.module) - - # CRITICAL: Pre-deletion validation to prevent data loss - # Checks for active networks, VRFs, and warns about vPC interfaces - vpc_pair_key = f"{switch_id}-{peer_switch_id}" if peer_switch_id else switch_id - - # Track whether force parameter was actually needed - force_delete = nrm.module.params.get("force", False) - validation_succeeded = False - - # Perform validation with timeout protection - try: - _validate_vpc_pair_deletion(nd_v2, fabric_name, switch_id, vpc_pair_key, nrm.module) - validation_succeeded = True - - # If force was enabled but validation succeeded, inform user it wasn't needed - if force_delete: - nrm.module.warn( - f"Force deletion was enabled for {vpc_pair_key}, but pre-deletion validation succeeded. " - f"The 'force: true' parameter was not necessary in this case. " - f"Consider removing 'force: true' to benefit from safety checks in future runs." - ) - - except ValueError as already_unpaired: - # Sentinel from _validate_vpc_pair_deletion: pair no longer exists. - # Treat as idempotent success — nothing to delete. - nrm.module.warn(str(already_unpaired)) - return - - except (NDModuleError, Exception) as validation_error: - # Validation failed - check if force deletion is enabled - if not force_delete: - _raise_vpc_error( - msg=( - f"Pre-deletion validation failed for VPC pair {vpc_pair_key}. " - f"Error: {str(validation_error)}. " - f"If you're certain the VPC pair can be safely deleted, use 'force: true' parameter. " - f"WARNING: Force deletion bypasses safety checks and may cause data loss." - ), - vpc_pair_key=vpc_pair_key, - validation_error=str(validation_error), - force_available=True - ) - else: - # Force enabled and validation failed - this is when force was actually needed - nrm.module.warn( - f"Force deletion enabled for {vpc_pair_key} - bypassing pre-deletion validation. " - f"Validation error was: {str(validation_error)}. " - f"WARNING: Proceeding without safety checks - ensure no data loss will occur." - ) - - # Build path with switch ID using Manage API (not NDFC API) - # The NDFC API (/appcenter/cisco/ndfc/api/v1/lan-fabric/rest/vpcpair) may not be available - # Use Manage API (/api/v1/manage/fabrics/.../vpcPair) instead - path = VpcPairEndpoints.switch_vpc_pair(fabric_name, switch_id) - - # Build minimal payload with discriminator for delete - payload = { - VpcFieldNames.VPC_ACTION: VpcActionEnum.UNPAIR.value, # ← Discriminator for DELETE - VpcFieldNames.SWITCH_ID: nrm.existing_config.get(VpcFieldNames.SWITCH_ID), - VpcFieldNames.PEER_SWITCH_ID: nrm.existing_config.get(VpcFieldNames.PEER_SWITCH_ID) - } - - # Log the operation - nrm.format_log( - identifier=nrm.current_identifier, - status="deleted", - sent_payload_data=payload - ) - - try: - # Use PUT (not DELETE!) for unpair via RestSend - rest_send = nd_v2._get_rest_send() - rest_send.save_settings() - rest_send.timeout = nrm.module.params.get("api_timeout", 30) - try: - nd_v2.request(path, HttpVerbEnum.PUT, payload) - finally: - rest_send.restore_settings() - - except NDModuleError as error: - error_msg = str(error.msg).lower() if error.msg else "" - status_code = error.status or 0 - - # Idempotent handling: if the API says the switch is not part of any - # vPC pair, the pair is already gone — treat as a successful no-op. - if status_code == 400 and "not a part of" in error_msg: - nrm.module.warn( - f"VPC pair {nrm.current_identifier} is already unpaired on the controller. " - f"Treating as idempotent success. API response: {error.msg}" - ) - return - - error_dict = error.to_dict() - # Preserve original API error message with different key to avoid conflict - if 'msg' in error_dict: - error_dict['api_error_msg'] = error_dict.pop('msg') - _raise_vpc_error( - msg=f"Failed to delete VPC pair {nrm.current_identifier}: {error.msg}", - fabric=fabric_name, - switch_id=switch_id, - path=path, - **error_dict - ) - except VpcPairResourceError: - raise - except Exception as e: - _raise_vpc_error( - msg=f"Failed to delete VPC pair {nrm.current_identifier}: {str(e)}", - fabric=fabric_name, - switch_id=switch_id, - path=path, - exception_type=type(e).__name__ - ) - - -def _needs_deployment(result: Dict, nrm) -> bool: - """ - Determine if deployment is needed based on changes and pending operations. - - Deployment is needed if any of: - 1. There are items in the diff (configuration changes) - 2. There are pending create VPC pairs - 3. There are pending delete VPC pairs - - Args: - result: Module result dictionary with diff info - nrm: NDStateMachine instance - - Returns: - True if deployment is needed, False otherwise - """ - # Check if there are any changes in the result - has_changes = result.get("changed", False) - - # Check diff - framework stores before/after - before = result.get("before", []) - after = result.get("after", []) - has_diff_changes = before != after - - # Check pending operations - pending_create = nrm.module.params.get("_pending_create", []) - pending_delete = nrm.module.params.get("_pending_delete", []) - has_pending = bool(pending_create or pending_delete) - - needs_deploy = has_changes or has_diff_changes or has_pending - - return needs_deploy - - -def _is_non_fatal_config_save_error(error: NDModuleError) -> bool: - """ - Return True only for known non-fatal configSave platform limitations. - """ - if not isinstance(error, NDModuleError): - return False - - # Keep this allowlist tight to avoid masking real config-save failures. - if error.status != 500: - return False - - message = (error.msg or "").lower() - non_fatal_signatures = ( - "vpc fabric peering is not supported", - "vpcsanitycheck", - "unexpected error generating vpc configuration", - ) - return any(signature in message for signature in non_fatal_signatures) - - -def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: - """ - Custom deploy function for fabric configuration changes using RestSend. - - - Smart deployment decision (Common.needs_deployment) - - Step 1: Save fabric configuration - - Step 2: Deploy fabric with forceShowRun=true - - Proper error handling with NDModuleError - - Results aggregation - - Only deploys if there are actual changes or pending operations - - Args: - nrm: NDStateMachine instance - fabric_name: Fabric name to deploy - result: Module result dictionary to check for changes - - Returns: - Deployment result dictionary - - Raises: - NDModuleError: If deployment fails - """ - # Smart deployment decision (from Common.needs_deployment) - if not _needs_deployment(result, nrm): - return { - "msg": "No configuration changes or pending operations detected, skipping deployment", - "fabric": fabric_name, - "deployment_needed": False, - "changed": False - } - - if nrm.module.check_mode: - # Dry run deployment info (similar to show_dry_run_deployment_info) - before = result.get("before", []) - after = result.get("after", []) - pending_create = nrm.module.params.get("_pending_create", []) - pending_delete = nrm.module.params.get("_pending_delete", []) - - deployment_info = { - "msg": "CHECK MODE: Would save and deploy fabric configuration", - "fabric": fabric_name, - "deployment_needed": True, - "changed": True, - "would_deploy": True, - "deployment_decision_factors": { - "diff_has_changes": before != after, - "pending_create_operations": len(pending_create), - "pending_delete_operations": len(pending_delete), - "actual_changes": result.get("changed", False) - }, - "planned_actions": [ - f"POST {VpcPairEndpoints.fabric_config_save(fabric_name)}", - f"POST {VpcPairEndpoints.fabric_config_deploy(fabric_name, force_show_run=True)}" - ] - } - return deployment_info - - # Initialize RestSend via NDModuleV2 - nd_v2 = NDModuleV2(nrm.module) - results = Results() - - # Step 1: Save config - save_path = VpcPairEndpoints.fabric_config_save(fabric_name) - - try: - nd_v2.request(save_path, HttpVerbEnum.POST, {}) - - results.response_current = { - "RETURN_CODE": nd_v2.status, - "METHOD": "POST", - "REQUEST_PATH": save_path, - "MESSAGE": "Config saved successfully", - "DATA": {}, - } - results.result_current = {"success": True, "changed": True} - results.register_api_call() - - except NDModuleError as error: - if _is_non_fatal_config_save_error(error): - # Known platform limitation warning; continue to deploy step. - nrm.module.warn(f"Config save failed: {error.msg}") - - results.response_current = { - "RETURN_CODE": error.status if error.status else -1, - "MESSAGE": error.msg, - "REQUEST_PATH": save_path, - "METHOD": "POST", - "DATA": {}, - } - results.result_current = {"success": True, "changed": False} - results.register_api_call() - else: - # Unknown config-save failures are fatal. - results.response_current = { - "RETURN_CODE": error.status if error.status else -1, - "MESSAGE": error.msg, - "REQUEST_PATH": save_path, - "METHOD": "POST", - "DATA": {}, - } - results.result_current = {"success": False, "changed": False} - results.register_api_call() - results.build_final_result() - final_result = dict(results.final_result) - final_msg = final_result.pop("msg", f"Config save failed: {error.msg}") - _raise_vpc_error(msg=final_msg, **final_result) - - # Step 2: Deploy - deploy_path = VpcPairEndpoints.fabric_config_deploy(fabric_name, force_show_run=True) - - try: - nd_v2.request(deploy_path, HttpVerbEnum.POST, {}) - - results.response_current = { - "RETURN_CODE": nd_v2.status, - "METHOD": "POST", - "REQUEST_PATH": deploy_path, - "MESSAGE": "Deployment successful", - "DATA": {}, - } - results.result_current = {"success": True, "changed": True} - results.register_api_call() - - except NDModuleError as error: - results.response_current = { - "RETURN_CODE": error.status if error.status else -1, - "MESSAGE": error.msg, - "REQUEST_PATH": deploy_path, - "METHOD": "POST", - "DATA": {}, - } - results.result_current = {"success": False, "changed": False} - results.register_api_call() - - # Build final result and fail - results.build_final_result() - final_result = dict(results.final_result) - final_msg = final_result.pop("msg", "Fabric deployment failed") - _raise_vpc_error(msg=final_msg, **final_result) - - # Build final result - results.build_final_result() - return results.final_result - - -def run_vpc_module(nrm) -> Dict[str, Any]: - """ - Run VPC module state machine with VPC-specific gathered output. - - gathered is the query/read-only mode for VPC pairs. - """ - state = nrm.module.params.get("state", "merged") - config = nrm.module.params.get("config", []) - - if state == "gathered": - nrm.add_logs_and_outputs() - nrm.result["changed"] = False - - current_pairs = nrm.result.get("current", []) or [] - pending_delete = nrm.module.params.get("_pending_delete", []) or [] - - # Exclude pairs in pending-delete from active gathered set. - pending_delete_keys = set() - for pair in pending_delete: - switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") - peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") - if switch_id and peer_switch_id: - pending_delete_keys.add(tuple(sorted([switch_id, peer_switch_id]))) - - filtered_current = [] - for pair in current_pairs: - switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") - peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") - if switch_id and peer_switch_id: - pair_key = tuple(sorted([switch_id, peer_switch_id])) - if pair_key in pending_delete_keys: - continue - filtered_current.append(pair) - - nrm.result["current"] = filtered_current - nrm.result["gathered"] = { - "vpc_pairs": filtered_current, - "pending_create_vpc_pairs": nrm.module.params.get("_pending_create", []), - "pending_delete_vpc_pairs": pending_delete, - } - return nrm.result - - # state=deleted with empty config means "delete all existing pairs in this fabric". - # - # state=overridden with empty config has the same user intent (TC4): - # remove all existing pairs from this fabric. - if state in ("deleted", "overridden") and not config: - # Use the live existing collection from NDStateMachine. - # nrm.result["current"] is only populated after add_logs_and_outputs(), so relying on - # it here would incorrectly produce an empty delete list. - existing_pairs = _collection_to_list_flex(getattr(nrm, "existing", None)) - if not existing_pairs: - existing_pairs = nrm.result.get("current", []) or [] - - delete_all_config = [] - for pair in existing_pairs: - switch_id = pair.get(VpcFieldNames.SWITCH_ID) or pair.get("switch_id") - peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) or pair.get("peer_switch_id") - if switch_id and peer_switch_id: - use_vpl = pair.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) - if use_vpl is None: - use_vpl = pair.get("use_virtual_peer_link", True) - delete_all_config.append( - { - "switch_id": switch_id, - "peer_switch_id": peer_switch_id, - "use_virtual_peer_link": use_vpl, - } - ) - config = delete_all_config - # Force explicit delete operations instead of relying on overridden-state - # reconciliation behavior with empty desired config. - if state == "overridden": - state = "deleted" - - nrm.manage_state(state=state, new_configs=config) - nrm.add_logs_and_outputs() - return nrm.result # ===== Module Entry Point ===== From 0e3cdb4d58d363dc7c528254d9200353fa4713f0 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 18 Mar 2026 14:20:43 +0530 Subject: [PATCH 26/39] Interim changes to test with on ND output extraction changes in VPC --- .../v1/manage_vpc_pair/vpc_pair_resources.py | 19 +---------- .../orchestrators/nd_vpc_pair_orchestrator.py | 33 +++++++++---------- plugins/modules/nd_manage_vpc_pair.py | 20 ----------- .../tests/nd/nd_vpc_pair_merge.yaml | 4 +-- 4 files changed, 19 insertions(+), 57 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py index d941aea4..7bfb3dce 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py @@ -19,7 +19,6 @@ ) -ActionHandler = Callable[[Any], Any] RunStateHandler = Callable[[Any], Dict[str, Any]] DeployHandler = Callable[[Any, str, Dict[str, Any]], Dict[str, Any]] NeedsDeployHandler = Callable[[Dict[str, Any], Any], bool] @@ -104,7 +103,7 @@ def manage_state( parsed_items = [] for config in self.ansible_config: try: - parsed_items.append(self.model_class.model_validate(config)) + parsed_items.append(self.model_class.from_config(config)) except ValidationError as e: raise VpcPairResourceError( msg=f"Invalid configuration: {e}", @@ -296,32 +295,16 @@ class VpcPairResourceService: def __init__( self, module: AnsibleModule, - model_class: Any, - actions: Dict[str, ActionHandler], run_state_handler: RunStateHandler, deploy_handler: DeployHandler, needs_deployment_handler: NeedsDeployHandler, ): self.module = module - self.model_class = model_class - self.actions = actions self.run_state_handler = run_state_handler self.deploy_handler = deploy_handler self.needs_deployment_handler = needs_deployment_handler - def _prime_runtime_context(self) -> None: - required_actions = {"query_all", "create", "update", "delete"} - if not required_actions.issubset(set(self.actions)): - raise ValueError( - "Invalid vPC action map. Required keys: query_all, create, update, delete" - ) - # Store runtime objects on module attributes (not params) to avoid - # JSON-serialization issues in httpapi connection parameter handling. - self.module._vpc_pair_model_class = self.model_class - self.module._vpc_pair_actions = self.actions - def execute(self, fabric_name: str) -> Dict[str, Any]: - self._prime_runtime_context() nd_manage_vpc_pair = VpcPairStateMachine(module=self.module) result = self.run_state_handler(nd_manage_vpc_pair) diff --git a/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py b/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py index 0bf9dcc8..18640228 100644 --- a/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py +++ b/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py @@ -8,6 +8,17 @@ __metaclass__ = type from typing import Any, Optional +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_module_model import ( + VpcPairModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_actions import ( + custom_vpc_create, + custom_vpc_delete, + custom_vpc_update, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_query import ( + custom_vpc_query_all, +) from ansible.module_utils.basic import AnsibleModule @@ -26,7 +37,7 @@ class VpcPairOrchestrator: Delegates CRUD operations to injected vPC action handlers. """ - model_class = None + model_class = VpcPairModel def __init__( self, @@ -50,18 +61,6 @@ def __init__( self.sender = sender self.state_machine = None - self.model_class = getattr(self.module, "_vpc_pair_model_class", None) - self.actions = getattr(self.module, "_vpc_pair_actions", {}) - - if self.model_class is None: - raise ValueError("Missing _vpc_pair_model_class in module params") - required_actions = {"query_all", "create", "update", "delete"} - if not required_actions.issubset(set(self.actions)): - raise ValueError( - "Missing required _vpc_pair_actions. Required keys: " - "query_all, create, update, delete" - ) - def bind_state_machine(self, state_machine: Any) -> None: self.state_machine = state_machine @@ -71,22 +70,22 @@ def query_all(self): if self.state_machine is not None else _VpcPairQueryContext(self.module) ) - return self.actions["query_all"](context) + return custom_vpc_query_all(context) def create(self, model_instance, **kwargs): _ = (model_instance, kwargs) if self.state_machine is None: raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") - return self.actions["create"](self.state_machine) + return custom_vpc_create(self.state_machine) def update(self, model_instance, **kwargs): _ = (model_instance, kwargs) if self.state_machine is None: raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") - return self.actions["update"](self.state_machine) + return custom_vpc_update(self.state_machine) def delete(self, model_instance, **kwargs): _ = (model_instance, kwargs) if self.state_machine is None: raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") - return self.actions["delete"](self.state_machine) + return custom_vpc_delete(self.state_machine) diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index 334fc987..d1e954db 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -290,11 +290,6 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( VpcFieldNames, ) -from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_actions import ( - custom_vpc_create, - custom_vpc_delete, - custom_vpc_update, -) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( DEEPDIFF_IMPORT_ERROR, HAS_DEEPDIFF, @@ -303,15 +298,9 @@ _needs_deployment, custom_vpc_deploy, ) -from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_query import ( - custom_vpc_query_all, -) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_runner import ( run_vpc_module, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_module_model import ( - VpcPairModel, -) # ===== Module Entry Point ===== @@ -440,18 +429,9 @@ def main(): # VpcPairResourceService bridges NDStateMachine lifecycle hooks to RestSend actions. fabric_name = module.params.get("fabric_name") - actions = { - "query_all": custom_vpc_query_all, - "create": custom_vpc_create, - "update": custom_vpc_update, - "delete": custom_vpc_delete, - } - try: service = VpcPairResourceService( module=module, - model_class=VpcPairModel, - actions=actions, run_state_handler=run_vpc_module, deploy_handler=custom_vpc_deploy, needs_deployment_handler=_needs_deployment, diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml index 68f9e888..e9a8ae58 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml @@ -296,7 +296,7 @@ - name: MERGE - TC4b - ASSERT - Check if delete successfully ansible.builtin.assert: that: - - result.failed == false + - result.failed == false when: test_fabric_type == "LANClassic" tags: merge @@ -311,7 +311,7 @@ - name: MERGE - TC5 - ASSERT - Check if changed flag is true ansible.builtin.assert: that: - - result.failed == false + - result.failed == false tags: merge - name: MERGE - TC5 - GATHER - Get vPC pair state in ND From 2f5411956a27bad26c6a3e580c58035dbc38846d Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 18 Mar 2026 15:44:51 +0530 Subject: [PATCH 27/39] Changes to relocate files under models --- plugins/models/__init__.py | 6 - .../models/manage_vpc_pair/__init__.py | 10 ++ .../models/manage_vpc_pair}/base.py | 0 .../models/manage_vpc_pair/model.py | 158 ++++++++++++++++++ .../models/manage_vpc_pair}/nested.py | 4 +- .../manage_vpc_pair}/vpc_pair_models.py | 6 +- 6 files changed, 175 insertions(+), 9 deletions(-) delete mode 100644 plugins/models/__init__.py create mode 100644 plugins/module_utils/models/manage_vpc_pair/__init__.py rename plugins/{models => module_utils/models/manage_vpc_pair}/base.py (100%) create mode 100644 plugins/module_utils/models/manage_vpc_pair/model.py rename plugins/{models => module_utils/models/manage_vpc_pair}/nested.py (88%) rename plugins/{models => module_utils/models/manage_vpc_pair}/vpc_pair_models.py (99%) diff --git a/plugins/models/__init__.py b/plugins/models/__init__.py deleted file mode 100644 index abb1dedf..00000000 --- a/plugins/models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - diff --git a/plugins/module_utils/models/manage_vpc_pair/__init__.py b/plugins/module_utils/models/manage_vpc_pair/__init__.py new file mode 100644 index 00000000..04758866 --- /dev/null +++ b/plugins/module_utils/models/manage_vpc_pair/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami S +# 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.models.manage_vpc_pair.model import ( # noqa: F401 + VpcPairModel, +) diff --git a/plugins/models/base.py b/plugins/module_utils/models/manage_vpc_pair/base.py similarity index 100% rename from plugins/models/base.py rename to plugins/module_utils/models/manage_vpc_pair/base.py diff --git a/plugins/module_utils/models/manage_vpc_pair/model.py b/plugins/module_utils/models/manage_vpc_pair/model.py new file mode 100644 index 00000000..275052c5 --- /dev/null +++ b/plugins/module_utils/models/manage_vpc_pair/model.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami S +# 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, ClassVar, Dict, List, Literal, Optional, Union + +try: + from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.base import ( + NDVpcPairBaseModel as _VpcPairBaseModel, + ) +except ImportError: + from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel as _VpcPairBaseModel, + ) + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ConfigDict, + Field, + field_validator, + model_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( + VpcFieldNames, +) + +try: + from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.vpc_pair_models import ( + VpcPairDetailsDefault, + VpcPairDetailsCustom, + ) +except ImportError: + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_schemas import ( + VpcPairDetailsDefault, + VpcPairDetailsCustom, + ) + + +class VpcPairModel(_VpcPairBaseModel): + """ + Pydantic model for nd_manage_vpc_pair input. + + Uses a composite identifier `(switch_id, peer_switch_id)` and module-oriented + defaults/validation behavior. + """ + + identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] + identifier_strategy: ClassVar[Literal["composite"]] = "composite" + exclude_from_diff: ClassVar[List[str]] = [] + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, + extra="ignore", + ) + + switch_id: str = Field( + alias=VpcFieldNames.SWITCH_ID, + description="Peer-1 switch serial number", + min_length=3, + max_length=64, + ) + peer_switch_id: str = Field( + alias=VpcFieldNames.PEER_SWITCH_ID, + description="Peer-2 switch serial number", + min_length=3, + max_length=64, + ) + use_virtual_peer_link: bool = Field( + default=True, + alias=VpcFieldNames.USE_VIRTUAL_PEER_LINK, + description="Virtual peer link enabled", + ) + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, + discriminator="type", + alias=VpcFieldNames.VPC_PAIR_DETAILS, + description="VPC pair configuration details (default or custom template)", + ) + + @field_validator("switch_id", "peer_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> "VpcPairModel": + if self.switch_id == self.peer_switch_id: + raise ValueError( + f"switch_id and peer_switch_id must be different: {self.switch_id}" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + return self.model_dump(by_alias=True, exclude_none=True) + + def to_diff_dict(self) -> Dict[str, Any]: + return self.model_dump( + by_alias=True, + exclude_none=True, + exclude=set(self.exclude_from_diff), + ) + + def get_identifier_value(self): + return tuple(sorted([self.switch_id, self.peer_switch_id])) + + def to_config(self, **kwargs) -> Dict[str, Any]: + return self.model_dump(by_alias=False, exclude_none=True, **kwargs) + + @classmethod + def from_config(cls, ansible_config: Dict[str, Any]) -> "VpcPairModel": + data = dict(ansible_config or {}) + + # Accept both snake_case module input and API camelCase aliases. + if VpcFieldNames.SWITCH_ID not in data and "switch_id" in data: + data[VpcFieldNames.SWITCH_ID] = data.get("switch_id") + if VpcFieldNames.PEER_SWITCH_ID not in data and "peer_switch_id" in data: + data[VpcFieldNames.PEER_SWITCH_ID] = data.get("peer_switch_id") + if ( + VpcFieldNames.USE_VIRTUAL_PEER_LINK not in data + and "use_virtual_peer_link" in data + ): + data[VpcFieldNames.USE_VIRTUAL_PEER_LINK] = data.get("use_virtual_peer_link") + if VpcFieldNames.VPC_PAIR_DETAILS not in data and "vpc_pair_details" in data: + data[VpcFieldNames.VPC_PAIR_DETAILS] = data.get("vpc_pair_details") + + return cls.model_validate(data, by_alias=True, by_name=True) + + def merge(self, other_model: "VpcPairModel") -> "VpcPairModel": + if not isinstance(other_model, type(self)): + raise TypeError( + "VpcPairModel.merge requires both models to be the same type" + ) + + for field, value in other_model: + if value is None: + continue + setattr(self, field, value) + return self + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> "VpcPairModel": + data = { + VpcFieldNames.SWITCH_ID: response.get(VpcFieldNames.SWITCH_ID), + VpcFieldNames.PEER_SWITCH_ID: response.get(VpcFieldNames.PEER_SWITCH_ID), + VpcFieldNames.USE_VIRTUAL_PEER_LINK: response.get( + VpcFieldNames.USE_VIRTUAL_PEER_LINK, True + ), + } + return cls.model_validate(data) diff --git a/plugins/models/nested.py b/plugins/module_utils/models/manage_vpc_pair/nested.py similarity index 88% rename from plugins/models/nested.py rename to plugins/module_utils/models/manage_vpc_pair/nested.py index 558da3c5..999f60b4 100644 --- a/plugins/models/nested.py +++ b/plugins/module_utils/models/manage_vpc_pair/nested.py @@ -10,7 +10,9 @@ from typing import Any, Dict, List, ClassVar from typing_extensions import Self -from ansible_collections.cisco.nd.plugins.models.base import NDVpcPairBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.base import ( + NDVpcPairBaseModel, +) class NDVpcPairNestedModel(NDVpcPairBaseModel): diff --git a/plugins/models/vpc_pair_models.py b/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py similarity index 99% rename from plugins/models/vpc_pair_models.py rename to plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py index 35845acf..6b2eea32 100644 --- a/plugins/models/vpc_pair_models.py +++ b/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py @@ -27,13 +27,15 @@ field_validator, model_validator, ) -from ansible_collections.cisco.nd.plugins.models.base import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.base import ( FlexibleBool, FlexibleInt, FlexibleListStr, NDVpcPairBaseModel, ) -from ansible_collections.cisco.nd.plugins.models.nested import NDVpcPairNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.nested import ( + NDVpcPairNestedModel, +) # Import enums from centralized location from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( From c44cd6700448aa0df5d6e74f62678d7ae4843464 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 18 Mar 2026 19:19:40 +0530 Subject: [PATCH 28/39] Interim changes for endpoint folder contents --- .../endpoints/v1/manage/__init__.py | 31 ++ .../endpoints/v1/manage/vpc_pair.py | 67 +++ .../vpc_pair_base_paths.py} | 0 .../v1/manage/vpc_pair_consistency.py | 53 ++ .../endpoints/v1/manage/vpc_pair_endpoints.py | 40 ++ .../enums.py => manage/vpc_pair_enums.py} | 0 .../v1/manage/vpc_pair_module_model.py | 10 + .../endpoints/v1/manage/vpc_pair_overview.py | 55 ++ .../v1/manage/vpc_pair_recommendation.py | 55 ++ .../vpc_pair_resources.py | 131 ++++- .../vpc_pair_runtime_endpoints.py | 56 +- .../vpc_pair_runtime_payloads.py | 2 +- .../vpc_pair_schemas.py | 17 +- .../endpoints/v1/manage/vpc_pair_support.py | 55 ++ .../endpoints/v1/manage/vpc_pairs.py | 57 ++ .../endpoints/v1/manage_vpc_pair/__init__.py | 114 ---- .../endpoints/v1/manage_vpc_pair/mixins.py | 52 -- .../v1/manage_vpc_pair/vpc_pair_endpoints.py | 497 ------------------ .../manage_vpc_pair/vpc_pair_module_model.py | 157 ------ 19 files changed, 579 insertions(+), 870 deletions(-) create mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair.py rename plugins/module_utils/endpoints/v1/{manage_vpc_pair/base_paths.py => manage/vpc_pair_base_paths.py} (100%) create mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair_consistency.py create mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py rename plugins/module_utils/endpoints/v1/{manage_vpc_pair/enums.py => manage/vpc_pair_enums.py} (100%) create mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py create mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair_overview.py create mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair_recommendation.py rename plugins/module_utils/endpoints/v1/{manage_vpc_pair => manage}/vpc_pair_resources.py (73%) rename plugins/module_utils/endpoints/v1/{manage_vpc_pair => manage}/vpc_pair_runtime_endpoints.py (78%) rename plugins/module_utils/endpoints/v1/{manage_vpc_pair => manage}/vpc_pair_runtime_payloads.py (98%) rename plugins/module_utils/endpoints/v1/{manage_vpc_pair => manage}/vpc_pair_schemas.py (91%) create mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair_support.py create mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pairs.py delete mode 100644 plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py delete mode 100644 plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py delete mode 100644 plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py delete mode 100644 plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_module_model.py diff --git a/plugins/module_utils/endpoints/v1/manage/__init__.py b/plugins/module_utils/endpoints/v1/manage/__init__.py index e69de29b..9838a1ea 100644 --- a/plugins/module_utils/endpoints/v1/manage/__init__.py +++ b/plugins/module_utils/endpoints/v1/manage/__init__.py @@ -0,0 +1,31 @@ +from __future__ import absolute_import, division, print_function + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair import ( + EpVpcPairGet, + EpVpcPairPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_support import ( + EpVpcPairSupportGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_overview import ( + EpVpcPairOverviewGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_recommendation import ( + EpVpcPairRecommendationGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_consistency import ( + EpVpcPairConsistencyGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pairs import ( + EpVpcPairsListGet, +) + +__all__ = [ + "EpVpcPairGet", + "EpVpcPairPut", + "EpVpcPairSupportGet", + "EpVpcPairOverviewGet", + "EpVpcPairRecommendationGet", + "EpVpcPairConsistencyGet", + "EpVpcPairsListGet", +] diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair.py new file mode 100644 index 00000000..11f103f4 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 Literal + +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, + FromClusterMixin, + SwitchIdMixin, + TicketIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class _EpVpcPairBase( + FabricNameMixin, + SwitchIdMixin, + FromClusterMixin, + NDEndpointBaseModel, +): + model_config = COMMON_CONFIG + + @property + def path(self) -> str: + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + return BasePath.path( + "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPair" + ) + + +class EpVpcPairGet(_EpVpcPairBase): + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairGet"] = Field(default="EpVpcPairGet") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +class EpVpcPairPut(_EpVpcPairBase, TicketIdMixin): + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairPut"] = Field(default="EpVpcPairPut") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.PUT + + +__all__ = ["EpVpcPairGet", "EpVpcPairPut"] diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_base_paths.py similarity index 100% rename from plugins/module_utils/endpoints/v1/manage_vpc_pair/base_paths.py rename to plugins/module_utils/endpoints/v1/manage/vpc_pair_base_paths.py diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_consistency.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_consistency.py new file mode 100644 index 00000000..c205cd98 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_consistency.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 Literal + +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, + FromClusterMixin, + SwitchIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpVpcPairConsistencyGet( + FabricNameMixin, + SwitchIdMixin, + FromClusterMixin, + NDEndpointBaseModel, +): + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairConsistencyGet"] = Field(default="EpVpcPairConsistencyGet") + + @property + def path(self) -> str: + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + return BasePath.path( + "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairConsistency" + ) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpVpcPairConsistencyGet"] diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py new file mode 100644 index 00000000..acece926 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 + +# Backward-compatible export surface for legacy imports. +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair import ( + EpVpcPairGet, + EpVpcPairPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_consistency import ( + EpVpcPairConsistencyGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_overview import ( + EpVpcPairOverviewGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_recommendation import ( + EpVpcPairRecommendationGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_support import ( + EpVpcPairSupportGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pairs import ( + EpVpcPairsListGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_base_paths import ( + VpcPairBasePath, +) + +__all__ = [ + "EpVpcPairGet", + "EpVpcPairPut", + "EpVpcPairSupportGet", + "EpVpcPairOverviewGet", + "EpVpcPairRecommendationGet", + "EpVpcPairConsistencyGet", + "EpVpcPairsListGet", + "VpcPairBasePath", +] diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/enums.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_enums.py similarity index 100% rename from plugins/module_utils/endpoints/v1/manage_vpc_pair/enums.py rename to plugins/module_utils/endpoints/v1/manage/vpc_pair_enums.py diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py new file mode 100644 index 00000000..7350e94d --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 + +# Backward-compatible import path for callers still using the legacy location. +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( # noqa: F401 + VpcPairModel, +) diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_overview.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_overview.py new file mode 100644 index 00000000..193cb703 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_overview.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 Literal + +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ComponentTypeMixin, + FabricNameMixin, + FromClusterMixin, + SwitchIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpVpcPairOverviewGet( + FabricNameMixin, + SwitchIdMixin, + FromClusterMixin, + ComponentTypeMixin, + NDEndpointBaseModel, +): + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairOverviewGet"] = Field(default="EpVpcPairOverviewGet") + + @property + def path(self) -> str: + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + return BasePath.path( + "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairOverview" + ) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpVpcPairOverviewGet"] diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_recommendation.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_recommendation.py new file mode 100644 index 00000000..ab5117ff --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_recommendation.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 Literal + +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, + FromClusterMixin, + SwitchIdMixin, + UseVirtualPeerLinkMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpVpcPairRecommendationGet( + FabricNameMixin, + SwitchIdMixin, + FromClusterMixin, + UseVirtualPeerLinkMixin, + NDEndpointBaseModel, +): + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairRecommendationGet"] = Field(default="EpVpcPairRecommendationGet") + + @property + def path(self) -> str: + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + return BasePath.path( + "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairRecommendation" + ) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpVpcPairRecommendationGet"] diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py similarity index 73% rename from plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py rename to plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py index 7bfb3dce..b50c735b 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py @@ -1,22 +1,25 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Sivakami S +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com # 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 from typing import Any, Callable, Dict, List, Optional from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import ( NDStateMachine, ) -from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.nd_vpc_pair_orchestrator import ( +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_pair import ( VpcPairOrchestrator, ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( ValidationError, ) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( + VpcPairResourceError, +) RunStateHandler = Callable[[Any], Dict[str, Any]] @@ -24,15 +27,6 @@ NeedsDeployHandler = Callable[[Dict[str, Any], Any], bool] -class VpcPairResourceError(Exception): - """Structured error raised by vpc_pair runtime layers.""" - - def __init__(self, msg: str, **details: Any): - super().__init__(msg) - self.msg = msg - self.details = details - - class VpcPairStateMachine(NDStateMachine): """NDStateMachine adapter with state handling for nd_manage_vpc_pair.""" @@ -67,6 +61,7 @@ def add_logs_and_outputs(self) -> None: """ Build final result payload compatible with nd_manage_vpc_pair runtime. """ + self._refresh_after_state() self.output.assign( after=getattr(self, "existing", None), before=getattr(self, "before", None), @@ -78,10 +73,122 @@ def add_logs_and_outputs(self) -> None: formatted.setdefault("current", formatted.get("after", [])) formatted.setdefault("response", []) formatted.setdefault("result", []) + class_diff = self._build_class_diff() + formatted["created"] = class_diff["created"] + formatted["deleted"] = class_diff["deleted"] + formatted["updated"] = class_diff["updated"] + formatted["class_diff"] = class_diff if self.logs and "logs" not in formatted: formatted["logs"] = self.logs self.result = formatted + def _refresh_after_state(self) -> None: + """ + Optionally refresh the final "after" state from controller query. + + Enabled by default for write states to better reflect live controller + state. Can be disabled for performance-sensitive runs. + """ + state = self.module.params.get("state") + if state not in ("merged", "replaced", "overridden", "deleted"): + return + if self.module.check_mode: + return + if not self.module.params.get("refresh_after_apply", True): + return + + refresh_timeout = self.module.params.get("refresh_after_timeout") + had_original_timeout = "query_timeout" in self.module.params + original_timeout = self.module.params.get("query_timeout") + + try: + if refresh_timeout is not None: + self.module.params["query_timeout"] = refresh_timeout + response_data = self.model_orchestrator.query_all() + self.existing = self.nd_config_collection.from_api_response( + response_data=response_data, + model_class=self.model_class, + ) + except Exception as exc: + self.module.warn( + f"Failed to refresh final after-state from controller query: {exc}" + ) + finally: + if refresh_timeout is not None: + if had_original_timeout: + self.module.params["query_timeout"] = original_timeout + else: + self.module.params.pop("query_timeout", None) + + @staticmethod + def _identifier_to_key(identifier: Any) -> str: + """ + Build a stable key for de-duplicating identifiers in class diff output. + """ + try: + return json.dumps(identifier, sort_keys=True, default=str) + except Exception: + return str(identifier) + + @staticmethod + def _extract_changed_properties(log_entry: Dict[str, Any]) -> List[str]: + """ + Best-effort changed-property extraction for update operations. + """ + before = log_entry.get("before") + after = log_entry.get("after") + sent_payload = log_entry.get("sent_payload") + + changed = [] + if isinstance(before, dict) and isinstance(after, dict): + all_keys = set(before.keys()) | set(after.keys()) + changed = [key for key in all_keys if before.get(key) != after.get(key)] + + if not changed and isinstance(sent_payload, dict): + changed = list(sent_payload.keys()) + + return sorted(set(changed)) + + def _build_class_diff(self) -> Dict[str, List[Any]]: + """ + Build class-level diff with created/deleted/updated entries. + """ + created: List[Any] = [] + deleted: List[Any] = [] + updated: List[Dict[str, Any]] = [] + + created_seen = set() + deleted_seen = set() + updated_map: Dict[str, Dict[str, Any]] = {} + + for log_entry in self.logs: + status = log_entry.get("status") + identifier = log_entry.get("identifier") + key = self._identifier_to_key(identifier) + + if status == "created": + if key not in created_seen: + created_seen.add(key) + created.append(identifier) + elif status == "deleted": + if key not in deleted_seen: + deleted_seen.add(key) + deleted.append(identifier) + elif status == "updated": + changed_props = self._extract_changed_properties(log_entry) + entry = updated_map.get(key) + if entry is None: + entry = {"identifier": identifier} + if changed_props: + entry["changed_properties"] = changed_props + updated_map[key] = entry + elif changed_props: + merged = set(entry.get("changed_properties", [])) | set(changed_props) + entry["changed_properties"] = sorted(merged) + + updated.extend(updated_map.values()) + return {"created": created, "deleted": deleted, "updated": updated} + def manage_state( self, state: str, diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_runtime_endpoints.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py similarity index 78% rename from plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_runtime_endpoints.py rename to plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py index 6b238ab5..2073932c 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_runtime_endpoints.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Sivakami S +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com # 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 @@ -11,32 +10,31 @@ CompositeQueryParams, EndpointQueryParams, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( ComponentTypeSupportEnum, ) - -try: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_endpoints import ( - EpVpcPairConsistencyGet, - EpVpcPairGet, - EpVpcPairPut, - EpVpcPairOverviewGet, - EpVpcPairRecommendationGet, - EpVpcPairSupportGet, - EpVpcPairsListGet, - VpcPairBasePath, - ) -except ImportError: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair import ( - EpVpcPairConsistencyGet, - EpVpcPairGet, - EpVpcPairPut, - EpVpcPairOverviewGet, - EpVpcPairRecommendationGet, - EpVpcPairSupportGet, - EpVpcPairsListGet, - VpcPairBasePath, - ) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair import ( + EpVpcPairGet, + EpVpcPairPut, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_consistency import ( + EpVpcPairConsistencyGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_overview import ( + EpVpcPairOverviewGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_recommendation import ( + EpVpcPairRecommendationGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_support import ( + EpVpcPairSupportGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pairs import ( + EpVpcPairsListGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) class _ComponentTypeQueryParams(EndpointQueryParams): @@ -92,7 +90,7 @@ def vpc_pair_put(fabric_name: str, switch_id: str) -> str: @staticmethod def fabric_switches(fabric_name: str) -> str: - return VpcPairBasePath.fabrics(fabric_name, "switches") + return BasePath.path("fabrics", fabric_name, "switches") @staticmethod def switch_vpc_pair(fabric_name: str, switch_id: str) -> str: @@ -133,11 +131,11 @@ def switch_vpc_consistency(fabric_name: str, switch_id: str) -> str: @staticmethod def fabric_config_save(fabric_name: str) -> str: - return VpcPairBasePath.fabrics(fabric_name, "actions", "configSave") + return BasePath.path("fabrics", fabric_name, "actions", "configSave") @staticmethod def fabric_config_deploy(fabric_name: str, force_show_run: bool = True) -> str: - base_path = VpcPairBasePath.fabrics(fabric_name, "actions", "deploy") + base_path = BasePath.path("fabrics", fabric_name, "actions", "deploy") query_params = _ForceShowRunQueryParams( force_show_run=True if force_show_run else None ) diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_runtime_payloads.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_payloads.py similarity index 98% rename from plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_runtime_payloads.py rename to plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_payloads.py index 040a89b9..6d9e6db3 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_runtime_payloads.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_payloads.py @@ -7,7 +7,7 @@ from typing import Any, Dict, Optional -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( VpcActionEnum, VpcFieldNames, ) diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py similarity index 91% rename from plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py rename to plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py index eb8de0b4..ca3c5981 100644 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_schemas.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py @@ -1,17 +1,16 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2025, Sivakami Sivaraman +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com # 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 """ Backward-compatible export surface for vPC pair schemas. -Primary source of truth lives in `plugins/models/vpc_pair_models.py`. +Primary source of truth lives in `plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py`. This module also provides local fallback models for AnsiballZ runtimes where -`plugins/models` files may not be packaged. +`module_utils/models/manage_vpc_pair` files may not be packaged. """ from typing import Any, Dict, List, Optional, Literal, Annotated @@ -24,7 +23,7 @@ ) try: - from ansible_collections.cisco.nd.plugins.models.base import ( # noqa: F401 + from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.base import ( # noqa: F401 coerce_str_to_int, coerce_to_bool, coerce_list_of_str, @@ -33,10 +32,12 @@ FlexibleListStr, NDVpcPairBaseModel, ) - from ansible_collections.cisco.nd.plugins.models.nested import NDVpcPairNestedModel # noqa: F401 - from ansible_collections.cisco.nd.plugins.models.vpc_pair_models import * # noqa: F401,F403 + from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.nested import ( # noqa: F401 + NDVpcPairNestedModel, + ) + from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.vpc_pair_models import * # noqa: F401,F403 except Exception: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( # noqa: F401 + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( # noqa: F401 KeepAliveVrfEnum, ) diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_support.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_support.py new file mode 100644 index 00000000..ade16dfb --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_support.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 Literal + +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ComponentTypeMixin, + FabricNameMixin, + FromClusterMixin, + SwitchIdMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpVpcPairSupportGet( + FabricNameMixin, + SwitchIdMixin, + FromClusterMixin, + ComponentTypeMixin, + NDEndpointBaseModel, +): + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairSupportGet"] = Field(default="EpVpcPairSupportGet") + + @property + def path(self) -> str: + if self.fabric_name is None or self.switch_id is None: + raise ValueError("fabric_name and switch_id are required") + return BasePath.path( + "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairSupport" + ) + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpVpcPairSupportGet"] diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pairs.py b/plugins/module_utils/endpoints/v1/manage/vpc_pairs.py new file mode 100644 index 00000000..54b693c8 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pairs.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 Literal + +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, + FilterMixin, + FromClusterMixin, + PaginationMixin, + SortMixin, + ViewMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpVpcPairsListGet( + FabricNameMixin, + FromClusterMixin, + FilterMixin, + PaginationMixin, + SortMixin, + ViewMixin, + NDEndpointBaseModel, +): + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpVpcPairsListGet"] = Field(default="EpVpcPairsListGet") + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError("fabric_name is required") + return BasePath.path("fabrics", self.fabric_name, "vpcPairs") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpVpcPairsListGet"] diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py deleted file mode 100644 index 90510486..00000000 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/__init__.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import absolute_import, division, print_function - -__copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." -__author__ = "Neil John" - -""" -VPC pair management utilities for Cisco ND Ansible collection. - -This package provides Pydantic-based schemas and endpoint models for -managing VPC pairs in Nexus Dashboard. - -Components: -- enums: Enumeration types for constrained values -- mixins: Reusable field mixins for composition -- base_paths: Centralized API path builders -- vpc_pair_endpoints: Endpoint models for each API operation -- vpc_pair_schemas: Request/response data schemas - -Usage: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair import ( - EpVpcPairGet, - EpVpcPairPut, - VpcPairDetailsDefault, - VpcPairingRequest, - VpcActionEnum, - ) -""" - -# Export commonly used components for easier imports -__all__ = [ - # Endpoints - "EpVpcPairGet", - "EpVpcPairPut", - "EpVpcPairSupportGet", - "EpVpcPairOverviewGet", - "EpVpcPairRecommendationGet", - "EpVpcPairConsistencyGet", - "EpVpcPairsListGet", - "VpcPairEndpoints", - # Schemas - "VpcPairDetailsDefault", - "VpcPairDetailsCustom", - "VpcPairingRequest", - "VpcUnpairingRequest", - "VpcPairBase", - "VpcPairConsistency", - "VpcPairRecommendation", - "VpcPairModel", - # Enums - "VerbEnum", - "VpcActionEnum", - "VpcPairTypeEnum", - "KeepAliveVrfEnum", - "PoModeEnum", - "PortChannelDuplexEnum", - "VpcRoleEnum", - "MaintenanceModeEnum", - "ComponentTypeOverviewEnum", - "ComponentTypeSupportEnum", - "VpcPairViewEnum", - # Field names - "VpcFieldNames", - # Base paths - "VpcPairBasePath", -] - -# Try to import and expose components (graceful fallback if pydantic not available) -try: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_endpoints import ( - EpVpcPairGet, - EpVpcPairPut, - EpVpcPairSupportGet, - EpVpcPairOverviewGet, - EpVpcPairRecommendationGet, - EpVpcPairConsistencyGet, - EpVpcPairsListGet, - ) - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( - VpcPairEndpoints, - ) - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_module_model import ( - VpcPairModel, - ) - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_schemas import ( - VpcPairDetailsDefault, - VpcPairDetailsCustom, - VpcPairingRequest, - VpcUnpairingRequest, - VpcPairBase, - VpcPairConsistency, - VpcPairRecommendation, - ) - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( - VerbEnum, - VpcActionEnum, - VpcPairTypeEnum, - KeepAliveVrfEnum, - PoModeEnum, - PortChannelDuplexEnum, - VpcRoleEnum, - MaintenanceModeEnum, - ComponentTypeOverviewEnum, - ComponentTypeSupportEnum, - VpcPairViewEnum, - VpcFieldNames, - ) - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.base_paths import ( - VpcPairBasePath, - ) - -except ImportError as e: - # Pydantic not available - components will not be exposed - # This allows the package to be imported without pydantic for basic functionality - _ = e diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py deleted file mode 100644 index c93eb105..00000000 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/mixins.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) 2026 Cisco and/or its affiliates. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -Reusable mixin classes for VPC pair endpoint models. - -This module re-exports shared endpoint mixins from -`plugins/module_utils/endpoints/mixins.py` to avoid local duplication -while preserving the existing import path for vPC pair code. -""" - -from __future__ import absolute_import, annotations, division, print_function - -__author__ = "Sivakami Sivaraman" - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( - ComponentTypeMixin, - FabricNameMixin, - FilterMixin, - FromClusterMixin, - PaginationMixin, - PeerSwitchIdMixin, - SortMixin, - SwitchIdMixin, - TicketIdMixin, - UseVirtualPeerLinkMixin, - ViewMixin, -) - -__all__ = [ - "FabricNameMixin", - "SwitchIdMixin", - "PeerSwitchIdMixin", - "UseVirtualPeerLinkMixin", - "FromClusterMixin", - "TicketIdMixin", - "ComponentTypeMixin", - "FilterMixin", - "PaginationMixin", - "SortMixin", - "ViewMixin", -] diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py deleted file mode 100644 index da5fa608..00000000 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_endpoints.py +++ /dev/null @@ -1,497 +0,0 @@ -# Copyright (c) 2026 Cisco and/or its affiliates. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -VPC Pair endpoint models. - -This module contains endpoint definitions for VPC pair management operations -in the ND Manage API. -""" - -from __future__ import absolute_import, division, print_function - -__author__ = "Sivakami Sivaraman" - -from typing import Literal - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( - NDEndpointBaseModel, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.base_paths import ( - VpcPairBasePath, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.mixins import ( - ComponentTypeMixin, - FabricNameMixin, - FilterMixin, - FromClusterMixin, - PaginationMixin, - SortMixin, - SwitchIdMixin, - TicketIdMixin, - UseVirtualPeerLinkMixin, - ViewMixin, -) -from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - ConfigDict, - Field, -) - -# Common config for basic validation -COMMON_CONFIG = ConfigDict(validate_assignment=True) - - -# ============================================================================ -# VPC Pair Details Endpoints (/vpcPair) -# ============================================================================ - - -class _EpVpcPairBase( - FabricNameMixin, - SwitchIdMixin, - FromClusterMixin, - NDEndpointBaseModel, -): - """ - Base class for VPC pair details endpoints. - - Provides common functionality for all HTTP methods on the - /fabrics/{fabricName}/switches/{switchId}/vpcPair endpoint. - """ - - model_config = COMMON_CONFIG - - @property - def path(self) -> str: - """ - # Summary - - Build the endpoint path. - - ## Returns - - - Complete endpoint path string - """ - if self.fabric_name is None or self.switch_id is None: - raise ValueError("fabric_name and switch_id are required") - return VpcPairBasePath.vpc_pair(self.fabric_name, self.switch_id) - - -class EpVpcPairGet(_EpVpcPairBase): - """ - # Summary - - VPC Pair Details GET Endpoint - - ## Description - - Endpoint to retrieve VPC pair details for a specific switch. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair - - ## Verb - - - GET - - ## Usage - - ```python - # Get VPC pair details - endpoint = EpVpcPairGet() - endpoint.fabric_name = "Fabric1" - endpoint.switch_id = "FDO23040Q85" - endpoint.from_cluster = "cluster1" # Optional - path = endpoint.path - verb = endpoint.verb - ``` - """ - - 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") - class_name: Literal["EpVpcPairGet"] = Field(default="EpVpcPairGet", description="Class name for backward compatibility") - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.GET - - -class EpVpcPairPut(_EpVpcPairBase, TicketIdMixin): - """ - # Summary - - VPC Pair Management PUT Endpoint - - ## Description - - Endpoint to manage (create, update, delete) VPC pair configuration. - Use vpcAction="pair" for pairing and vpcAction="unPair" for unpairing. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair - - ## Verb - - - PUT - - ## Usage - - ```python - # Manage VPC pair - endpoint = EpVpcPairPut() - endpoint.fabric_name = "Fabric1" - endpoint.switch_id = "FDO23040Q85" - endpoint.ticket_id = "CHG001" # Optional - path = endpoint.path - verb = endpoint.verb - ``` - """ - - 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") - class_name: Literal["EpVpcPairPut"] = Field(default="EpVpcPairPut", description="Class name for backward compatibility") - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.PUT - - -# ============================================================================ -# VPC Pair Support Endpoints (/vpcPairSupport) -# ============================================================================ - - -class EpVpcPairSupportGet( - FabricNameMixin, - SwitchIdMixin, - FromClusterMixin, - ComponentTypeMixin, - NDEndpointBaseModel, -): - """ - # Summary - - VPC Pair Support Check GET Endpoint - - ## Description - - Endpoint to check VPC pairing support and validation details. - Supports componentType="checkPairing" or "checkFabricPeeringSupport". - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport - - ## Verb - - - GET - - ## Gathered Parameters - - - componentType: Required. Values: "checkPairing", "checkFabricPeeringSupport" - - ## Usage - - ```python - # Check if pairing is allowed - endpoint = EpVpcPairSupportGet() - endpoint.fabric_name = "Fabric1" - endpoint.switch_id = "FDO23040Q85" - endpoint.component_type = "checkPairing" - path = endpoint.path - verb = endpoint.verb - ``` - """ - - model_config = COMMON_CONFIG - 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") - class_name: Literal["EpVpcPairSupportGet"] = Field(default="EpVpcPairSupportGet", description="Class name for backward compatibility") - - @property - def path(self) -> str: - """Build the endpoint path.""" - if self.fabric_name is None or self.switch_id is None: - raise ValueError("fabric_name and switch_id are required") - return VpcPairBasePath.vpc_pair_support(self.fabric_name, self.switch_id) - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.GET - - -# ============================================================================ -# VPC Pair Overview Endpoints (/vpcPairOverview) -# ============================================================================ - - -class EpVpcPairOverviewGet( - FabricNameMixin, - SwitchIdMixin, - FromClusterMixin, - ComponentTypeMixin, - NDEndpointBaseModel, -): - """ - # Summary - - VPC Pair Overview GET Endpoint - - ## Description - - Endpoint to retrieve VPC pair overview details with various component types. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview - - ## Verb - - - GET - - ## Gathered Parameters - - - componentType: Required. Values: "full", "health", "module", "vxlan", - "overlay", "pairsInfo", "inventory", "anomalies" - - ## Usage - - ```python - # Get full overview - endpoint = EpVpcPairOverviewGet() - endpoint.fabric_name = "Fabric1" - endpoint.switch_id = "FDO23040Q85" - endpoint.component_type = "full" - path = endpoint.path - verb = endpoint.verb - ``` - """ - - model_config = COMMON_CONFIG - 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") - class_name: Literal["EpVpcPairOverviewGet"] = Field(default="EpVpcPairOverviewGet", description="Class name for backward compatibility") - - @property - def path(self) -> str: - """Build the endpoint path.""" - if self.fabric_name is None or self.switch_id is None: - raise ValueError("fabric_name and switch_id are required") - return VpcPairBasePath.vpc_pair_overview(self.fabric_name, self.switch_id) - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.GET - - -# ============================================================================ -# VPC Pair Recommendation Endpoints (/vpcPairRecommendation) -# ============================================================================ - - -class EpVpcPairRecommendationGet( - FabricNameMixin, - SwitchIdMixin, - FromClusterMixin, - UseVirtualPeerLinkMixin, - NDEndpointBaseModel, -): - """ - # Summary - - VPC Pair Recommendation GET Endpoint - - ## Description - - Endpoint to get recommendations for VPC pairing with available devices. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation - - ## Verb - - - GET - - ## Gathered Parameters - - - useVirtualPeerLink: Optional boolean - - ## Usage - - ```python - # Get pairing recommendations - endpoint = EpVpcPairRecommendationGet() - endpoint.fabric_name = "Fabric1" - endpoint.switch_id = "FDO23040Q85" - endpoint.use_virtual_peer_link = True - path = endpoint.path - verb = endpoint.verb - ``` - """ - - model_config = COMMON_CONFIG - 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") - class_name: Literal["EpVpcPairRecommendationGet"] = Field(default="EpVpcPairRecommendationGet", description="Class name for backward compatibility") - - @property - def path(self) -> str: - """Build the endpoint path.""" - if self.fabric_name is None or self.switch_id is None: - raise ValueError("fabric_name and switch_id are required") - return VpcPairBasePath.vpc_pair_recommendation(self.fabric_name, self.switch_id) - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.GET - - -# ============================================================================ -# VPC Pair Consistency Endpoints (/vpcPairConsistency) -# ============================================================================ - - -class EpVpcPairConsistencyGet( - FabricNameMixin, - SwitchIdMixin, - FromClusterMixin, - NDEndpointBaseModel, -): - """ - # Summary - - VPC Pair Consistency GET Endpoint - - ## Description - - Endpoint to retrieve VPC pair consistency details between peers. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency - - ## Verb - - - GET - - ## Usage - - ```python - # Get consistency details - endpoint = EpVpcPairConsistencyGet() - endpoint.fabric_name = "Fabric1" - endpoint.switch_id = "FDO23040Q85" - path = endpoint.path - verb = endpoint.verb - ``` - """ - - model_config = COMMON_CONFIG - 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") - class_name: Literal["EpVpcPairConsistencyGet"] = Field(default="EpVpcPairConsistencyGet", description="Class name for backward compatibility") - - @property - def path(self) -> str: - """Build the endpoint path.""" - if self.fabric_name is None or self.switch_id is None: - raise ValueError("fabric_name and switch_id are required") - return VpcPairBasePath.vpc_pair_consistency(self.fabric_name, self.switch_id) - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.GET - - -# ============================================================================ -# VPC Pairs List Endpoints (/vpcPairs) -# ============================================================================ - - -class EpVpcPairsListGet( - FabricNameMixin, - FromClusterMixin, - FilterMixin, - PaginationMixin, - SortMixin, - ViewMixin, - NDEndpointBaseModel, -): - """ - # Summary - - VPC Pairs List GET Endpoint - - ## Description - - Endpoint to list all VPC pairs for a specific fabric. - Supports filtering, pagination, and sorting. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/vpcPairs - - ## Verb - - - GET - - ## Gathered Parameters - - - filter: Optional filter expression - - max: Optional maximum number of results - - offset: Optional offset for pagination - - sort: Optional sort field - - view: Optional. Values: "intendedPairs" - - ## Usage - - ```python - # List VPC pairs with filtering - endpoint = EpVpcPairsListGet() - endpoint.fabric_name = "Fabric1" - endpoint.filter = "domainId:10" - endpoint.max = 50 - endpoint.offset = 0 - endpoint.sort = "switchName:asc" - endpoint.view = "intendedPairs" - path = endpoint.path - verb = endpoint.verb - ``` - """ - - model_config = COMMON_CONFIG - 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") - class_name: Literal["EpVpcPairsListGet"] = Field(default="EpVpcPairsListGet", description="Class name for backward compatibility") - - @property - def path(self) -> str: - """Build the endpoint path.""" - if self.fabric_name is None: - raise ValueError("fabric_name is required") - return VpcPairBasePath.vpc_pairs_list(self.fabric_name) - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.GET diff --git a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_module_model.py b/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_module_model.py deleted file mode 100644 index 23a78435..00000000 --- a/plugins/module_utils/endpoints/v1/manage_vpc_pair/vpc_pair_module_model.py +++ /dev/null @@ -1,157 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Sivakami S -# 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, ClassVar, Dict, List, Literal, Optional, Union - -try: - from ansible_collections.cisco.nd.plugins.models.base import NDVpcPairBaseModel as _VpcPairBaseModel -except ImportError: - from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( # type: ignore - BaseModel as _VpcPairBaseModel, - ) - -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - ConfigDict, - Field, - field_validator, - model_validator, -) - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( - VpcFieldNames, -) - -try: - from ansible_collections.cisco.nd.plugins.models.vpc_pair_models import ( - VpcPairDetailsDefault, - VpcPairDetailsCustom, - ) -except ImportError: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_schemas import ( - VpcPairDetailsDefault, - VpcPairDetailsCustom, - ) - - -class VpcPairModel(_VpcPairBaseModel): - """ - Pydantic model for nd_manage_vpc_pair input. - - Uses a composite identifier `(switch_id, peer_switch_id)` and module-oriented - defaults/validation behavior. - """ - - identifiers: ClassVar[List[str]] = ["switch_id", "peer_switch_id"] - identifier_strategy: ClassVar[Literal["composite"]] = "composite" - exclude_from_diff: ClassVar[List[str]] = [] - - model_config = ConfigDict( - str_strip_whitespace=True, - use_enum_values=True, - validate_assignment=True, - populate_by_name=True, - validate_by_alias=True, - validate_by_name=True, - extra="ignore", - ) - - switch_id: str = Field( - alias=VpcFieldNames.SWITCH_ID, - description="Peer-1 switch serial number", - min_length=3, - max_length=64, - ) - peer_switch_id: str = Field( - alias=VpcFieldNames.PEER_SWITCH_ID, - description="Peer-2 switch serial number", - min_length=3, - max_length=64, - ) - use_virtual_peer_link: bool = Field( - default=True, - alias=VpcFieldNames.USE_VIRTUAL_PEER_LINK, - description="Virtual peer link enabled", - ) - vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( - default=None, - discriminator="type", - alias=VpcFieldNames.VPC_PAIR_DETAILS, - description="VPC pair configuration details (default or custom template)", - ) - - @field_validator("switch_id", "peer_switch_id") - @classmethod - def validate_switch_id_format(cls, v: str) -> str: - if not v or not v.strip(): - raise ValueError("Switch ID cannot be empty or whitespace") - return v.strip() - - @model_validator(mode="after") - def validate_different_switches(self) -> "VpcPairModel": - if self.switch_id == self.peer_switch_id: - raise ValueError( - f"switch_id and peer_switch_id must be different: {self.switch_id}" - ) - return self - - def to_payload(self) -> Dict[str, Any]: - return self.model_dump(by_alias=True, exclude_none=True) - - def to_diff_dict(self) -> Dict[str, Any]: - return self.model_dump( - by_alias=True, - exclude_none=True, - exclude=set(self.exclude_from_diff), - ) - - def get_identifier_value(self): - return tuple(sorted([self.switch_id, self.peer_switch_id])) - - def to_config(self, **kwargs) -> Dict[str, Any]: - return self.model_dump(by_alias=False, exclude_none=True, **kwargs) - - @classmethod - def from_config(cls, ansible_config: Dict[str, Any]) -> "VpcPairModel": - data = dict(ansible_config or {}) - - # Accept both snake_case module input and API camelCase aliases. - if VpcFieldNames.SWITCH_ID not in data and "switch_id" in data: - data[VpcFieldNames.SWITCH_ID] = data.get("switch_id") - if VpcFieldNames.PEER_SWITCH_ID not in data and "peer_switch_id" in data: - data[VpcFieldNames.PEER_SWITCH_ID] = data.get("peer_switch_id") - if ( - VpcFieldNames.USE_VIRTUAL_PEER_LINK not in data - and "use_virtual_peer_link" in data - ): - data[VpcFieldNames.USE_VIRTUAL_PEER_LINK] = data.get("use_virtual_peer_link") - if VpcFieldNames.VPC_PAIR_DETAILS not in data and "vpc_pair_details" in data: - data[VpcFieldNames.VPC_PAIR_DETAILS] = data.get("vpc_pair_details") - - return cls.model_validate(data, by_alias=True, by_name=True) - - def merge(self, other_model: "VpcPairModel") -> "VpcPairModel": - if not isinstance(other_model, type(self)): - raise TypeError( - "VpcPairModel.merge requires both models to be the same type" - ) - - for field, value in other_model: - if value is None: - continue - setattr(self, field, value) - return self - - @classmethod - def from_response(cls, response: Dict[str, Any]) -> "VpcPairModel": - data = { - VpcFieldNames.SWITCH_ID: response.get(VpcFieldNames.SWITCH_ID), - VpcFieldNames.PEER_SWITCH_ID: response.get(VpcFieldNames.PEER_SWITCH_ID), - VpcFieldNames.USE_VIRTUAL_PEER_LINK: response.get( - VpcFieldNames.USE_VIRTUAL_PEER_LINK, True - ), - } - return cls.model_validate(data) From fe6e66405e1f180ed5abdcc850e3903946de9429 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 18 Mar 2026 21:38:13 +0530 Subject: [PATCH 29/39] Interim changes to move across folders --- ...py => manage_fabrics_switches_vpc_pair.py} | 0 ..._fabrics_switches_vpc_pair_consistency.py} | 0 ...age_fabrics_switches_vpc_pair_overview.py} | 0 ...brics_switches_vpc_pair_recommendation.py} | 0 ...nage_fabrics_switches_vpc_pair_support.py} | 0 ...c_pairs.py => manage_fabrics_vpc_pairs.py} | 0 .../nd_manage_vpc_pair_actions.py | 12 +-- .../module_utils/nd_manage_vpc_pair_common.py | 6 +- .../module_utils/nd_manage_vpc_pair_deploy.py | 3 +- .../module_utils/nd_manage_vpc_pair_query.py | 7 +- .../module_utils/nd_manage_vpc_pair_runner.py | 3 +- .../nd_manage_vpc_pair_validation.py | 14 +-- .../orchestrators/nd_vpc_pair_orchestrator.py | 91 +------------------ plugins/modules/nd_manage_vpc_pair.py | 45 ++++++++- 14 files changed, 65 insertions(+), 116 deletions(-) rename plugins/module_utils/endpoints/v1/manage/{vpc_pair.py => manage_fabrics_switches_vpc_pair.py} (100%) rename plugins/module_utils/endpoints/v1/manage/{vpc_pair_consistency.py => manage_fabrics_switches_vpc_pair_consistency.py} (100%) rename plugins/module_utils/endpoints/v1/manage/{vpc_pair_overview.py => manage_fabrics_switches_vpc_pair_overview.py} (100%) rename plugins/module_utils/endpoints/v1/manage/{vpc_pair_recommendation.py => manage_fabrics_switches_vpc_pair_recommendation.py} (100%) rename plugins/module_utils/endpoints/v1/manage/{vpc_pair_support.py => manage_fabrics_switches_vpc_pair_support.py} (100%) rename plugins/module_utils/endpoints/v1/manage/{vpc_pairs.py => manage_fabrics_vpc_pairs.py} (100%) diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py similarity index 100% rename from plugins/module_utils/endpoints/v1/manage/vpc_pair.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_consistency.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py similarity index 100% rename from plugins/module_utils/endpoints/v1/manage/vpc_pair_consistency.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_overview.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py similarity index 100% rename from plugins/module_utils/endpoints/v1/manage/vpc_pair_overview.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_recommendation.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py similarity index 100% rename from plugins/module_utils/endpoints/v1/manage/vpc_pair_recommendation.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_support.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py similarity index 100% rename from plugins/module_utils/endpoints/v1/manage/vpc_pair_support.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pairs.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py similarity index 100% rename from plugins/module_utils/endpoints/v1/manage/vpc_pairs.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py diff --git a/plugins/module_utils/nd_manage_vpc_pair_actions.py b/plugins/module_utils/nd_manage_vpc_pair_actions.py index 668de805..df85b78c 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_actions.py +++ b/plugins/module_utils/nd_manage_vpc_pair_actions.py @@ -1,14 +1,13 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Sivakami S +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com # 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, Dict, Optional from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( ComponentTypeSupportEnum, VpcActionEnum, VpcFieldNames, @@ -24,13 +23,13 @@ _validate_switches_exist_in_fabric, _validate_vpc_pair_deletion, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_resources import ( +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( VpcPairResourceError, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_endpoints import ( VpcPairEndpoints, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_payloads import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_payloads import ( _build_vpc_pair_payload, _get_api_field_value, ) @@ -460,4 +459,3 @@ def custom_vpc_delete(nrm) -> None: path=path, exception_type=type(e).__name__ ) - diff --git a/plugins/module_utils/nd_manage_vpc_pair_common.py b/plugins/module_utils/nd_manage_vpc_pair_common.py index a1d51b62..6bd45326 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_common.py +++ b/plugins/module_utils/nd_manage_vpc_pair_common.py @@ -1,14 +1,13 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Sivakami S +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com # 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 traceback from typing import Any, Dict, List -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_resources import ( +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( VpcPairResourceError, ) @@ -81,4 +80,3 @@ def _is_update_needed(want: Dict[str, Any], have: Dict[str, Any]) -> bool: # Fallback to simple comparison if DeepDiff fails return want != have - diff --git a/plugins/module_utils/nd_manage_vpc_pair_deploy.py b/plugins/module_utils/nd_manage_vpc_pair_deploy.py index 7c90d302..64d7898b 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_deploy.py +++ b/plugins/module_utils/nd_manage_vpc_pair_deploy.py @@ -11,7 +11,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( _raise_vpc_error, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_endpoints import ( VpcPairEndpoints, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( @@ -222,4 +222,3 @@ def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: results.build_final_result() return results.final_result - diff --git a/plugins/module_utils/nd_manage_vpc_pair_query.py b/plugins/module_utils/nd_manage_vpc_pair_query.py index 0cd23c98..73a41a31 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_query.py +++ b/plugins/module_utils/nd_manage_vpc_pair_query.py @@ -8,17 +8,17 @@ from typing import Any, Dict, List, Optional from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( VpcFieldNames, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_validation import ( _is_switch_in_vpc_pair, _validate_fabric_switches, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_endpoints import ( VpcPairEndpoints, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_payloads import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_payloads import ( _get_api_field_value, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( @@ -673,4 +673,3 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic exception_type=type(e).__name__ ) - diff --git a/plugins/module_utils/nd_manage_vpc_pair_runner.py b/plugins/module_utils/nd_manage_vpc_pair_runner.py index 085f39d7..66448480 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_runner.py +++ b/plugins/module_utils/nd_manage_vpc_pair_runner.py @@ -7,7 +7,7 @@ from typing import Any, Dict -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( VpcFieldNames, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( @@ -96,4 +96,3 @@ def run_vpc_module(nrm) -> Dict[str, Any]: # ===== Module Entry Point ===== - diff --git a/plugins/module_utils/nd_manage_vpc_pair_validation.py b/plugins/module_utils/nd_manage_vpc_pair_validation.py index af1a9d75..829d5dad 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_validation.py +++ b/plugins/module_utils/nd_manage_vpc_pair_validation.py @@ -1,24 +1,26 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Sivakami S +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com # 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, Dict, List, Optional from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( ComponentTypeSupportEnum, VpcFieldNames, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( _raise_vpc_error, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_endpoints import ( +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( + VpcPairResourceError, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_endpoints import ( VpcPairEndpoints, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_runtime_payloads import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_payloads import ( _get_api_field_value, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModuleError @@ -600,5 +602,3 @@ def _validate_vpc_pair_deletion(nd_v2, fabric_name: str, switch_id: str, vpc_pai # ===== Custom Action Functions (used by VpcPairResourceService via orchestrator) ===== - - diff --git a/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py b/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py index 18640228..047dbc46 100644 --- a/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py +++ b/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py @@ -1,91 +1,10 @@ # -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Sivakami S +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) - from __future__ import absolute_import, division, print_function -__metaclass__ = type - -from typing import Any, Optional -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_module_model import ( - VpcPairModel, -) -from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_actions import ( - custom_vpc_create, - custom_vpc_delete, - custom_vpc_update, -) -from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_query import ( - custom_vpc_query_all, +# Backward-compatible import path for callers still using nd_ prefix. +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_pair import ( # noqa: F401 + VpcPairOrchestrator, ) - -from ansible.module_utils.basic import AnsibleModule - - -class _VpcPairQueryContext: - """Minimal context object for query_all during NDStateMachine initialization.""" - - def __init__(self, module: AnsibleModule): - self.module = module - - -class VpcPairOrchestrator: - """ - VPC orchestrator implementation for NDStateMachine. - - Delegates CRUD operations to injected vPC action handlers. - """ - - model_class = VpcPairModel - - def __init__( - self, - module: Optional[AnsibleModule] = None, - sender: Optional[Any] = None, - **kwargs, - ): - _ = kwargs - # Compatibility with both NDStateMachine variants: - # - legacy: model_orchestrator(module=...) - # - Current: model_orchestrator(sender=nd_module) - if module is None and sender is not None: - module = getattr(sender, "module", None) - if module is None: - raise ValueError( - "VpcPairOrchestrator requires either module=AnsibleModule " - "or sender=." - ) - - self.module = module - self.sender = sender - self.state_machine = None - - def bind_state_machine(self, state_machine: Any) -> None: - self.state_machine = state_machine - - def query_all(self): - context = ( - self.state_machine - if self.state_machine is not None - else _VpcPairQueryContext(self.module) - ) - return custom_vpc_query_all(context) - - def create(self, model_instance, **kwargs): - _ = (model_instance, kwargs) - if self.state_machine is None: - raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") - return custom_vpc_create(self.state_machine) - - def update(self, model_instance, **kwargs): - _ = (model_instance, kwargs) - if self.state_machine is None: - raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") - return custom_vpc_update(self.state_machine) - - def delete(self, model_instance, **kwargs): - _ = (model_instance, kwargs) - if self.state_machine is None: - raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") - return custom_vpc_delete(self.state_machine) diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index d1e954db..e33c8f82 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Sivakami S +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com # 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 __copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." @@ -68,6 +67,17 @@ - Lower timeout for non-critical queries to avoid port exhaustion. type: int default: 10 + refresh_after_apply: + description: + - Query controller again after write operations to populate final C(after) state. + - Disable for faster execution when eventual consistency is acceptable. + type: bool + default: true + refresh_after_timeout: + description: + - Optional timeout in seconds for the post-apply refresh query. + - When omitted, C(query_timeout) is used. + type: int config: description: - List of vPC pair configuration dictionaries. @@ -205,6 +215,21 @@ description: Request payload sent to API type: dict sample: [{"operation": "PUT", "vpc_pair_key": "FDO123-FDO456", "path": "/api/v1/...", "payload": {}}] +created: + description: List of created object identifiers + type: list + returned: always + sample: [["FDO123", "FDO456"]] +deleted: + description: List of deleted object identifiers + type: list + returned: always + sample: [["FDO123", "FDO456"]] +updated: + description: List of updated object identifiers and changed properties + type: list + returned: always + sample: [{"identifier": ["FDO123", "FDO456"], "changed_properties": ["useVirtualPeerLink"]}] metadata: description: Operation metadata with sequence and identifiers type: dict @@ -273,8 +298,10 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging # Service layer imports -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_resources import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_resources import ( VpcPairResourceService, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( VpcPairResourceError, ) @@ -287,7 +314,7 @@ _nd_config_collection = None # noqa: F841 _nd_utils = None # noqa: F841 -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( VpcFieldNames, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( @@ -339,6 +366,16 @@ def main(): default=10, description="API request timeout in seconds for query/recommendation operations" ), + refresh_after_apply=dict( + type="bool", + default=True, + description="Refresh final after-state by querying controller after write operations", + ), + refresh_after_timeout=dict( + type="int", + required=False, + description="Optional timeout in seconds for post-apply after-state refresh query", + ), config=dict( type="list", elements="dict", From 90b2a1b57b0566d01f7e26f01dc42cf8f217976a Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 18 Mar 2026 22:14:20 +0530 Subject: [PATCH 30/39] Renamed ep names and corresponding imports, info on paths --- .../endpoints/v1/manage/__init__.py | 12 +++---- .../manage_fabrics_switches_vpc_pair.py | 18 +++++++--- ...e_fabrics_switches_vpc_pair_consistency.py | 14 +++++--- ...nage_fabrics_switches_vpc_pair_overview.py | 14 +++++--- ...abrics_switches_vpc_pair_recommendation.py | 14 +++++--- ...anage_fabrics_switches_vpc_pair_support.py | 14 +++++--- .../v1/manage/manage_fabrics_vpc_pairs.py | 12 +++++-- .../v1/manage/vpc_pair_base_paths.py | 8 +++++ .../endpoints/v1/manage/vpc_pair_endpoints.py | 24 +++++++++---- .../endpoints/v1/manage/vpc_pair_enums.py | 4 +++ .../v1/manage/vpc_pair_module_model.py | 8 +++++ .../endpoints/v1/manage/vpc_pair_resources.py | 8 +++++ .../v1/manage/vpc_pair_runtime_endpoints.py | 36 ++++++++++++------- .../v1/manage/vpc_pair_runtime_payloads.py | 8 +++++ .../endpoints/v1/manage/vpc_pair_schemas.py | 4 +++ 15 files changed, 146 insertions(+), 52 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage/__init__.py b/plugins/module_utils/endpoints/v1/manage/__init__.py index 9838a1ea..37564ff7 100644 --- a/plugins/module_utils/endpoints/v1/manage/__init__.py +++ b/plugins/module_utils/endpoints/v1/manage/__init__.py @@ -1,22 +1,22 @@ from __future__ import absolute_import, division, print_function -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair import ( EpVpcPairGet, EpVpcPairPut, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_support import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_support import ( EpVpcPairSupportGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_overview import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_overview import ( EpVpcPairOverviewGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_recommendation import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_recommendation import ( EpVpcPairRecommendationGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_consistency import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_consistency import ( EpVpcPairConsistencyGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pairs import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( EpVpcPairsListGet, ) diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py index 11f103f4..65333b1d 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py @@ -19,11 +19,13 @@ SwitchIdMixin, TicketIdMixin, ) -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.vpc_pair_base_paths import ( + VpcPairBasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair COMMON_CONFIG = ConfigDict(validate_assignment=True) @@ -39,12 +41,14 @@ class _EpVpcPairBase( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return BasePath.path( - "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPair" - ) + return VpcPairBasePath.vpc_pair(self.fabric_name, self.switch_id) class EpVpcPairGet(_EpVpcPairBase): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + """ + api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") class_name: Literal["EpVpcPairGet"] = Field(default="EpVpcPairGet") @@ -55,6 +59,10 @@ def verb(self) -> HttpVerbEnum: class EpVpcPairPut(_EpVpcPairBase, TicketIdMixin): + """ + PUT /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + """ + api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") class_name: Literal["EpVpcPairPut"] = Field(default="EpVpcPairPut") diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py index c205cd98..ec436478 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py @@ -18,11 +18,13 @@ FromClusterMixin, SwitchIdMixin, ) -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.vpc_pair_base_paths import ( + VpcPairBasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency COMMON_CONFIG = ConfigDict(validate_assignment=True) @@ -32,6 +34,10 @@ class EpVpcPairConsistencyGet( FromClusterMixin, NDEndpointBaseModel, ): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency + """ + model_config = COMMON_CONFIG api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") @@ -41,9 +47,7 @@ class EpVpcPairConsistencyGet( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return BasePath.path( - "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairConsistency" - ) + return VpcPairBasePath.vpc_pair_consistency(self.fabric_name, self.switch_id) @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py index 193cb703..b4067765 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py @@ -19,11 +19,13 @@ FromClusterMixin, SwitchIdMixin, ) -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.vpc_pair_base_paths import ( + VpcPairBasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview COMMON_CONFIG = ConfigDict(validate_assignment=True) @@ -34,6 +36,10 @@ class EpVpcPairOverviewGet( ComponentTypeMixin, NDEndpointBaseModel, ): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview + """ + model_config = COMMON_CONFIG api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") @@ -43,9 +49,7 @@ class EpVpcPairOverviewGet( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return BasePath.path( - "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairOverview" - ) + return VpcPairBasePath.vpc_pair_overview(self.fabric_name, self.switch_id) @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py index ab5117ff..e2c44a83 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py @@ -19,11 +19,13 @@ SwitchIdMixin, UseVirtualPeerLinkMixin, ) -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.vpc_pair_base_paths import ( + VpcPairBasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation COMMON_CONFIG = ConfigDict(validate_assignment=True) @@ -34,6 +36,10 @@ class EpVpcPairRecommendationGet( UseVirtualPeerLinkMixin, NDEndpointBaseModel, ): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation + """ + model_config = COMMON_CONFIG api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") @@ -43,9 +49,7 @@ class EpVpcPairRecommendationGet( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return BasePath.path( - "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairRecommendation" - ) + return VpcPairBasePath.vpc_pair_recommendation(self.fabric_name, self.switch_id) @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py index ade16dfb..5b1ebb1e 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py @@ -19,11 +19,13 @@ FromClusterMixin, SwitchIdMixin, ) -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.vpc_pair_base_paths import ( + VpcPairBasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport COMMON_CONFIG = ConfigDict(validate_assignment=True) @@ -34,6 +36,10 @@ class EpVpcPairSupportGet( ComponentTypeMixin, NDEndpointBaseModel, ): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport + """ + model_config = COMMON_CONFIG api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") @@ -43,9 +49,7 @@ class EpVpcPairSupportGet( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return BasePath.path( - "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairSupport" - ) + return VpcPairBasePath.vpc_pair_support(self.fabric_name, self.switch_id) @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py index 54b693c8..42971b0a 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py @@ -21,11 +21,13 @@ SortMixin, ViewMixin, ) -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.vpc_pair_base_paths import ( + VpcPairBasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/vpcPairs COMMON_CONFIG = ConfigDict(validate_assignment=True) @@ -38,6 +40,10 @@ class EpVpcPairsListGet( ViewMixin, NDEndpointBaseModel, ): + """ + GET /api/v1/manage/fabrics/{fabricName}/vpcPairs + """ + model_config = COMMON_CONFIG api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") @@ -47,7 +53,7 @@ class EpVpcPairsListGet( def path(self) -> str: if self.fabric_name is None: raise ValueError("fabric_name is required") - return BasePath.path("fabrics", self.fabric_name, "vpcPairs") + return VpcPairBasePath.vpc_pairs_list(self.fabric_name) @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_base_paths.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_base_paths.py index ae165128..53644bde 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_base_paths.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_base_paths.py @@ -17,6 +17,14 @@ This module provides a single location to manage all VPC pair API base paths, allowing easy modification when API paths change. All endpoint classes should use these path builders for consistency. + +Path roots used by vPC endpoints: +- /api/v1/manage/fabrics/{fabricName}/vpcPairs +- /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair +- /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport +- /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview +- /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation +- /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency """ from __future__ import absolute_import, division, print_function diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py index acece926..272b2690 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py @@ -4,24 +4,36 @@ # 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 +""" +vPC endpoint export map. + +Class -> API path: +- EpVpcPairGet / EpVpcPairPut -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair +- EpVpcPairSupportGet -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport +- EpVpcPairOverviewGet -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview +- EpVpcPairRecommendationGet -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation +- EpVpcPairConsistencyGet -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency +- EpVpcPairsListGet -> /api/v1/manage/fabrics/{fabricName}/vpcPairs +""" + # Backward-compatible export surface for legacy imports. -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair import ( EpVpcPairGet, EpVpcPairPut, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_consistency import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_consistency import ( EpVpcPairConsistencyGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_overview import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_overview import ( EpVpcPairOverviewGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_recommendation import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_recommendation import ( EpVpcPairRecommendationGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_support import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_support import ( EpVpcPairSupportGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pairs import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( EpVpcPairsListGet, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_base_paths import ( diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_enums.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_enums.py index 304d1e0e..7a0826b6 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_enums.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_enums.py @@ -19,6 +19,10 @@ This module provides enumeration types used throughout the VPC pair management implementation. + +Note: +- This file does not define API paths. +- Endpoint path mappings are documented in `vpc_pair_endpoints.py`. """ from __future__ import absolute_import, division, print_function diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py index 7350e94d..221c2630 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py @@ -4,6 +4,14 @@ # 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 +""" +Compatibility import shim for vPC module model. + +Note: +- This file has no endpoint path definitions. +- It only re-exports `VpcPairModel`. +""" + # Backward-compatible import path for callers still using the legacy location. from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( # noqa: F401 VpcPairModel, diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py index b50c735b..64eeb255 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py @@ -21,6 +21,14 @@ VpcPairResourceError, ) +""" +State-machine resource service for nd_manage_vpc_pair. + +Note: +- This file does not define endpoint paths directly. +- Runtime endpoint path usage is centralized in `vpc_pair_runtime_endpoints.py`. +""" + RunStateHandler = Callable[[Any], Dict[str, Any]] DeployHandler = Callable[[Any, str, Dict[str, Any]], Dict[str, Any]] diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py index 2073932c..06646587 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py @@ -13,27 +13,27 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( ComponentTypeSupportEnum, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair import ( EpVpcPairGet, EpVpcPairPut, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_consistency import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_consistency import ( EpVpcPairConsistencyGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_overview import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_overview import ( EpVpcPairOverviewGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_recommendation import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_recommendation import ( EpVpcPairRecommendationGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_support import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_support import ( EpVpcPairSupportGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pairs import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( EpVpcPairsListGet, ) -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.vpc_pair_base_paths import ( + VpcPairBasePath, ) @@ -50,7 +50,19 @@ class _ForceShowRunQueryParams(EndpointQueryParams): class VpcPairEndpoints: - """Centralized endpoint builders for vPC pair runtime operations.""" + """ + Centralized endpoint builders for vPC pair runtime operations. + + Runtime helper -> API path: + - vpc_pairs_list/vpc_pair_base -> /api/v1/manage/fabrics/{fabricName}/vpcPairs + - switch_vpc_pair/vpc_pair_put -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + - switch_vpc_support -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport + - switch_vpc_overview -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview + - switch_vpc_recommendations -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation + - switch_vpc_consistency -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency + - fabric_config_save -> /api/v1/manage/fabrics/{fabricName}/actions/configSave + - fabric_config_deploy -> /api/v1/manage/fabrics/{fabricName}/actions/deploy + """ NDFC_BASE = "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest" MANAGE_BASE = "/api/v1/manage" @@ -90,7 +102,7 @@ def vpc_pair_put(fabric_name: str, switch_id: str) -> str: @staticmethod def fabric_switches(fabric_name: str) -> str: - return BasePath.path("fabrics", fabric_name, "switches") + return VpcPairBasePath.fabrics(fabric_name, "switches") @staticmethod def switch_vpc_pair(fabric_name: str, switch_id: str) -> str: @@ -131,11 +143,11 @@ def switch_vpc_consistency(fabric_name: str, switch_id: str) -> str: @staticmethod def fabric_config_save(fabric_name: str) -> str: - return BasePath.path("fabrics", fabric_name, "actions", "configSave") + return VpcPairBasePath.fabrics(fabric_name, "actions", "configSave") @staticmethod def fabric_config_deploy(fabric_name: str, force_show_run: bool = True) -> str: - base_path = BasePath.path("fabrics", fabric_name, "actions", "deploy") + base_path = VpcPairBasePath.fabrics(fabric_name, "actions", "deploy") query_params = _ForceShowRunQueryParams( force_show_run=True if force_show_run else None ) diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_payloads.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_payloads.py index 6d9e6db3..149b1de3 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_payloads.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_payloads.py @@ -12,6 +12,14 @@ VpcFieldNames, ) +""" +Payload helpers for vPC runtime operations. + +Note: +- This file builds request/response payload structures only. +- Endpoint paths are resolved in `vpc_pair_runtime_endpoints.py`. +""" + def _get_template_config(vpc_pair_model) -> Optional[Dict[str, Any]]: """Extract template configuration from a vPC pair model if present.""" diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py index ca3c5981..815ae1c7 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py @@ -11,6 +11,10 @@ Primary source of truth lives in `plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py`. This module also provides local fallback models for AnsiballZ runtimes where `module_utils/models/manage_vpc_pair` files may not be packaged. + +Note: +- This file defines schema/model types, not endpoint paths. +- Endpoint path mappings are documented in `vpc_pair_endpoints.py`. """ from typing import Any, Dict, List, Optional, Literal, Annotated From 30ad3b9c7f6fb0b9404013c9f98e2337d91b57ca Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 18 Mar 2026 22:27:58 +0530 Subject: [PATCH 31/39] Interim changes --- .../endpoints/v1/manage/__init__.py | 12 +++++++ .../endpoints/v1/manage/vpc_pair_resources.py | 2 +- .../v1/manage/vpc_pair_runtime_endpoints.py | 32 ++++++++----------- plugins/module_utils/models/__init__.py | 4 --- .../models/manage_vpc_pair/model.py | 4 +-- .../models/manage_vpc_pair/vpc_pair_models.py | 2 +- .../orchestrators/nd_vpc_pair_orchestrator.py | 4 ++- plugins/modules/nd_manage_vpc_pair.py | 29 +++-------------- .../tests/nd/nd_vpc_pair_gather.yaml | 13 ++++---- .../tests/nd/nd_vpc_pair_merge.yaml | 14 ++++---- 10 files changed, 50 insertions(+), 66 deletions(-) delete mode 100644 plugins/module_utils/models/__init__.py diff --git a/plugins/module_utils/endpoints/v1/manage/__init__.py b/plugins/module_utils/endpoints/v1/manage/__init__.py index 37564ff7..1683dfbd 100644 --- a/plugins/module_utils/endpoints/v1/manage/__init__.py +++ b/plugins/module_utils/endpoints/v1/manage/__init__.py @@ -19,6 +19,15 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( EpVpcPairsListGet, ) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches import ( + EpFabricSwitchesGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions_config_save import ( + EpFabricConfigSavePost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions_deploy import ( + EpFabricDeployPost, +) __all__ = [ "EpVpcPairGet", @@ -28,4 +37,7 @@ "EpVpcPairRecommendationGet", "EpVpcPairConsistencyGet", "EpVpcPairsListGet", + "EpFabricSwitchesGet", + "EpFabricConfigSavePost", + "EpFabricDeployPost", ] diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py index 64eeb255..b25e9d5c 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py @@ -11,7 +11,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import ( NDStateMachine, ) -from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_pair import ( +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vpc_pair import ( VpcPairOrchestrator, ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py index 06646587..1134fa60 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py +++ b/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py @@ -17,6 +17,9 @@ EpVpcPairGet, EpVpcPairPut, ) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches import ( + EpFabricSwitchesGet, +) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_consistency import ( EpVpcPairConsistencyGet, ) @@ -32,8 +35,11 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( EpVpcPairsListGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_base_paths import ( - VpcPairBasePath, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions_config_save import ( + EpFabricConfigSavePost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions_deploy import ( + EpFabricDeployPost, ) @@ -64,19 +70,6 @@ class VpcPairEndpoints: - fabric_config_deploy -> /api/v1/manage/fabrics/{fabricName}/actions/deploy """ - NDFC_BASE = "/appcenter/cisco/ndfc/api/v1/lan-fabric/rest" - MANAGE_BASE = "/api/v1/manage" - VPC_PAIR_BASE = f"{NDFC_BASE}/vpcpair/fabrics/{{fabric_name}}" - VPC_PAIR_SWITCH = f"{NDFC_BASE}/vpcpair/fabrics/{{fabric_name}}/switches/{{switch_id}}" - FABRIC_CONFIG_SAVE = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/actions/configSave" - FABRIC_CONFIG_DEPLOY = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/actions/deploy" - FABRIC_SWITCHES = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches" - SWITCH_VPC_PAIR = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPair" - SWITCH_VPC_RECOMMENDATIONS = ( - f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPairRecommendation" - ) - SWITCH_VPC_OVERVIEW = f"{MANAGE_BASE}/fabrics/{{fabric_name}}/switches/{{switch_id}}/vpcPairOverview" - @staticmethod def _append_query(path: str, *query_groups: EndpointQueryParams) -> str: composite_params = CompositeQueryParams() @@ -102,7 +95,8 @@ def vpc_pair_put(fabric_name: str, switch_id: str) -> str: @staticmethod def fabric_switches(fabric_name: str) -> str: - return VpcPairBasePath.fabrics(fabric_name, "switches") + endpoint = EpFabricSwitchesGet(fabric_name=fabric_name) + return endpoint.path @staticmethod def switch_vpc_pair(fabric_name: str, switch_id: str) -> str: @@ -143,11 +137,13 @@ def switch_vpc_consistency(fabric_name: str, switch_id: str) -> str: @staticmethod def fabric_config_save(fabric_name: str) -> str: - return VpcPairBasePath.fabrics(fabric_name, "actions", "configSave") + endpoint = EpFabricConfigSavePost(fabric_name=fabric_name) + return endpoint.path @staticmethod def fabric_config_deploy(fabric_name: str, force_show_run: bool = True) -> str: - base_path = VpcPairBasePath.fabrics(fabric_name, "actions", "deploy") + endpoint = EpFabricDeployPost(fabric_name=fabric_name) + base_path = endpoint.path query_params = _ForceShowRunQueryParams( force_show_run=True if force_show_run else None ) diff --git a/plugins/module_utils/models/__init__.py b/plugins/module_utils/models/__init__.py deleted file mode 100644 index 7b839f18..00000000 --- a/plugins/module_utils/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .base import NDBaseModel -from .nested import NDNestedModel - -__all__ = ["NDBaseModel", "NDNestedModel"] diff --git a/plugins/module_utils/models/manage_vpc_pair/model.py b/plugins/module_utils/models/manage_vpc_pair/model.py index 275052c5..f843b721 100644 --- a/plugins/module_utils/models/manage_vpc_pair/model.py +++ b/plugins/module_utils/models/manage_vpc_pair/model.py @@ -22,7 +22,7 @@ field_validator, model_validator, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( VpcFieldNames, ) @@ -32,7 +32,7 @@ VpcPairDetailsCustom, ) except ImportError: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.vpc_pair_schemas import ( + from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_schemas import ( VpcPairDetailsDefault, VpcPairDetailsCustom, ) diff --git a/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py b/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py index 6b2eea32..0d410585 100644 --- a/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py +++ b/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py @@ -38,7 +38,7 @@ ) # Import enums from centralized location -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage_vpc_pair.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( VpcActionEnum, VpcPairTypeEnum, KeepAliveVrfEnum, diff --git a/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py b/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py index 047dbc46..d510f2a4 100644 --- a/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py +++ b/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py @@ -5,6 +5,8 @@ from __future__ import absolute_import, division, print_function # Backward-compatible import path for callers still using nd_ prefix. -from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.vpc_pair import ( # noqa: F401 +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vpc_pair import ( VpcPairOrchestrator, ) + +__all__ = ("VpcPairOrchestrator",) diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index e33c8f82..27e602d2 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -41,12 +41,6 @@ - Saves fabric configuration and triggers deployment. type: bool default: false - dry_run: - description: - - Show what changes would be made without executing them. - - Maps to Ansible check_mode internally. - type: bool - default: false force: description: - Force deletion without pre-deletion validation checks. @@ -142,15 +136,15 @@ - peer1_switch_id: "FDO23040Q85" peer2_switch_id: "FDO23040Q86" -# Dry run to see what would change -- name: Dry run vPC pair creation +# Native Ansible check mode (dry-run behavior) +- name: Check mode vPC pair creation cisco.nd.nd_manage_vpc_pair: fabric_name: myFabric state: merged - dry_run: true config: - peer1_switch_id: "FDO23040Q85" peer2_switch_id: "FDO23040Q86" + check_mode: true """ RETURN = """ @@ -292,8 +286,6 @@ sample: [] """ -import sys - from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging @@ -350,7 +342,6 @@ def main(): ), fabric_name=dict(type="str", required=True), deploy=dict(type="bool", default=False), - dry_run=dict(type="bool", default=False), force=dict( type="bool", default=False, @@ -391,10 +382,6 @@ def main(): module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) setup_logging(module) - # Module-level validations - if sys.version_info < (3, 9): - module.fail_json(msg="Python version 3.9 or higher is required for this module.") - if not HAS_DEEPDIFF: module.fail_json( msg=missing_required_lib("deepdiff"), @@ -402,20 +389,12 @@ def main(): ) # State-specific parameter validations - state = module.params.get("state", "merged") + state = module.params["state"] deploy = module.params.get("deploy") - dry_run = module.params.get("dry_run") if state == "gathered" and deploy: module.fail_json(msg="Deploy parameter cannot be used with 'gathered' state") - if state == "gathered" and dry_run: - module.fail_json(msg="Dry_run parameter cannot be used with 'gathered' state") - - # Map dry_run to check_mode - if dry_run: - module.check_mode = True - # Validate force parameter usage: # - state=deleted # - state=overridden with empty config (interpreted as delete-all) diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml index 45758a70..8c4bd68c 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml @@ -184,21 +184,20 @@ - result.msg is search("Deploy parameter cannot be used") tags: gather -# TC9 - gathered + dry_run validation (must fail) -- name: GATHER - TC9 - GATHER - Gather with dry_run enabled (invalid) +# TC9 - gathered with native check_mode should succeed +- name: GATHER - TC9 - GATHER - Gather with check_mode enabled cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered - dry_run: true + check_mode: true register: result - ignore_errors: true tags: gather -- name: GATHER - TC9 - ASSERT - Verify gathered+dry_run validation +- name: GATHER - TC9 - ASSERT - Verify gathered+check_mode behavior ansible.builtin.assert: that: - - result.failed == true - - result.msg is search("Dry_run parameter cannot be used") + - result.failed == false + - result.gathered is defined tags: gather # TC10 - Validate /vpcPairs list API alignment with module gathered output diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml index e9a8ae58..1748ddf2 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml +++ b/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml @@ -540,8 +540,8 @@ - result.failed == false tags: merge -# TC12 - dry_run should not apply configuration changes -- name: MERGE - TC12 - DELETE - Ensure vPC pair is absent before dry_run test +# TC12 - check_mode should not apply configuration changes +- name: MERGE - TC12 - DELETE - Ensure vPC pair is absent before check_mode test cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted @@ -551,22 +551,22 @@ ignore_errors: true tags: merge -- name: MERGE - TC12 - MERGE - Run dry_run create for vPC pair +- name: MERGE - TC12 - MERGE - Run check_mode create for vPC pair cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: merged - dry_run: true config: "{{ nd_vpc_pair_merge_full_conf }}" + check_mode: true register: result tags: merge -- name: MERGE - TC12 - ASSERT - Verify dry_run invocation succeeded +- name: MERGE - TC12 - ASSERT - Verify check_mode invocation succeeded ansible.builtin.assert: that: - result.failed == false tags: merge -- name: MERGE - TC12 - GATHER - Verify dry_run did not create vPC pair +- name: MERGE - TC12 - GATHER - Verify check_mode did not create vPC pair cisco.nd.nd_manage_vpc_pair: state: gathered fabric_name: "{{ test_fabric }}" @@ -576,7 +576,7 @@ register: verify_result tags: merge -- name: MERGE - TC12 - VALIDATE - Confirm no persistent changes from dry_run +- name: MERGE - TC12 - VALIDATE - Confirm no persistent changes from check_mode cisco.nd.tests.integration.nd_vpc_pair_validate: gathered_data: "{{ verify_result }}" expected_data: [] From 6ca4b7f21d3be1f6476181f710fffac6421d8c9b Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 19 Mar 2026 20:07:17 +0530 Subject: [PATCH 32/39] Adhereing to a common standard --- .../manage_fabrics_actions_config_save.py | 55 +++ .../manage/manage_fabrics_actions_deploy.py | 55 +++ .../v1/manage/manage_fabrics_switches.py | 63 ++++ .../manage_fabrics_switches_vpc_pair.py | 12 +- ...e_fabrics_switches_vpc_pair_consistency.py | 12 +- ...nage_fabrics_switches_vpc_pair_overview.py | 12 +- ...abrics_switches_vpc_pair_recommendation.py | 12 +- ...anage_fabrics_switches_vpc_pair_support.py | 12 +- .../v1/manage/manage_fabrics_vpc_pairs.py | 6 +- .../v1/manage/vpc_pair_base_paths.py | 331 ------------------ .../endpoints/v1/manage/vpc_pair_endpoints.py | 52 --- .../v1/manage/vpc_pair_module_model.py | 18 - .../endpoints/v1/manage/vpc_pair_schemas.py | 127 ------- .../module_utils/manage_vpc_pair/__init__.py | 33 ++ .../enums.py} | 3 +- .../resources.py} | 7 +- .../runtime_endpoints.py} | 2 +- .../runtime_payloads.py} | 2 +- .../models/manage_vpc_pair/model.py | 16 +- .../models/manage_vpc_pair/vpc_pair_models.py | 2 +- .../nd_manage_vpc_pair_actions.py | 6 +- .../module_utils/nd_manage_vpc_pair_deploy.py | 3 +- .../module_utils/nd_manage_vpc_pair_query.py | 7 +- .../module_utils/nd_manage_vpc_pair_runner.py | 3 +- .../nd_manage_vpc_pair_validation.py | 6 +- .../orchestrators/manage_vpc_pair.py | 91 +++++ .../orchestrators/nd_vpc_pair_orchestrator.py | 12 - plugins/modules/nd_manage_vpc_pair.py | 93 ++++- 28 files changed, 459 insertions(+), 594 deletions(-) create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_config_save.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_deploy.py create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py delete mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair_base_paths.py delete mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py delete mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py delete mode 100644 plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py create mode 100644 plugins/module_utils/manage_vpc_pair/__init__.py rename plugins/module_utils/{endpoints/v1/manage/vpc_pair_enums.py => manage_vpc_pair/enums.py} (98%) rename plugins/module_utils/{endpoints/v1/manage/vpc_pair_resources.py => manage_vpc_pair/resources.py} (98%) rename plugins/module_utils/{endpoints/v1/manage/vpc_pair_runtime_endpoints.py => manage_vpc_pair/runtime_endpoints.py} (98%) rename plugins/module_utils/{endpoints/v1/manage/vpc_pair_runtime_payloads.py => manage_vpc_pair/runtime_payloads.py} (96%) create mode 100644 plugins/module_utils/orchestrators/manage_vpc_pair.py delete mode 100644 plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_config_save.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_config_save.py new file mode 100644 index 00000000..d4cc68e6 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_config_save.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 Literal + +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, + FromClusterMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/actions/configSave +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpFabricConfigSavePost( + FabricNameMixin, + FromClusterMixin, + NDEndpointBaseModel, +): + """ + POST /api/v1/manage/fabrics/{fabricName}/actions/configSave + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpFabricConfigSavePost"] = Field(default="EpFabricConfigSavePost") + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError("fabric_name is required") + return BasePath.path("fabrics", self.fabric_name, "actions", "configSave") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +__all__ = ["EpFabricConfigSavePost"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_deploy.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_deploy.py new file mode 100644 index 00000000..4cb0bed7 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions_deploy.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 Literal + +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, + FromClusterMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/actions/deploy +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpFabricDeployPost( + FabricNameMixin, + FromClusterMixin, + NDEndpointBaseModel, +): + """ + POST /api/v1/manage/fabrics/{fabricName}/actions/deploy + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpFabricDeployPost"] = Field(default="EpFabricDeployPost") + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError("fabric_name is required") + return BasePath.path("fabrics", self.fabric_name, "actions", "deploy") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +__all__ = ["EpFabricDeployPost"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py new file mode 100644 index 00000000..8c309f6a --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 Literal + +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, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, + FilterMixin, + FromClusterMixin, + PaginationMixin, + SortMixin, + ViewMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +# API path covered by this file: +# /api/v1/manage/fabrics/{fabricName}/switches +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class EpFabricSwitchesGet( + FabricNameMixin, + FromClusterMixin, + FilterMixin, + PaginationMixin, + SortMixin, + ViewMixin, + NDEndpointBaseModel, +): + """ + GET /api/v1/manage/fabrics/{fabricName}/switches + """ + + model_config = COMMON_CONFIG + api_version: Literal["v1"] = Field(default="v1") + min_controller_version: str = Field(default="3.0.0") + class_name: Literal["EpFabricSwitchesGet"] = Field(default="EpFabricSwitchesGet") + + @property + def path(self) -> str: + if self.fabric_name is None: + raise ValueError("fabric_name is required") + return BasePath.path("fabrics", self.fabric_name, "switches") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET + + +__all__ = ["EpFabricSwitchesGet"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py index 65333b1d..2a11b488 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py @@ -19,8 +19,8 @@ SwitchIdMixin, TicketIdMixin, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_base_paths import ( - VpcPairBasePath, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum @@ -41,7 +41,13 @@ class _EpVpcPairBase( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return VpcPairBasePath.vpc_pair(self.fabric_name, self.switch_id) + return BasePath.path( + "fabrics", + self.fabric_name, + "switches", + self.switch_id, + "vpcPair", + ) class EpVpcPairGet(_EpVpcPairBase): diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py index ec436478..8dcb78e6 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py @@ -18,8 +18,8 @@ FromClusterMixin, SwitchIdMixin, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_base_paths import ( - VpcPairBasePath, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum @@ -47,7 +47,13 @@ class EpVpcPairConsistencyGet( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return VpcPairBasePath.vpc_pair_consistency(self.fabric_name, self.switch_id) + return BasePath.path( + "fabrics", + self.fabric_name, + "switches", + self.switch_id, + "vpcPairConsistency", + ) @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py index b4067765..85137ffd 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py @@ -19,8 +19,8 @@ FromClusterMixin, SwitchIdMixin, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_base_paths import ( - VpcPairBasePath, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum @@ -49,7 +49,13 @@ class EpVpcPairOverviewGet( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return VpcPairBasePath.vpc_pair_overview(self.fabric_name, self.switch_id) + return BasePath.path( + "fabrics", + self.fabric_name, + "switches", + self.switch_id, + "vpcPairOverview", + ) @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py index e2c44a83..cd340804 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py @@ -19,8 +19,8 @@ SwitchIdMixin, UseVirtualPeerLinkMixin, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_base_paths import ( - VpcPairBasePath, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum @@ -49,7 +49,13 @@ class EpVpcPairRecommendationGet( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return VpcPairBasePath.vpc_pair_recommendation(self.fabric_name, self.switch_id) + return BasePath.path( + "fabrics", + self.fabric_name, + "switches", + self.switch_id, + "vpcPairRecommendation", + ) @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py index 5b1ebb1e..a38d644c 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py @@ -19,8 +19,8 @@ FromClusterMixin, SwitchIdMixin, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_base_paths import ( - VpcPairBasePath, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum @@ -49,7 +49,13 @@ class EpVpcPairSupportGet( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return VpcPairBasePath.vpc_pair_support(self.fabric_name, self.switch_id) + return BasePath.path( + "fabrics", + self.fabric_name, + "switches", + self.switch_id, + "vpcPairSupport", + ) @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py index 42971b0a..303f9cf0 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py @@ -21,8 +21,8 @@ SortMixin, ViewMixin, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_base_paths import ( - VpcPairBasePath, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum @@ -53,7 +53,7 @@ class EpVpcPairsListGet( def path(self) -> str: if self.fabric_name is None: raise ValueError("fabric_name is required") - return VpcPairBasePath.vpc_pairs_list(self.fabric_name) + return BasePath.path("fabrics", self.fabric_name, "vpcPairs") @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_base_paths.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_base_paths.py deleted file mode 100644 index 53644bde..00000000 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_base_paths.py +++ /dev/null @@ -1,331 +0,0 @@ -# Copyright (c) 2026 Cisco and/or its affiliates. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -Centralized base paths for VPC pair API endpoints. - -This module provides a single location to manage all VPC pair API base paths, -allowing easy modification when API paths change. All endpoint classes -should use these path builders for consistency. - -Path roots used by vPC endpoints: -- /api/v1/manage/fabrics/{fabricName}/vpcPairs -- /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair -- /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport -- /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview -- /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation -- /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency -""" - -from __future__ import absolute_import, division, print_function - -__author__ = "Sivakami Sivaraman" - -from typing import Final - -try: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( - BasePath as _ManageBasePath, - ) -except Exception: - # Backward-compat for older endpoint layouts. - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( # type: ignore - BasePath as _ManageBasePath, - ) - - -def _require_non_empty_str(name: str, value: str, owner: str) -> str: - """Validate required string params for endpoint path construction.""" - if not value or not isinstance(value, str) or not value.strip(): - raise ValueError( - f"{owner}: {name} must be a non-empty string. " - f"Got: {value!r} (type: {type(value).__name__})" - ) - return value.strip() - - -class VpcPairBasePath: - """ - # Summary - - Centralized VPC Pair API Base Paths - - ## Description - - Provides centralized base path definitions for all ND Manage VPC Pair - API endpoints. This allows API path changes to be managed in a single - location. - - ## Usage - - ```python - # Get VPC pair details path - path = VpcPairBasePath.vpc_pair("Fabric1", "FDO23040Q85") - # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPair - - # Get VPC pairs list path - path = VpcPairBasePath.vpc_pairs_list("Fabric1") - # Returns: /api/v1/manage/fabrics/Fabric1/vpcPairs - ``` - - ## 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 changes base API paths, only this class needs updating - """ - - # Root API paths - MANAGE_API: Final = _ManageBasePath.path() - - @classmethod - def manage(cls, *segments: str) -> str: - """ - # Summary - - Build path from Manage API root. - - ## Parameters - - - segments: Path segments to append - - ## Returns - - - Complete path string - - ## Example - - ```python - path = VpcPairBasePath.manage("fabrics", "Fabric1") - # Returns: /api/v1/manage/fabrics/Fabric1 - ``` - """ - return _ManageBasePath.path(*segments) - - @classmethod - def fabrics(cls, fabric_name: str, *segments: str) -> str: - """ - # Summary - - Build fabrics API path. - - ## Parameters - - - fabric_name: Name of the fabric - - segments: Additional path segments to append - - ## Returns - - - Complete fabrics path - - ## Raises - - - ValueError: If fabric_name is None, empty, or not a string - - ## Example - - ```python - path = VpcPairBasePath.fabrics("Fabric1", "switches") - # Returns: /api/v1/manage/fabrics/Fabric1/switches - ``` - """ - fabric_name = _require_non_empty_str( - name="fabric_name", - value=fabric_name, - owner="VpcPairBasePath.fabrics()", - ) - - return cls.manage("fabrics", fabric_name, *segments) - - @classmethod - def switches(cls, fabric_name: str, switch_id: str, *segments: str) -> str: - """ - # Summary - - Build switches API path. - - ## Parameters - - - fabric_name: Name of the fabric - - switch_id: Serial number of the switch - - segments: Additional path segments to append - - ## Returns - - - Complete switches path - - ## Example - - ```python - path = VpcPairBasePath.switches("Fabric1", "FDO23040Q85") - # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85 - ``` - """ - switch_id = _require_non_empty_str( - name="switch_id", - value=switch_id, - owner="VpcPairBasePath.switches()", - ) - if not segments: - return cls.fabrics(fabric_name, "switches", switch_id) - return cls.fabrics(fabric_name, "switches", switch_id, *segments) - - @classmethod - def vpc_pair(cls, fabric_name: str, switch_id: str) -> str: - """ - # Summary - - Build VPC pair details API path. - - ## Parameters - - - fabric_name: Name of the fabric - - switch_id: Serial number of the switch - - ## Returns - - - Complete VPC pair path - - ## Example - - ```python - path = VpcPairBasePath.vpc_pair("Fabric1", "FDO23040Q85") - # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPair - ``` - """ - return cls.switches(fabric_name, switch_id, "vpcPair") - - @classmethod - def vpc_pair_support(cls, fabric_name: str, switch_id: str) -> str: - """ - # Summary - - Build VPC pair support check API path. - - ## Parameters - - - fabric_name: Name of the fabric - - switch_id: Serial number of the switch - - ## Returns - - - Complete VPC pair support path - - ## Example - - ```python - path = VpcPairBasePath.vpc_pair_support("Fabric1", "FDO23040Q85") - # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPairSupport - ``` - """ - return cls.switches(fabric_name, switch_id, "vpcPairSupport") - - @classmethod - def vpc_pair_overview(cls, fabric_name: str, switch_id: str) -> str: - """ - # Summary - - Build VPC pair overview API path. - - ## Parameters - - - fabric_name: Name of the fabric - - switch_id: Serial number of the switch - - ## Returns - - - Complete VPC pair overview path - - ## Example - - ```python - path = VpcPairBasePath.vpc_pair_overview("Fabric1", "FDO23040Q85") - # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPairOverview - ``` - """ - return cls.switches(fabric_name, switch_id, "vpcPairOverview") - - @classmethod - def vpc_pair_recommendation(cls, fabric_name: str, switch_id: str) -> str: - """ - # Summary - - Build VPC pair recommendation API path. - - ## Parameters - - - fabric_name: Name of the fabric - - switch_id: Serial number of the switch - - ## Returns - - - Complete VPC pair recommendation path - - ## Example - - ```python - path = VpcPairBasePath.vpc_pair_recommendation("Fabric1", "FDO23040Q85") - # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPairRecommendation - ``` - """ - return cls.switches(fabric_name, switch_id, "vpcPairRecommendation") - - @classmethod - def vpc_pair_consistency(cls, fabric_name: str, switch_id: str) -> str: - """ - # Summary - - Build VPC pair consistency API path. - - ## Parameters - - - fabric_name: Name of the fabric - - switch_id: Serial number of the switch - - ## Returns - - - Complete VPC pair consistency path - - ## Example - - ```python - path = VpcPairBasePath.vpc_pair_consistency("Fabric1", "FDO23040Q85") - # Returns: /api/v1/manage/fabrics/Fabric1/switches/FDO23040Q85/vpcPairConsistency - ``` - """ - return cls.switches(fabric_name, switch_id, "vpcPairConsistency") - - @classmethod - def vpc_pairs_list(cls, fabric_name: str) -> str: - """ - # Summary - - Build VPC pairs list API path. - - ## Parameters - - - fabric_name: Name of the fabric - - ## Returns - - - Complete VPC pairs list path - - ## Example - - ```python - path = VpcPairBasePath.vpc_pairs_list("Fabric1") - # Returns: /api/v1/manage/fabrics/Fabric1/vpcPairs - ``` - """ - return cls.fabrics(fabric_name, "vpcPairs") diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py deleted file mode 100644 index 272b2690..00000000 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_endpoints.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com -# 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 - -""" -vPC endpoint export map. - -Class -> API path: -- EpVpcPairGet / EpVpcPairPut -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair -- EpVpcPairSupportGet -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairSupport -- EpVpcPairOverviewGet -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairOverview -- EpVpcPairRecommendationGet -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairRecommendation -- EpVpcPairConsistencyGet -> /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPairConsistency -- EpVpcPairsListGet -> /api/v1/manage/fabrics/{fabricName}/vpcPairs -""" - -# Backward-compatible export surface for legacy imports. -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair import ( - EpVpcPairGet, - EpVpcPairPut, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_consistency import ( - EpVpcPairConsistencyGet, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_overview import ( - EpVpcPairOverviewGet, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_recommendation import ( - EpVpcPairRecommendationGet, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_support import ( - EpVpcPairSupportGet, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( - EpVpcPairsListGet, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_base_paths import ( - VpcPairBasePath, -) - -__all__ = [ - "EpVpcPairGet", - "EpVpcPairPut", - "EpVpcPairSupportGet", - "EpVpcPairOverviewGet", - "EpVpcPairRecommendationGet", - "EpVpcPairConsistencyGet", - "EpVpcPairsListGet", - "VpcPairBasePath", -] diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py deleted file mode 100644 index 221c2630..00000000 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_module_model.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com -# 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 - -""" -Compatibility import shim for vPC module model. - -Note: -- This file has no endpoint path definitions. -- It only re-exports `VpcPairModel`. -""" - -# Backward-compatible import path for callers still using the legacy location. -from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( # noqa: F401 - VpcPairModel, -) diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py b/plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py deleted file mode 100644 index 815ae1c7..00000000 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_schemas.py +++ /dev/null @@ -1,127 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com - -# 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 - -""" -Backward-compatible export surface for vPC pair schemas. - -Primary source of truth lives in `plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py`. -This module also provides local fallback models for AnsiballZ runtimes where -`module_utils/models/manage_vpc_pair` files may not be packaged. - -Note: -- This file defines schema/model types, not endpoint paths. -- Endpoint path mappings are documented in `vpc_pair_endpoints.py`. -""" - -from typing import Any, Dict, List, Optional, Literal, Annotated - -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - BeforeValidator, - ConfigDict, - Field, -) - -try: - from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.base import ( # noqa: F401 - coerce_str_to_int, - coerce_to_bool, - coerce_list_of_str, - FlexibleInt, - FlexibleBool, - FlexibleListStr, - NDVpcPairBaseModel, - ) - from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.nested import ( # noqa: F401 - NDVpcPairNestedModel, - ) - from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.vpc_pair_models import * # noqa: F401,F403 -except Exception: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( # noqa: F401 - KeepAliveVrfEnum, - ) - - def coerce_str_to_int(data): - if data is None: - return None - if isinstance(data, str): - if data.strip() and data.lstrip("-").isdigit(): - return int(data) - raise ValueError(f"Cannot convert '{data}' to int") - return int(data) - - def coerce_to_bool(data): - if data is None: - return None - if isinstance(data, str): - return data.lower() in ("true", "1", "yes", "on") - return bool(data) - - def coerce_list_of_str(data): - if data is None: - return None - if isinstance(data, str): - return [item.strip() for item in data.split(",") if item.strip()] - if isinstance(data, list): - return [str(item) for item in data] - return data - - FlexibleInt = Annotated[int, BeforeValidator(coerce_str_to_int)] - FlexibleBool = Annotated[bool, BeforeValidator(coerce_to_bool)] - FlexibleListStr = Annotated[List[str], BeforeValidator(coerce_list_of_str)] - - class _VpcPairDetailsBaseModel(BaseModel): - model_config = ConfigDict( - str_strip_whitespace=True, - use_enum_values=True, - validate_assignment=True, - populate_by_name=True, - extra="ignore", - ) - - class VpcPairDetailsDefault(_VpcPairDetailsBaseModel): - """Default template vPC pair configuration.""" - - type: Literal["default"] = Field(default="default", alias="type", description="Template type") - domain_id: Optional[FlexibleInt] = Field(default=None, alias="domainId", description="VPC domain ID") - switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="switchKeepAliveLocalIp", description="Peer-1 keep-alive IP") - peer_switch_keep_alive_local_ip: Optional[str] = Field(default=None, alias="peerSwitchKeepAliveLocalIp", description="Peer-2 keep-alive IP") - keep_alive_vrf: Optional[KeepAliveVrfEnum] = Field(default=None, alias="keepAliveVrf", description="Keep-alive VRF") - keep_alive_hold_timeout: Optional[FlexibleInt] = Field(default=3, alias="keepAliveHoldTimeout", description="Keep-alive hold timeout") - enable_mirror_config: Optional[FlexibleBool] = Field(default=False, alias="enableMirrorConfig", description="Enable config mirroring") - is_vpc_plus: Optional[FlexibleBool] = Field(default=False, alias="isVpcPlus", description="VPC+ topology") - fabric_path_switch_id: Optional[FlexibleInt] = Field(default=None, alias="fabricPathSwitchId", description="FabricPath switch ID") - is_vteps: Optional[FlexibleBool] = Field(default=False, alias="isVteps", description="Configure NVE source loopback") - nve_interface: Optional[FlexibleInt] = Field(default=1, alias="nveInterface", description="NVE interface") - switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="switchSourceLoopback", description="Peer-1 source loopback") - peer_switch_source_loopback: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchSourceLoopback", description="Peer-2 source loopback") - switch_primary_ip: Optional[str] = Field(default=None, alias="switchPrimaryIp", description="Peer-1 primary IP") - peer_switch_primary_ip: Optional[str] = Field(default=None, alias="peerSwitchPrimaryIp", description="Peer-2 primary IP") - loopback_secondary_ip: Optional[str] = Field(default=None, alias="loopbackSecondaryIp", description="Secondary loopback IP") - switch_domain_config: Optional[str] = Field(default=None, alias="switchDomainConfig", description="Peer-1 domain config CLI") - peer_switch_domain_config: Optional[str] = Field(default=None, alias="peerSwitchDomainConfig", description="Peer-2 domain config CLI") - switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="switchPoId", description="Peer-1 port-channel ID") - peer_switch_po_id: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchPoId", description="Peer-2 port-channel ID") - switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="switchMemberInterfaces", description="Peer-1 member interfaces") - peer_switch_member_interfaces: Optional[FlexibleListStr] = Field(default=None, alias="peerSwitchMemberInterfaces", description="Peer-2 member interfaces") - po_mode: Optional[str] = Field(default="active", alias="poMode", description="Port-channel mode") - switch_po_description: Optional[str] = Field(default=None, alias="switchPoDescription", description="Peer-1 port-channel description") - peer_switch_po_description: Optional[str] = Field(default=None, alias="peerSwitchPoDescription", description="Peer-2 port-channel description") - admin_state: Optional[FlexibleBool] = Field(default=True, alias="adminState", description="Admin state") - allowed_vlans: Optional[str] = Field(default="all", alias="allowedVlans", description="Allowed VLANs") - switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="switchNativeVlan", description="Peer-1 native VLAN") - peer_switch_native_vlan: Optional[FlexibleInt] = Field(default=None, alias="peerSwitchNativeVlan", description="Peer-2 native VLAN") - switch_po_config: Optional[str] = Field(default=None, alias="switchPoConfig", description="Peer-1 port-channel freeform config") - peer_switch_po_config: Optional[str] = Field(default=None, alias="peerSwitchPoConfig", description="Peer-2 port-channel freeform config") - fabric_name: Optional[str] = Field(default=None, alias="fabricName", description="Fabric name") - - class VpcPairDetailsCustom(_VpcPairDetailsBaseModel): - """Custom template vPC pair configuration.""" - - type: Literal["custom"] = Field(default="custom", alias="type", description="Template type") - template_name: str = Field(alias="templateName", description="Name of the custom template") - template_config: Dict[str, Any] = Field(alias="templateConfig", description="Free-form configuration") diff --git a/plugins/module_utils/manage_vpc_pair/__init__.py b/plugins/module_utils/manage_vpc_pair/__init__.py new file mode 100644 index 00000000..7a73610a --- /dev/null +++ b/plugins/module_utils/manage_vpc_pair/__init__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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.manage_vpc_pair.enums import ( + ComponentTypeSupportEnum, + VpcActionEnum, + VpcFieldNames, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.resources import ( + VpcPairResourceService, + VpcPairStateMachine, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( + VpcPairEndpoints, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_payloads import ( + _build_vpc_pair_payload, + _get_api_field_value, +) + +__all__ = [ + "ComponentTypeSupportEnum", + "VpcActionEnum", + "VpcFieldNames", + "VpcPairEndpoints", + "VpcPairResourceService", + "VpcPairStateMachine", + "_build_vpc_pair_payload", + "_get_api_field_value", +] diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_enums.py b/plugins/module_utils/manage_vpc_pair/enums.py similarity index 98% rename from plugins/module_utils/endpoints/v1/manage/vpc_pair_enums.py rename to plugins/module_utils/manage_vpc_pair/enums.py index 7a0826b6..6c4c8345 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_enums.py +++ b/plugins/module_utils/manage_vpc_pair/enums.py @@ -22,7 +22,8 @@ Note: - This file does not define API paths. -- Endpoint path mappings are documented in `vpc_pair_endpoints.py`. +- Endpoint path mappings are defined by path-based endpoint files under + `plugins/module_utils/endpoints/v1/manage/`. """ from __future__ import absolute_import, division, print_function diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py b/plugins/module_utils/manage_vpc_pair/resources.py similarity index 98% rename from plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py rename to plugins/module_utils/manage_vpc_pair/resources.py index b25e9d5c..31df053a 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_resources.py +++ b/plugins/module_utils/manage_vpc_pair/resources.py @@ -102,6 +102,8 @@ def _refresh_after_state(self) -> None: return if self.module.check_mode: return + if self.module.params.get("suppress_verification", False): + return if not self.module.params.get("refresh_after_apply", True): return @@ -305,9 +307,8 @@ def _manage_create_update_state(self, state: str, unwanted_keys: List) -> None: sent_payload_data=sent_payload, ) except VpcPairResourceError as e: - # The error details from nd_manage_vpc_pair are dropped by - # State machine wrappers in vpc_pair_resources.py - # Here is the exception handling to capture those details + # Preserve detailed context from vPC handlers instead of losing + # it in generic state-machine wrapping layers. error_msg = f"Failed to process {identifier}: {e.msg}" self.format_log( identifier=identifier, diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py b/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py similarity index 98% rename from plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py rename to plugins/module_utils/manage_vpc_pair/runtime_endpoints.py index 1134fa60..87e1f580 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_endpoints.py +++ b/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py @@ -10,7 +10,7 @@ CompositeQueryParams, EndpointQueryParams, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( ComponentTypeSupportEnum, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair import ( diff --git a/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_payloads.py b/plugins/module_utils/manage_vpc_pair/runtime_payloads.py similarity index 96% rename from plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_payloads.py rename to plugins/module_utils/manage_vpc_pair/runtime_payloads.py index 149b1de3..d697e028 100644 --- a/plugins/module_utils/endpoints/v1/manage/vpc_pair_runtime_payloads.py +++ b/plugins/module_utils/manage_vpc_pair/runtime_payloads.py @@ -7,7 +7,7 @@ from typing import Any, Dict, Optional -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( VpcActionEnum, VpcFieldNames, ) diff --git a/plugins/module_utils/models/manage_vpc_pair/model.py b/plugins/module_utils/models/manage_vpc_pair/model.py index f843b721..22c44166 100644 --- a/plugins/module_utils/models/manage_vpc_pair/model.py +++ b/plugins/module_utils/models/manage_vpc_pair/model.py @@ -22,20 +22,14 @@ field_validator, model_validator, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( VpcFieldNames, ) -try: - from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.vpc_pair_models import ( - VpcPairDetailsDefault, - VpcPairDetailsCustom, - ) -except ImportError: - from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_schemas import ( - VpcPairDetailsDefault, - VpcPairDetailsCustom, - ) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.vpc_pair_models import ( + VpcPairDetailsDefault, + VpcPairDetailsCustom, +) class VpcPairModel(_VpcPairBaseModel): diff --git a/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py b/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py index 0d410585..b7301466 100644 --- a/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py +++ b/plugins/module_utils/models/manage_vpc_pair/vpc_pair_models.py @@ -38,7 +38,7 @@ ) # Import enums from centralized location -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( VpcActionEnum, VpcPairTypeEnum, KeepAliveVrfEnum, diff --git a/plugins/module_utils/nd_manage_vpc_pair_actions.py b/plugins/module_utils/nd_manage_vpc_pair_actions.py index df85b78c..be23b4d7 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_actions.py +++ b/plugins/module_utils/nd_manage_vpc_pair_actions.py @@ -7,7 +7,7 @@ from typing import Any, Dict, Optional from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( ComponentTypeSupportEnum, VpcActionEnum, VpcFieldNames, @@ -26,10 +26,10 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( VpcPairResourceError, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_endpoints import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( VpcPairEndpoints, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_payloads import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_payloads import ( _build_vpc_pair_payload, _get_api_field_value, ) diff --git a/plugins/module_utils/nd_manage_vpc_pair_deploy.py b/plugins/module_utils/nd_manage_vpc_pair_deploy.py index 64d7898b..1fa02d4f 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_deploy.py +++ b/plugins/module_utils/nd_manage_vpc_pair_deploy.py @@ -11,7 +11,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( _raise_vpc_error, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_endpoints import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( VpcPairEndpoints, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( @@ -221,4 +221,3 @@ def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: # Build final result results.build_final_result() return results.final_result - diff --git a/plugins/module_utils/nd_manage_vpc_pair_query.py b/plugins/module_utils/nd_manage_vpc_pair_query.py index 73a41a31..c0ccc099 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_query.py +++ b/plugins/module_utils/nd_manage_vpc_pair_query.py @@ -8,17 +8,17 @@ from typing import Any, Dict, List, Optional from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( VpcFieldNames, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_validation import ( _is_switch_in_vpc_pair, _validate_fabric_switches, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_endpoints import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( VpcPairEndpoints, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_payloads import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_payloads import ( _get_api_field_value, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( @@ -672,4 +672,3 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic fabric=fabric_name, exception_type=type(e).__name__ ) - diff --git a/plugins/module_utils/nd_manage_vpc_pair_runner.py b/plugins/module_utils/nd_manage_vpc_pair_runner.py index 66448480..ca7376b0 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_runner.py +++ b/plugins/module_utils/nd_manage_vpc_pair_runner.py @@ -7,7 +7,7 @@ from typing import Any, Dict -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( VpcFieldNames, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( @@ -95,4 +95,3 @@ def run_vpc_module(nrm) -> Dict[str, Any]: # ===== Module Entry Point ===== - diff --git a/plugins/module_utils/nd_manage_vpc_pair_validation.py b/plugins/module_utils/nd_manage_vpc_pair_validation.py index 829d5dad..70ab8e9b 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_validation.py +++ b/plugins/module_utils/nd_manage_vpc_pair_validation.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( ComponentTypeSupportEnum, VpcFieldNames, ) @@ -17,10 +17,10 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( VpcPairResourceError, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_endpoints import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( VpcPairEndpoints, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_runtime_payloads import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_payloads import ( _get_api_field_value, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModuleError diff --git a/plugins/module_utils/orchestrators/manage_vpc_pair.py b/plugins/module_utils/orchestrators/manage_vpc_pair.py new file mode 100644 index 00000000..492b2eb6 --- /dev/null +++ b/plugins/module_utils/orchestrators/manage_vpc_pair.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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, Optional + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( + VpcPairModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_actions import ( + custom_vpc_create, + custom_vpc_delete, + custom_vpc_update, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_query import ( + custom_vpc_query_all, +) + + +class _VpcPairQueryContext: + """Minimal context object for query_all during NDStateMachine initialization.""" + + def __init__(self, module: AnsibleModule): + self.module = module + + +class VpcPairOrchestrator: + """ + VPC orchestrator implementation for NDStateMachine. + + Delegates CRUD operations to vPC handlers while staying compatible with + sender/module constructor styles used by shared NDStateMachine variants. + """ + + model_class = VpcPairModel + + def __init__( + self, + module: Optional[AnsibleModule] = None, + sender: Optional[Any] = None, + **kwargs, + ): + _ = kwargs + if module is None and sender is not None: + module = getattr(sender, "module", None) + if module is None: + raise ValueError( + "VpcPairOrchestrator requires either module=AnsibleModule " + "or sender=." + ) + + self.module = module + self.sender = sender + self.state_machine = None + + def bind_state_machine(self, state_machine: Any) -> None: + self.state_machine = state_machine + + def query_all(self): + # Optional performance knob: skip initial query used to build "before" + # state and baseline diff in NDStateMachine initialization. + if self.state_machine is None and self.module.params.get("suppress_previous", False): + return [] + + context = ( + self.state_machine + if self.state_machine is not None + else _VpcPairQueryContext(self.module) + ) + return custom_vpc_query_all(context) + + def create(self, model_instance, **kwargs): + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return custom_vpc_create(self.state_machine) + + def update(self, model_instance, **kwargs): + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return custom_vpc_update(self.state_machine) + + def delete(self, model_instance, **kwargs): + _ = (model_instance, kwargs) + if self.state_machine is None: + raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") + return custom_vpc_delete(self.state_machine) diff --git a/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py b/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py deleted file mode 100644 index d510f2a4..00000000 --- a/plugins/module_utils/orchestrators/nd_vpc_pair_orchestrator.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com -# 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 - -# Backward-compatible import path for callers still using nd_ prefix. -from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vpc_pair import ( - VpcPairOrchestrator, -) - -__all__ = ("VpcPairOrchestrator",) diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index 27e602d2..6abee304 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -72,6 +72,21 @@ - Optional timeout in seconds for the post-apply refresh query. - When omitted, C(query_timeout) is used. type: int + suppress_previous: + description: + - Skip initial controller query for C(before) state and diff baseline. + - Performance optimization for trusted upsert workflows. + - May reduce idempotency and diff accuracy because existing controller state is not pre-fetched. + - Supported only with C(state=merged). + type: bool + default: false + suppress_verification: + description: + - Skip post-apply controller query for final C(after) state verification. + - Equivalent to setting C(refresh_after_apply=false). + - Improves performance by avoiding end-of-task query. + type: bool + default: false config: description: - List of vPC pair configuration dictionaries. @@ -145,6 +160,26 @@ - peer1_switch_id: "FDO23040Q85" peer2_switch_id: "FDO23040Q86" check_mode: true + +# Performance mode: skip final after-state verification query +- name: Create vPC pair without post-apply verification query + cisco.nd.nd_manage_vpc_pair: + fabric_name: myFabric + state: merged + suppress_verification: true + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" + +# Advanced performance mode: skip initial before-state query (merged only) +- name: Create/update vPC pair without initial before query + cisco.nd.nd_manage_vpc_pair: + fabric_name: myFabric + state: merged + suppress_previous: true + config: + - peer1_switch_id: "FDO23040Q85" + peer2_switch_id: "FDO23040Q86" """ RETURN = """ @@ -154,12 +189,18 @@ returned: always sample: true before: - description: vPC pair state before changes + description: + - vPC pair state before changes. + - May contain controller read-only properties because it is queried from controller state. + - Empty when C(suppress_previous=true). type: list returned: always sample: [{"switchId": "FDO123", "peerSwitchId": "FDO456", "useVirtualPeerLink": false}] after: - description: vPC pair state after changes + description: + - vPC pair state after changes. + - By default this is refreshed from controller after write operations and may include read-only properties. + - Refresh can be skipped with C(refresh_after_apply=false) or C(suppress_verification=true). type: list returned: always sample: [{"switchId": "FDO123", "peerSwitchId": "FDO456", "useVirtualPeerLink": true}] @@ -290,7 +331,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging # Service layer imports -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_resources import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.resources import ( VpcPairResourceService, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( @@ -306,7 +347,7 @@ _nd_config_collection = None # noqa: F841 _nd_utils = None # noqa: F841 -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.vpc_pair_enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( VpcFieldNames, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( @@ -367,6 +408,22 @@ def main(): required=False, description="Optional timeout in seconds for post-apply after-state refresh query", ), + suppress_previous=dict( + type="bool", + default=False, + description=( + "Skip initial controller query for before/diff baseline. " + "Supported only with state=merged." + ), + ), + suppress_verification=dict( + type="bool", + default=False, + description=( + "Skip post-apply controller query for after-state verification " + "(alias for refresh_after_apply=false)." + ), + ), config=dict( type="list", elements="dict", @@ -391,10 +448,38 @@ def main(): # State-specific parameter validations state = module.params["state"] deploy = module.params.get("deploy") + suppress_previous = module.params.get("suppress_previous", False) + suppress_verification = module.params.get("suppress_verification", False) if state == "gathered" and deploy: module.fail_json(msg="Deploy parameter cannot be used with 'gathered' state") + if suppress_previous and state != "merged": + module.fail_json( + msg=( + "Parameter 'suppress_previous' is supported only with state 'merged' " + "for nd_manage_vpc_pair." + ) + ) + + if suppress_previous: + module.warn( + "suppress_previous=true skips initial controller query. " + "before/diff accuracy and idempotency checks may be reduced." + ) + + if suppress_verification: + if module.params.get("refresh_after_apply", True): + module.warn( + "suppress_verification=true overrides refresh_after_apply=true. " + "Final after-state refresh query will be skipped." + ) + if module.params.get("refresh_after_timeout") is not None: + module.warn( + "refresh_after_timeout is ignored when suppress_verification=true." + ) + module.params["refresh_after_apply"] = False + # Validate force parameter usage: # - state=deleted # - state=overridden with empty config (interpreted as delete-all) From af11f9f848b0c3e0e530b887ef3875eb0bcb6ef1 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Thu, 19 Mar 2026 23:39:21 +0530 Subject: [PATCH 33/39] Interim changes --- .../module_utils/manage_vpc_pair/__init__.py | 47 +++- .../models/manage_vpc_pair/__init__.py | 2 + .../models/manage_vpc_pair/model.py | 231 ++++++++++++++++++ plugins/modules/nd_manage_vpc_pair.py | 113 ++------- 4 files changed, 293 insertions(+), 100 deletions(-) diff --git a/plugins/module_utils/manage_vpc_pair/__init__.py b/plugins/module_utils/manage_vpc_pair/__init__.py index 7a73610a..cffdaf68 100644 --- a/plugins/module_utils/manage_vpc_pair/__init__.py +++ b/plugins/module_utils/manage_vpc_pair/__init__.py @@ -9,17 +9,6 @@ VpcActionEnum, VpcFieldNames, ) -from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.resources import ( - VpcPairResourceService, - VpcPairStateMachine, -) -from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( - VpcPairEndpoints, -) -from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_payloads import ( - _build_vpc_pair_payload, - _get_api_field_value, -) __all__ = [ "ComponentTypeSupportEnum", @@ -31,3 +20,39 @@ "_build_vpc_pair_payload", "_get_api_field_value", ] + + +def __getattr__(name): + """ + Lazy-load heavy symbols to avoid import-time cycles. + """ + if name in ("VpcPairResourceService", "VpcPairStateMachine"): + from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.resources import ( + VpcPairResourceService, + VpcPairStateMachine, + ) + + return { + "VpcPairResourceService": VpcPairResourceService, + "VpcPairStateMachine": VpcPairStateMachine, + }[name] + + if name == "VpcPairEndpoints": + from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( + VpcPairEndpoints, + ) + + return VpcPairEndpoints + + if name in ("_build_vpc_pair_payload", "_get_api_field_value"): + from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_payloads import ( + _build_vpc_pair_payload, + _get_api_field_value, + ) + + return { + "_build_vpc_pair_payload": _build_vpc_pair_payload, + "_get_api_field_value": _get_api_field_value, + }[name] + + raise AttributeError("module '{}' has no attribute '{}'".format(__name__, name)) diff --git a/plugins/module_utils/models/manage_vpc_pair/__init__.py b/plugins/module_utils/models/manage_vpc_pair/__init__.py index 04758866..98b04d6c 100644 --- a/plugins/module_utils/models/manage_vpc_pair/__init__.py +++ b/plugins/module_utils/models/manage_vpc_pair/__init__.py @@ -6,5 +6,7 @@ from __future__ import absolute_import, division, print_function from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( # noqa: F401 + VpcPairPlaybookConfigModel, + VpcPairPlaybookItemModel, VpcPairModel, ) diff --git a/plugins/module_utils/models/manage_vpc_pair/model.py b/plugins/module_utils/models/manage_vpc_pair/model.py index 22c44166..2966e562 100644 --- a/plugins/module_utils/models/manage_vpc_pair/model.py +++ b/plugins/module_utils/models/manage_vpc_pair/model.py @@ -17,6 +17,7 @@ ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, ConfigDict, Field, field_validator, @@ -150,3 +151,233 @@ def from_response(cls, response: Dict[str, Any]) -> "VpcPairModel": ), } return cls.model_validate(data) + + @classmethod + def get_argument_spec(cls) -> Dict[str, Any]: + """ + Return Ansible argument_spec for nd_manage_vpc_pair. + + Backward-compatible wrapper around the dedicated playbook config model. + """ + return VpcPairPlaybookConfigModel.get_argument_spec() + + +class VpcPairPlaybookItemModel(BaseModel): + """ + One item under playbook `config` for nd_manage_vpc_pair. + """ + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, + extra="ignore", + ) + + peer1_switch_id: str = Field( + alias="switch_id", + description="Peer-1 switch serial number", + min_length=3, + max_length=64, + ) + peer2_switch_id: str = Field( + alias="peer_switch_id", + description="Peer-2 switch serial number", + min_length=3, + max_length=64, + ) + use_virtual_peer_link: bool = Field( + default=True, + description="Virtual peer link enabled", + ) + vpc_pair_details: Optional[Union[VpcPairDetailsDefault, VpcPairDetailsCustom]] = Field( + default=None, + discriminator="type", + alias=VpcFieldNames.VPC_PAIR_DETAILS, + description="VPC pair configuration details (default or custom template)", + ) + + @field_validator("peer1_switch_id", "peer2_switch_id") + @classmethod + def validate_switch_id_format(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Switch ID cannot be empty or whitespace") + return v.strip() + + @model_validator(mode="after") + def validate_different_switches(self) -> "VpcPairPlaybookItemModel": + if self.peer1_switch_id == self.peer2_switch_id: + raise ValueError( + "peer1_switch_id and peer2_switch_id must be different: " + f"{self.peer1_switch_id}" + ) + return self + + def to_runtime_config(self) -> Dict[str, Any]: + """ + Normalize playbook keys into runtime keys consumed by state machine code. + """ + switch_id = self.peer1_switch_id + peer_switch_id = self.peer2_switch_id + use_virtual_peer_link = self.use_virtual_peer_link + vpc_pair_details = self.vpc_pair_details + return { + "switch_id": switch_id, + "peer_switch_id": peer_switch_id, + "use_virtual_peer_link": use_virtual_peer_link, + "vpc_pair_details": ( + vpc_pair_details.model_dump(by_alias=True, exclude_none=True) + if vpc_pair_details is not None + else None + ), + VpcFieldNames.SWITCH_ID: switch_id, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_virtual_peer_link, + VpcFieldNames.VPC_PAIR_DETAILS: ( + vpc_pair_details.model_dump(by_alias=True, exclude_none=True) + if vpc_pair_details is not None + else None + ), + } + + +class VpcPairPlaybookConfigModel(BaseModel): + """ + Top-level playbook configuration model for nd_manage_vpc_pair. + """ + + model_config = ConfigDict( + str_strip_whitespace=True, + use_enum_values=True, + validate_assignment=True, + populate_by_name=True, + validate_by_alias=True, + validate_by_name=True, + extra="ignore", + ) + + state: Literal["merged", "replaced", "deleted", "overridden", "gathered"] = Field( + default="merged", + description="Desired state for vPC pair configuration", + ) + fabric_name: str = Field(description="Fabric name") + deploy: bool = Field(default=False, description="Deploy after configuration changes") + force: bool = Field( + default=False, + description="Force deletion without pre-deletion safety checks", + ) + api_timeout: int = Field( + default=30, + description="API request timeout in seconds for write operations", + ) + query_timeout: int = Field( + default=10, + description="API request timeout in seconds for query/recommendation operations", + ) + refresh_after_apply: bool = Field( + default=True, + description="Refresh final after-state with a post-apply query", + ) + refresh_after_timeout: Optional[int] = Field( + default=None, + description="Optional timeout for post-apply refresh query", + ) + suppress_previous: bool = Field( + default=False, + description="Skip initial before-state query (merged state only)", + ) + suppress_verification: bool = Field( + default=False, + description="Skip final after-state refresh query", + ) + config: List[VpcPairPlaybookItemModel] = Field( + default_factory=list, + description="List of vPC pair configurations", + ) + + @classmethod + def get_argument_spec(cls) -> Dict[str, Any]: + """ + Return Ansible argument_spec for nd_manage_vpc_pair. + """ + return dict( + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "deleted", "overridden", "gathered"], + ), + fabric_name=dict(type="str", required=True), + deploy=dict(type="bool", default=False), + force=dict( + type="bool", + default=False, + description=( + "Force deletion without pre-deletion validation " + "(bypasses safety checks)" + ), + ), + api_timeout=dict( + type="int", + default=30, + description=( + "API request timeout in seconds for primary operations" + ), + ), + query_timeout=dict( + type="int", + default=10, + description=( + "API request timeout in seconds for query/recommendation " + "operations" + ), + ), + refresh_after_apply=dict( + type="bool", + default=True, + description=( + "Refresh final after-state by querying controller " + "after write operations" + ), + ), + refresh_after_timeout=dict( + type="int", + required=False, + description=( + "Optional timeout in seconds for post-apply after-state " + "refresh query" + ), + ), + suppress_previous=dict( + type="bool", + default=False, + description=( + "Skip initial controller query for before/diff baseline. " + "Supported only with state=merged." + ), + ), + suppress_verification=dict( + type="bool", + default=False, + description=( + "Skip post-apply controller query for after-state " + "verification (alias for refresh_after_apply=false)." + ), + ), + config=dict( + type="list", + elements="dict", + options=dict( + peer1_switch_id=dict( + type="str", required=True, aliases=["switch_id"] + ), + peer2_switch_id=dict( + type="str", required=True, aliases=["peer_switch_id"] + ), + use_virtual_peer_link=dict(type="bool", default=True), + vpc_pair_details=dict(type="dict"), + ), + ), + ) diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index 6abee304..31c6d24c 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -329,6 +329,9 @@ from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + ValidationError, +) # Service layer imports from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.resources import ( @@ -347,8 +350,8 @@ _nd_config_collection = None # noqa: F841 _nd_utils = None # noqa: F841 -from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import ( - VpcFieldNames, +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( + VpcPairPlaybookConfigModel, ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( DEEPDIFF_IMPORT_ERROR, @@ -375,66 +378,7 @@ def main(): - VpcPairResourceService handles NDStateMachine orchestration - Custom actions use RestSend (NDModuleV2) for HTTP with retry logic """ - argument_spec = dict( - state=dict( - type="str", - default="merged", - choices=["merged", "replaced", "deleted", "overridden", "gathered"], - ), - fabric_name=dict(type="str", required=True), - deploy=dict(type="bool", default=False), - force=dict( - type="bool", - default=False, - description="Force deletion without pre-deletion validation (bypasses safety checks)" - ), - api_timeout=dict( - type="int", - default=30, - description="API request timeout in seconds for primary operations" - ), - query_timeout=dict( - type="int", - default=10, - description="API request timeout in seconds for query/recommendation operations" - ), - refresh_after_apply=dict( - type="bool", - default=True, - description="Refresh final after-state by querying controller after write operations", - ), - refresh_after_timeout=dict( - type="int", - required=False, - description="Optional timeout in seconds for post-apply after-state refresh query", - ), - suppress_previous=dict( - type="bool", - default=False, - description=( - "Skip initial controller query for before/diff baseline. " - "Supported only with state=merged." - ), - ), - suppress_verification=dict( - type="bool", - default=False, - description=( - "Skip post-apply controller query for after-state verification " - "(alias for refresh_after_apply=false)." - ), - ), - config=dict( - type="list", - elements="dict", - options=dict( - peer1_switch_id=dict(type="str", required=True, aliases=["switch_id"]), - peer2_switch_id=dict(type="str", required=True, aliases=["peer_switch_id"]), - use_virtual_peer_link=dict(type="bool", default=True), - vpc_pair_details=dict(type="dict"), - ), - ), - ) + argument_spec = VpcPairPlaybookConfigModel.get_argument_spec() module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) setup_logging(module) @@ -445,11 +389,21 @@ def main(): exception=DEEPDIFF_IMPORT_ERROR ) + try: + module_config = VpcPairPlaybookConfigModel.model_validate( + module.params, by_alias=True, by_name=True + ) + except ValidationError as e: + module.fail_json( + msg="Invalid nd_manage_vpc_pair playbook configuration", + validation_errors=e.errors(), + ) + # State-specific parameter validations - state = module.params["state"] - deploy = module.params.get("deploy") - suppress_previous = module.params.get("suppress_previous", False) - suppress_verification = module.params.get("suppress_verification", False) + state = module_config.state + deploy = module_config.deploy + suppress_previous = module_config.suppress_previous + suppress_verification = module_config.suppress_verification if state == "gathered" and deploy: module.fail_json(msg="Deploy parameter cannot be used with 'gathered' state") @@ -483,8 +437,8 @@ def main(): # Validate force parameter usage: # - state=deleted # - state=overridden with empty config (interpreted as delete-all) - force = module.params.get("force", False) - user_config = module.params.get("config") or [] + force = module_config.force + user_config = module_config.config or [] force_applicable = state == "deleted" or ( state == "overridden" and len(user_config) == 0 ) @@ -495,27 +449,8 @@ def main(): f"Ignoring force for state '{state}'." ) - # Normalize config keys for model - config = module.params.get("config") or [] - normalized_config = [] - - for item in config: - switch_id = item.get("peer1_switch_id") or item.get("switch_id") - peer_switch_id = item.get("peer2_switch_id") or item.get("peer_switch_id") - use_virtual_peer_link = item.get("use_virtual_peer_link", True) - vpc_pair_details = item.get("vpc_pair_details") - normalized = { - "switch_id": switch_id, - "peer_switch_id": peer_switch_id, - "use_virtual_peer_link": use_virtual_peer_link, - "vpc_pair_details": vpc_pair_details, - # Defensive dual-shape normalization for state-machine/model variants. - VpcFieldNames.SWITCH_ID: switch_id, - VpcFieldNames.PEER_SWITCH_ID: peer_switch_id, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_virtual_peer_link, - VpcFieldNames.VPC_PAIR_DETAILS: vpc_pair_details, - } - normalized_config.append(normalized) + # Normalize config keys for runtime/state-machine model handling. + normalized_config = [item.to_runtime_config() for item in module_config.config] module.params["config"] = normalized_config From 71acd940c04f7184c6dc1babeb9668c030de6241 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Mon, 23 Mar 2026 23:27:01 +0530 Subject: [PATCH 34/39] Addressing review comments and other few --- .../module_utils/manage_vpc_pair/resources.py | 54 +++--- .../models/manage_vpc_pair/base.py | 31 ++++ .../models/manage_vpc_pair/model.py | 4 +- .../nd_manage_vpc_pair_actions.py | 22 ++- .../module_utils/nd_manage_vpc_pair_common.py | 55 +++--- .../module_utils/nd_manage_vpc_pair_deploy.py | 2 +- .../nd_manage_vpc_pair_exceptions.py | 16 ++ .../module_utils/nd_manage_vpc_pair_query.py | 168 +++++++++--------- plugins/modules/nd_manage_vpc_pair.py | 19 +- .../{tests/nd => tasks}/base_tasks.yaml | 3 + .../{tests/nd => tasks}/conf_prep_tasks.yaml | 6 +- .../targets/nd_vpc_pair/tasks/main.yaml | 59 +++--- .../nd => tasks}/nd_vpc_pair_delete.yaml | 1 + .../nd => tasks}/nd_vpc_pair_gather.yaml | 0 .../nd => tasks}/nd_vpc_pair_merge.yaml | 24 ++- .../nd => tasks}/nd_vpc_pair_override.yaml | 0 .../nd => tasks}/nd_vpc_pair_replace.yaml | 0 17 files changed, 275 insertions(+), 189 deletions(-) create mode 100644 plugins/module_utils/nd_manage_vpc_pair_exceptions.py rename tests/integration/targets/nd_vpc_pair/{tests/nd => tasks}/base_tasks.yaml (94%) rename tests/integration/targets/nd_vpc_pair/{tests/nd => tasks}/conf_prep_tasks.yaml (70%) rename tests/integration/targets/nd_vpc_pair/{tests/nd => tasks}/nd_vpc_pair_delete.yaml (99%) rename tests/integration/targets/nd_vpc_pair/{tests/nd => tasks}/nd_vpc_pair_gather.yaml (100%) rename tests/integration/targets/nd_vpc_pair/{tests/nd => tasks}/nd_vpc_pair_merge.yaml (96%) rename tests/integration/targets/nd_vpc_pair/{tests/nd => tasks}/nd_vpc_pair_override.yaml (100%) rename tests/integration/targets/nd_vpc_pair/{tests/nd => tasks}/nd_vpc_pair_replace.yaml (100%) diff --git a/plugins/module_utils/manage_vpc_pair/resources.py b/plugins/module_utils/manage_vpc_pair/resources.py index 31df053a..173a1d94 100644 --- a/plugins/module_utils/manage_vpc_pair/resources.py +++ b/plugins/module_utils/manage_vpc_pair/resources.py @@ -11,12 +11,12 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import ( NDStateMachine, ) +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import ( + NDConfigCollection, +) from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_vpc_pair import ( VpcPairOrchestrator, ) -from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - ValidationError, -) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( VpcPairResourceError, ) @@ -82,6 +82,10 @@ def add_logs_and_outputs(self) -> None: formatted.setdefault("response", []) formatted.setdefault("result", []) class_diff = self._build_class_diff() + changed_by_class_diff = bool( + class_diff["created"] or class_diff["deleted"] or class_diff["updated"] + ) + formatted["changed"] = bool(formatted.get("changed")) or changed_by_class_diff formatted["created"] = class_diff["created"] formatted["deleted"] = class_diff["deleted"] formatted["updated"] = class_diff["updated"] @@ -115,7 +119,7 @@ def _refresh_after_state(self) -> None: if refresh_timeout is not None: self.module.params["query_timeout"] = refresh_timeout response_data = self.model_orchestrator.query_all() - self.existing = self.nd_config_collection.from_api_response( + self.existing = NDConfigCollection.from_api_response( response_data=response_data, model_class=self.model_class, ) @@ -217,23 +221,21 @@ def manage_state( self.ansible_config = new_configs or [] try: - parsed_items = [] - for config in self.ansible_config: - try: - parsed_items.append(self.model_class.from_config(config)) - except ValidationError as e: - raise VpcPairResourceError( - msg=f"Invalid configuration: {e}", - config=config, - validation_errors=e.errors(), - ) - - self.proposed = self.nd_config_collection(model_class=self.model_class, items=parsed_items) + self.proposed = NDConfigCollection.from_ansible_config( + data=self.ansible_config, + model_class=self.model_class, + ) self.previous = self.existing.copy() except Exception as e: if isinstance(e, VpcPairResourceError): raise - raise VpcPairResourceError(msg=f"Failed to prepare configurations: {e}", error=str(e)) + error_details = {"error": str(e)} + if hasattr(e, "errors"): + error_details["validation_errors"] = e.errors() + raise VpcPairResourceError( + msg=f"Failed to prepare configurations: {e}", + **error_details, + ) if state in ["merged", "replaced", "overridden"]: self._manage_create_update_state(state, unwanted_keys) @@ -290,8 +292,8 @@ def _manage_create_update_state(self, state: str, unwanted_keys: List) -> None: response = self.model_orchestrator.create(final_item) operation_status = "created" + self.sent.add(final_item) if not self.module.check_mode: - self.sent.add(final_item) sent_payload = self.proposed_config else: sent_payload = None @@ -348,9 +350,13 @@ def _manage_override_deletions(self, override_exceptions: List) -> None: self.existing_config = existing_item.model_dump( by_alias=True, exclude_none=True ) - self.model_orchestrator.delete(existing_item) + delete_changed = self.model_orchestrator.delete(existing_item) self.existing.delete(identifier) - self.format_log(identifier=identifier, status="deleted", after_data={}) + self.format_log( + identifier=identifier, + status="deleted" if delete_changed is not False else "no_change", + after_data={}, + ) except VpcPairResourceError as e: error_msg = f"Failed to delete {identifier}: {e.msg}" if not self.module.params.get("ignore_errors", False): @@ -380,9 +386,13 @@ def _manage_delete_state(self) -> None: self.existing_config = existing_item.model_dump( by_alias=True, exclude_none=True ) - self.model_orchestrator.delete(existing_item) + delete_changed = self.model_orchestrator.delete(existing_item) self.existing.delete(identifier) - self.format_log(identifier=identifier, status="deleted", after_data={}) + self.format_log( + identifier=identifier, + status="deleted" if delete_changed is not False else "no_change", + after_data={}, + ) except VpcPairResourceError as e: error_msg = f"Failed to delete {identifier}: {e.msg}" if not self.module.params.get("ignore_errors", False): diff --git a/plugins/module_utils/models/manage_vpc_pair/base.py b/plugins/module_utils/models/manage_vpc_pair/base.py index de71b919..492de9dd 100644 --- a/plugins/module_utils/models/manage_vpc_pair/base.py +++ b/plugins/module_utils/models/manage_vpc_pair/base.py @@ -15,6 +15,7 @@ BeforeValidator, ConfigDict, ) +from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset from typing_extensions import Self @@ -140,3 +141,33 @@ def to_diff_dict(self) -> Dict[str, Any]: exclude_none=True, exclude=set(self.exclude_from_diff), ) + + def get_diff(self, other: "NDVpcPairBaseModel") -> bool: + """Return True when ``other`` is a subset of this model for diff checks.""" + self_data = self.to_diff_dict() + other_data = other.to_diff_dict() + return issubset(other_data, self_data) + + def merge(self, other: "NDVpcPairBaseModel") -> "NDVpcPairBaseModel": + """ + Merge another model's non-None values into this model instance. + + Nested NDVpcPairBaseModel values are merged recursively. + """ + if not isinstance(other, type(self)): + raise TypeError( + f"Cannot merge {type(other).__name__} into {type(self).__name__}. " + "Both must be the same type." + ) + + for field_name, value in other: + if value is None: + continue + + current = getattr(self, field_name, None) + if isinstance(current, NDVpcPairBaseModel) and isinstance(value, NDVpcPairBaseModel): + current.merge(value) + else: + setattr(self, field_name, value) + + return self diff --git a/plugins/module_utils/models/manage_vpc_pair/model.py b/plugins/module_utils/models/manage_vpc_pair/model.py index 2966e562..40530b9d 100644 --- a/plugins/module_utils/models/manage_vpc_pair/model.py +++ b/plugins/module_utils/models/manage_vpc_pair/model.py @@ -293,8 +293,8 @@ class VpcPairPlaybookConfigModel(BaseModel): default=False, description="Skip final after-state refresh query", ) - config: List[VpcPairPlaybookItemModel] = Field( - default_factory=list, + config: Optional[List[VpcPairPlaybookItemModel]] = Field( + default=None, description="List of vPC pair configurations", ) diff --git a/plugins/module_utils/nd_manage_vpc_pair_actions.py b/plugins/module_utils/nd_manage_vpc_pair_actions.py index be23b4d7..0ec9fa2f 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_actions.py +++ b/plugins/module_utils/nd_manage_vpc_pair_actions.py @@ -198,7 +198,7 @@ def custom_vpc_update(nrm) -> Optional[Dict[str, Any]]: - Uses PUT with discriminator (same as create) - Validates switches exist in fabric - Checks for switch conflicts - - Uses DeepDiff to detect if update is actually needed + - Uses normalized payload comparison to detect if update is needed - Proper error handling Args: @@ -244,7 +244,7 @@ def custom_vpc_update(nrm) -> Optional[Dict[str, Any]]: if other_vpc_pairs: _validate_switch_conflicts([nrm.proposed_config], other_vpc_pairs, nrm.module) - # Validation Step 3: Check if update is actually needed using DeepDiff + # Validation Step 3: Check if update is actually needed if nrm.existing_config: want_dict = nrm.proposed_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.proposed_config, 'model_dump') else nrm.proposed_config have_dict = nrm.existing_config.model_dump(by_alias=True, exclude_none=True) if hasattr(nrm.existing_config, 'model_dump') else nrm.existing_config @@ -315,7 +315,7 @@ def custom_vpc_update(nrm) -> Optional[Dict[str, Any]]: ) -def custom_vpc_delete(nrm) -> None: +def custom_vpc_delete(nrm) -> bool: """ Custom delete function for VPC pairs using RestSend with PUT + discriminator. @@ -332,7 +332,7 @@ def custom_vpc_delete(nrm) -> None: AnsibleModule.fail_json: If validation fails (networks/VRFs attached) """ if nrm.module.check_mode: - return + return True fabric_name = nrm.module.params.get("fabric_name") switch_id = nrm.existing_config.get(VpcFieldNames.SWITCH_ID) @@ -372,7 +372,7 @@ def custom_vpc_delete(nrm) -> None: # Sentinel from _validate_vpc_pair_deletion: pair no longer exists. # Treat as idempotent success — nothing to delete. nrm.module.warn(str(already_unpaired)) - return + return False except (NDModuleError, Exception) as validation_error: # Validation failed - check if force deletion is enabled @@ -432,11 +432,19 @@ def custom_vpc_delete(nrm) -> None: # Idempotent handling: if the API says the switch is not part of any # vPC pair, the pair is already gone — treat as a successful no-op. if status_code == 400 and "not a part of" in error_msg: + # Keep idempotent semantics: this is a no-op delete, so downgrade the + # pre-logged operation from "deleted" to "no_change". + if getattr(nrm, "logs", None): + last_log = nrm.logs[-1] + if last_log.get("identifier") == nrm.current_identifier: + last_log["status"] = "no_change" + last_log.pop("sent_payload", None) + nrm.module.warn( f"VPC pair {nrm.current_identifier} is already unpaired on the controller. " f"Treating as idempotent success. API response: {error.msg}" ) - return + return False error_dict = error.to_dict() # Preserve original API error message with different key to avoid conflict @@ -459,3 +467,5 @@ def custom_vpc_delete(nrm) -> None: path=path, exception_type=type(e).__name__ ) + + return True diff --git a/plugins/module_utils/nd_manage_vpc_pair_common.py b/plugins/module_utils/nd_manage_vpc_pair_common.py index 6bd45326..9bc483b5 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_common.py +++ b/plugins/module_utils/nd_manage_vpc_pair_common.py @@ -4,22 +4,13 @@ # 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 traceback +import json from typing import Any, Dict, List from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( VpcPairResourceError, ) -# DeepDiff for intelligent change detection -try: - from deepdiff import DeepDiff - HAS_DEEPDIFF = True - DEEPDIFF_IMPORT_ERROR = None -except ImportError: - HAS_DEEPDIFF = False - DEEPDIFF_IMPORT_ERROR = traceback.format_exc() - def _collection_to_list_flex(collection) -> List[Dict[str, Any]]: """ Serialize NDConfigCollection across old/new framework variants. @@ -43,18 +34,39 @@ def _raise_vpc_error(msg: str, **details: Any) -> None: # ===== Helper Functions ===== +def _canonicalize_for_compare(value: Any) -> Any: + """ + Normalize nested payload data for deterministic comparison. + + Lists are sorted by canonical JSON representation so list ordering does + not trigger false-positive update detection. + """ + if isinstance(value, dict): + return { + key: _canonicalize_for_compare(item) + for key, item in sorted(value.items()) + } + if isinstance(value, list): + normalized_items = [_canonicalize_for_compare(item) for item in value] + return sorted( + normalized_items, + key=lambda item: json.dumps( + item, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ), + ) + return value + + def _is_update_needed(want: Dict[str, Any], have: Dict[str, Any]) -> bool: """ - Determine if an update is needed by comparing want and have using DeepDiff. + Determine if an update is needed by comparing want and have. - Uses DeepDiff for intelligent comparison that handles: + Uses canonical, order-insensitive comparison that handles: - Field additions - Value changes - Nested structure changes - Ignores field order - Falls back to simple comparison if DeepDiff is unavailable. - Args: want: Desired VPC pair configuration (dict) have: Current VPC pair configuration (dict) @@ -68,15 +80,6 @@ def _is_update_needed(want: Dict[str, Any], have: Dict[str, Any]) -> bool: >>> _is_update_needed(want, have) True """ - if not HAS_DEEPDIFF: - # Fallback to simple comparison - return want != have - - try: - # Use DeepDiff for intelligent comparison - diff = DeepDiff(have, want, ignore_order=True) - return bool(diff) - except Exception: - # Fallback to simple comparison if DeepDiff fails - return want != have - + normalized_want = _canonicalize_for_compare(want) + normalized_have = _canonicalize_for_compare(have) + return normalized_want != normalized_have diff --git a/plugins/module_utils/nd_manage_vpc_pair_deploy.py b/plugins/module_utils/nd_manage_vpc_pair_deploy.py index 1fa02d4f..886ea69c 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_deploy.py +++ b/plugins/module_utils/nd_manage_vpc_pair_deploy.py @@ -110,7 +110,7 @@ def custom_vpc_deploy(nrm, fabric_name: str, result: Dict) -> Dict[str, Any]: } if nrm.module.check_mode: - # Dry run deployment info (similar to show_dry_run_deployment_info) + # check_mode deployment preview before = result.get("before", []) after = result.get("after", []) pending_create = nrm.module.params.get("_pending_create", []) diff --git a/plugins/module_utils/nd_manage_vpc_pair_exceptions.py b/plugins/module_utils/nd_manage_vpc_pair_exceptions.py new file mode 100644 index 00000000..2bf77816 --- /dev/null +++ b/plugins/module_utils/nd_manage_vpc_pair_exceptions.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# +# Copyright: (c) 2026, Sivakami Sivaraman sivakasi@cisco.com +# 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 + + +class VpcPairResourceError(Exception): + """Structured error raised by vpc_pair runtime layers.""" + + def __init__(self, msg: str, **details: Any): + super().__init__(msg) + self.msg = msg + self.details = details diff --git a/plugins/module_utils/nd_manage_vpc_pair_query.py b/plugins/module_utils/nd_manage_vpc_pair_query.py index c0ccc099..1eb9272e 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_query.py +++ b/plugins/module_utils/nd_manage_vpc_pair_query.py @@ -343,6 +343,33 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic # Keep heavy discovery/enrichment only for write states. if state in ("deleted", "gathered"): if list_query_succeeded: + if state == "deleted" and config and not have: + fallback_have = [] + for item in config: + switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id_val or not peer_switch_id_val: + continue + + use_vpl_val = item.get("use_virtual_peer_link") + if use_vpl_val is None: + use_vpl_val = item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + + fallback_have.append( + { + VpcFieldNames.SWITCH_ID: switch_id_val, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id_val, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl_val, + } + ) + + if fallback_have: + nrm.module.warn( + "vPC list query returned no pairs for delete workflow. " + "Using requested delete config as fallback existing set." + ) + return _set_lightweight_context(fallback_have) + if state == "gathered": have = _filter_vpc_pairs_by_requested_config(have, config) have = _enrich_pairs_from_direct_vpc( @@ -351,13 +378,20 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic pairs=have, timeout=5, ) - have = _filter_stale_vpc_pairs( - nd_v2=nd_v2, - fabric_name=fabric_name, - pairs=have, - module=nrm.module, - ) - return _set_lightweight_context(have) + have = _filter_stale_vpc_pairs( + nd_v2=nd_v2, + fabric_name=fabric_name, + pairs=have, + module=nrm.module, + ) + if have: + return _set_lightweight_context(have) + nrm.module.warn( + "vPC list query returned no active pairs for gathered workflow. " + "Falling back to switch-level discovery." + ) + else: + return _set_lightweight_context(have) nrm.module.warn( "Skipping switch-level discovery for read-only/delete workflow because " @@ -365,47 +399,50 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic ) if state == "gathered": - return _set_lightweight_context([]) - - # Preserve explicit delete intent without full-fabric discovery. - # This keeps delete deterministic and avoids expensive inventory calls. - fallback_have = [] - for item in config: - switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) - peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) - if not switch_id_val or not peer_switch_id_val: - continue + nrm.module.warn( + "vPC list endpoint unavailable for gathered workflow. " + "Falling back to switch-level discovery." + ) + else: + # Preserve explicit delete intent without full-fabric discovery. + # This keeps delete deterministic and avoids expensive inventory calls. + fallback_have = [] + for item in config: + switch_id_val = item.get("switch_id") or item.get(VpcFieldNames.SWITCH_ID) + peer_switch_id_val = item.get("peer_switch_id") or item.get(VpcFieldNames.PEER_SWITCH_ID) + if not switch_id_val or not peer_switch_id_val: + continue - use_vpl_val = item.get("use_virtual_peer_link") - if use_vpl_val is None: - use_vpl_val = item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) + use_vpl_val = item.get("use_virtual_peer_link") + if use_vpl_val is None: + use_vpl_val = item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK, True) - fallback_have.append( - { - VpcFieldNames.SWITCH_ID: switch_id_val, - VpcFieldNames.PEER_SWITCH_ID: peer_switch_id_val, - VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl_val, - } - ) + fallback_have.append( + { + VpcFieldNames.SWITCH_ID: switch_id_val, + VpcFieldNames.PEER_SWITCH_ID: peer_switch_id_val, + VpcFieldNames.USE_VIRTUAL_PEER_LINK: use_vpl_val, + } + ) - if fallback_have: - nrm.module.warn( - "Using requested delete config as fallback existing set because " - "vPC list query failed." - ) - return _set_lightweight_context(fallback_have) + if fallback_have: + nrm.module.warn( + "Using requested delete config as fallback existing set because " + "vPC list query failed." + ) + return _set_lightweight_context(fallback_have) + + if config: + nrm.module.warn( + "Delete config did not contain complete vPC pairs. " + "No delete intents can be built from list-query fallback." + ) + return _set_lightweight_context([]) - if config: nrm.module.warn( - "Delete config did not contain complete vPC pairs. " - "No delete intents can be built from list-query fallback." + "Delete-all requested with no explicit pairs and unavailable list endpoint. " + "Falling back to switch-level discovery." ) - return _set_lightweight_context([]) - - nrm.module.warn( - "Delete-all requested with no explicit pairs and unavailable list endpoint. " - "Falling back to switch-level discovery." - ) # Step 2 (write-state enrichment): Query and validate fabric switches. fabric_switches = _validate_fabric_switches(nd_v2, fabric_name) @@ -437,7 +474,6 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic pending_delete = [] processed_switches = set() - desired_pairs = {} config_switch_ids = set() for item in config: # Config items are normalized to snake_case in main(). @@ -449,9 +485,6 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic if peer_switch_id_val: config_switch_ids.add(peer_switch_id_val) - if switch_id_val and peer_switch_id_val: - desired_pairs[tuple(sorted([switch_id_val, peer_switch_id_val]))] = item - for switch_id, switch in fabric_switches.items(): if switch_id in processed_switches: continue @@ -488,26 +521,6 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic membership = _is_switch_in_vpc_pair( nd_v2, fabric_name, switch_id, timeout=5 ) - if membership is False: - pair_key = None - if resolved_peer_switch_id: - pair_key = tuple(sorted([switch_id, resolved_peer_switch_id])) - desired_item = desired_pairs.get(pair_key) if pair_key else None - desired_use_vpl = None - if desired_item: - desired_use_vpl = desired_item.get("use_virtual_peer_link") - if desired_use_vpl is None: - desired_use_vpl = desired_item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) - - # Narrow override: trust direct payload only for write states - # when it matches desired pair intent. - if state in ("merged", "replaced", "overridden") and desired_item is not None: - if desired_use_vpl is None or bool(desired_use_vpl) == bool(use_vpl): - nrm.module.warn( - f"Overview membership check returned 'not paired' for switch {switch_id}, " - "but direct /vpcPair matched requested config. Treating pair as active." - ) - membership = True if membership is False: pending_delete.append({ VpcFieldNames.SWITCH_ID: switch_id, @@ -573,22 +586,6 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic membership = _is_switch_in_vpc_pair( nd_v2, fabric_name, switch_id, timeout=5 ) - if membership is False: - pair_key = tuple(sorted([switch_id, peer_switch_id])) - desired_item = desired_pairs.get(pair_key) - desired_use_vpl = None - if desired_item: - desired_use_vpl = desired_item.get("use_virtual_peer_link") - if desired_use_vpl is None: - desired_use_vpl = desired_item.get(VpcFieldNames.USE_VIRTUAL_PEER_LINK) - - if state in ("merged", "replaced", "overridden") and desired_item is not None: - if desired_use_vpl is None or bool(desired_use_vpl) == bool(use_vpl): - nrm.module.warn( - f"Overview membership check returned 'not paired' for switch {switch_id}, " - "but direct /vpcPair matched requested config. Treating pair as active." - ) - membership = True if membership is False: pending_delete.append({ VpcFieldNames.SWITCH_ID: switch_id, @@ -632,11 +629,14 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic nrm.module.params["_pending_delete"] = pending_delete # Build effective existing set for state reconciliation: - # - Include active pairs (have) and pending-create pairs. + # - Include only active pairs (have). # - Exclude pending-delete pairs from active set to avoid stale # idempotence false-negatives right after unpair operations. + # + # Pending-create candidates are recommendations, not configured pairs. + # Treating them as existing causes false no-change outcomes for create. pair_by_key = {} - for pair in pending_create + have: + for pair in have: switch_id = pair.get(VpcFieldNames.SWITCH_ID) peer_switch_id = pair.get(VpcFieldNames.PEER_SWITCH_ID) if not switch_id or not peer_switch_id: diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index 31c6d24c..53e58dd5 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -11,7 +11,6 @@ --- module: nd_manage_vpc_pair short_description: Manage vPC pairs in Nexus devices. -version_added: "1.0.0" description: - Create, update, delete, override, and gather vPC pairs on Nexus devices. - Uses NDStateMachine framework with a vPC orchestrator. @@ -151,7 +150,7 @@ - peer1_switch_id: "FDO23040Q85" peer2_switch_id: "FDO23040Q86" -# Native Ansible check mode (dry-run behavior) +# Native Ansible check_mode behavior - name: Check mode vPC pair creation cisco.nd.nd_manage_vpc_pair: fabric_name: myFabric @@ -327,7 +326,7 @@ sample: [] """ -from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.common.log import setup_logging from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( ValidationError, @@ -353,10 +352,6 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( VpcPairPlaybookConfigModel, ) -from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( - DEEPDIFF_IMPORT_ERROR, - HAS_DEEPDIFF, -) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_deploy import ( _needs_deployment, custom_vpc_deploy, @@ -383,12 +378,6 @@ def main(): module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) setup_logging(module) - if not HAS_DEEPDIFF: - module.fail_json( - msg=missing_required_lib("deepdiff"), - exception=DEEPDIFF_IMPORT_ERROR - ) - try: module_config = VpcPairPlaybookConfigModel.model_validate( module.params, by_alias=True, by_name=True @@ -450,7 +439,9 @@ def main(): ) # Normalize config keys for runtime/state-machine model handling. - normalized_config = [item.to_runtime_config() for item in module_config.config] + normalized_config = [ + item.to_runtime_config() for item in (module_config.config or []) + ] module.params["config"] = normalized_config diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/base_tasks.yaml b/tests/integration/targets/nd_vpc_pair/tasks/base_tasks.yaml similarity index 94% rename from tests/integration/targets/nd_vpc_pair/tests/nd/base_tasks.yaml rename to tests/integration/targets/nd_vpc_pair/tasks/base_tasks.yaml index 8d5690f1..3cb9147c 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/base_tasks.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/base_tasks.yaml @@ -46,4 +46,7 @@ cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: deleted + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" ignore_errors: true diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml b/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml similarity index 70% rename from tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml rename to tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml index ceb8fa7d..c4031560 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/conf_prep_tasks.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml @@ -11,11 +11,11 @@ - name: Build vPC Pair Config Data from Template ansible.builtin.template: - src: "{{ playbook_dir | dirname }}/templates/nd_vpc_pair_conf.j2" - dest: "{{ playbook_dir | dirname }}/files/nd_vpc_pair_{{ file }}_conf.yaml" + src: "{{ role_path }}/templates/nd_vpc_pair_conf.j2" + dest: "{{ role_path }}/files/nd_vpc_pair_{{ file }}_conf.yaml" delegate_to: localhost - name: Load Configuration Data into Variable ansible.builtin.set_fact: - "{{ 'nd_vpc_pair_' + file + '_conf' }}": "{{ lookup('file', (playbook_dir | dirname) + '/files/nd_vpc_pair_' + file + '_conf.yaml') | from_yaml }}" + "{{ 'nd_vpc_pair_' + file + '_conf' }}": "{{ lookup('file', role_path + '/files/nd_vpc_pair_' + file + '_conf.yaml') | from_yaml }}" delegate_to: localhost diff --git a/tests/integration/targets/nd_vpc_pair/tasks/main.yaml b/tests/integration/targets/nd_vpc_pair/tasks/main.yaml index 1ca161e9..430f621b 100644 --- a/tests/integration/targets/nd_vpc_pair/tasks/main.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/main.yaml @@ -1,32 +1,41 @@ --- # Test discovery and execution for nd_vpc_pair integration tests. # -# Usage: -# ansible-playbook -i hosts.yaml tasks/main.yaml # run all tests -# ansible-playbook -i hosts.yaml tasks/main.yaml -e testcase=nd_vpc_pair_merge # run one -# ansible-playbook -i hosts.yaml tasks/main.yaml --tags merge # run by tag +# Optional: +# -e testcase=nd_vpc_pair_merge +# --tags merge -- name: nd_vpc_pair integration tests - hosts: nd - gather_facts: false - tasks: - - name: Discover nd_vpc_pair test cases - ansible.builtin.find: - paths: "{{ playbook_dir }}/../tests/nd" - patterns: "{{ testcase | default('nd_vpc_pair_*') }}.yaml" - connection: local - register: nd_vpc_pair_testcases +- 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: Build list of test items - ansible.builtin.set_fact: - test_items: "{{ nd_vpc_pair_testcases.files | map(attribute='path') | list }}" +- name: Discover nd_vpc_pair test cases + ansible.builtin.find: + paths: "{{ role_path }}/tasks" + patterns: "{{ testcase | default('nd_vpc_pair_*') }}.yaml" + file_type: file + connection: local + register: nd_vpc_pair_testcases - - name: Display discovered tests - ansible.builtin.debug: - msg: "Discovered {{ test_items | length }} test file(s): {{ test_items | map('basename') | list }}" +- name: Build list of test items + ansible.builtin.set_fact: + test_items: "{{ nd_vpc_pair_testcases.files | map(attribute='path') | sort | list }}" - - name: Run nd_vpc_pair test cases - ansible.builtin.include_tasks: "{{ test_case_to_run }}" - with_items: "{{ test_items }}" - loop_control: - loop_var: test_case_to_run +- name: Assert nd_vpc_pair test discovery has matches + ansible.builtin.assert: + that: + - test_items | length > 0 + fail_msg: >- + No nd_vpc_pair test cases matched pattern + '{{ testcase | default("nd_vpc_pair_*") }}.yaml' under '{{ role_path }}/tasks'. + +- name: Display discovered tests + ansible.builtin.debug: + msg: "Discovered {{ test_items | length }} test file(s): {{ test_items | map('basename') | list }}" + +- name: Run nd_vpc_pair test cases + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + loop: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_delete.yaml similarity index 99% rename from tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml rename to tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_delete.yaml index 9865b02f..8a119abd 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_delete.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_delete.yaml @@ -59,6 +59,7 @@ cisco.nd.tests.integration.nd_vpc_pair_validate: gathered_data: "{{ verify_result }}" expected_data: "{{ nd_vpc_pair_delete_setup_conf }}" + mode: "exists" register: validation tags: delete diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml similarity index 100% rename from tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_gather.yaml rename to tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml similarity index 96% rename from tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml rename to tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml index 1748ddf2..e9ba11d3 100644 --- a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_merge.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml @@ -675,12 +675,22 @@ }} tags: merge -- name: MERGE - TC14 - ASSERT - Ensure support candidates are available - ansible.builtin.assert: - that: - - blocked_switch_id | length > 0 - - allowed_switch_id | length > 0 - - blocked_switch_id != allowed_switch_id +- name: MERGE - TC14 - PREP - Determine if support enforcement scenario is available + ansible.builtin.set_fact: + tc14_supported_scenario: >- + {{ + (blocked_switch_id | length > 0) + and (allowed_switch_id | length > 0) + and (blocked_switch_id != allowed_switch_id) + }} + tags: merge + +- name: MERGE - TC14 - INFO - Skip support enforcement validation when no blocked switch exists + ansible.builtin.debug: + msg: >- + Skipping TC14 because no switch reports isPairingAllowed=false in this lab. + blocked_switch_id='{{ blocked_switch_id }}', allowed_switch_id='{{ allowed_switch_id }}' + when: not tc14_supported_scenario tags: merge - name: MERGE - TC14 - MERGE - Verify unsupported pairing is blocked by module @@ -693,6 +703,7 @@ use_virtual_peer_link: true register: result ignore_errors: true + when: tc14_supported_scenario tags: merge - name: MERGE - TC14 - ASSERT - Validate unsupported pairing failure details @@ -711,6 +722,7 @@ and (result.conflicts is defined) and ((result.conflicts | length) > 0) ) + when: tc14_supported_scenario tags: merge ############################################## diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_override.yaml similarity index 100% rename from tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_override.yaml rename to tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_override.yaml diff --git a/tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_replace.yaml similarity index 100% rename from tests/integration/targets/nd_vpc_pair/tests/nd/nd_vpc_pair_replace.yaml rename to tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_replace.yaml From 252718670158e99ef262ab65df00da543e88e5c5 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Tue, 24 Mar 2026 14:47:59 +0530 Subject: [PATCH 35/39] Fine tuning the comments --- .../module_utils/manage_vpc_pair/resources.py | 122 +++++++- .../manage_vpc_pair/runtime_endpoints.py | 118 ++++++++ .../manage_vpc_pair/runtime_payloads.py | 34 ++- .../models/manage_vpc_pair/base.py | 103 ++++++- .../models/manage_vpc_pair/model.py | 113 +++++++- .../module_utils/nd_manage_vpc_pair_common.py | 27 +- .../module_utils/nd_manage_vpc_pair_deploy.py | 7 + .../nd_manage_vpc_pair_exceptions.py | 8 + .../module_utils/nd_manage_vpc_pair_query.py | 261 +++++++++++++++++- .../module_utils/nd_manage_vpc_pair_runner.py | 11 +- .../nd_manage_vpc_pair_validation.py | 56 +++- .../orchestrators/manage_vpc_pair.py | 84 +++++- plugins/modules/nd_manage_vpc_pair.py | 21 +- .../targets/nd_vpc_pair/tasks/main.yaml | 69 ++--- 14 files changed, 970 insertions(+), 64 deletions(-) diff --git a/plugins/module_utils/manage_vpc_pair/resources.py b/plugins/module_utils/manage_vpc_pair/resources.py index 173a1d94..a748f953 100644 --- a/plugins/module_utils/manage_vpc_pair/resources.py +++ b/plugins/module_utils/manage_vpc_pair/resources.py @@ -39,6 +39,16 @@ class VpcPairStateMachine(NDStateMachine): """NDStateMachine adapter with state handling for nd_manage_vpc_pair.""" def __init__(self, module: AnsibleModule): + """ + Initialize VpcPairStateMachine. + + Creates the underlying NDStateMachine with VpcPairOrchestrator, binds + the state machine back to the orchestrator, and initializes log/result + containers. + + Args: + module: AnsibleModule instance with validated params + """ super().__init__(module=module, model_orchestrator=VpcPairOrchestrator) self.model_orchestrator.bind_state_machine(self) self.current_identifier = None @@ -55,7 +65,16 @@ def format_log( after_data: Optional[Any] = None, sent_payload_data: Optional[Any] = None, ) -> None: - """Collect operation log entries expected by nd_manage_vpc_pair flows.""" + """ + Collect operation log entries expected by nd_manage_vpc_pair flows. + + Args: + identifier: Pair identifier tuple (switch_id, peer_switch_id) + status: Operation status (created, updated, deleted, no_change) + before_data: Optional before-state dict for the pair + after_data: Optional after-state dict for the pair + sent_payload_data: Optional API payload that was sent + """ log_entry: Dict[str, Any] = {"identifier": identifier, "status": status} if before_data is not None: log_entry["before"] = before_data @@ -68,6 +87,10 @@ def format_log( def add_logs_and_outputs(self) -> None: """ Build final result payload compatible with nd_manage_vpc_pair runtime. + + Refreshes after-state from controller, walks all log entries to build + the output dict with before, after, current, diff, created, deleted, + updated lists. Populates self.result with the final Ansible output. """ self._refresh_after_state() self.output.assign( @@ -99,7 +122,14 @@ def _refresh_after_state(self) -> None: Optionally refresh the final "after" state from controller query. Enabled by default for write states to better reflect live controller - state. Can be disabled for performance-sensitive runs. + state. Can be disabled for performance-sensitive runs via + suppress_verification or refresh_after_apply params. + + Skipped when: + - State is gathered (read-only) + - Running in check mode + - suppress_verification is True + - refresh_after_apply is False """ state = self.module.params.get("state") if state not in ("merged", "replaced", "overridden", "deleted"): @@ -138,6 +168,12 @@ def _refresh_after_state(self) -> None: def _identifier_to_key(identifier: Any) -> str: """ Build a stable key for de-duplicating identifiers in class diff output. + + Args: + identifier: Pair identifier (tuple, string, or any serializable value) + + Returns: + JSON string representation of the identifier for use as dict key. """ try: return json.dumps(identifier, sort_keys=True, default=str) @@ -148,6 +184,13 @@ def _identifier_to_key(identifier: Any) -> str: def _extract_changed_properties(log_entry: Dict[str, Any]) -> List[str]: """ Best-effort changed-property extraction for update operations. + + Args: + log_entry: Single log entry dict with before/after/sent_payload keys + + Returns: + Sorted list of property names that changed between before and after. + Falls back to sent_payload keys if before/after comparison yields nothing. """ before = log_entry.get("before") after = log_entry.get("after") @@ -166,6 +209,12 @@ def _extract_changed_properties(log_entry: Dict[str, Any]) -> List[str]: def _build_class_diff(self) -> Dict[str, List[Any]]: """ Build class-level diff with created/deleted/updated entries. + + Walks all log entries, deduplicates by identifier key, and sorts each + into created/deleted/updated buckets based on operation status. + + Returns: + Dict with 'created', 'deleted', 'updated' lists of identifiers. """ created: List[Any] = [] deleted: List[Any] = [] @@ -210,6 +259,21 @@ def manage_state( unwanted_keys: Optional[List] = None, override_exceptions: Optional[List] = None, ) -> None: + """ + Execute state reconciliation for the given state and config items. + + Builds proposed and previous NDConfigCollection objects, then dispatches + to create/update or delete handlers based on state. + + Args: + state: Desired state (merged, replaced, overridden, deleted) + new_configs: List of config dicts from playbook + unwanted_keys: Optional keys to exclude from diff comparison + override_exceptions: Optional identifiers to skip during override deletions + + Raises: + VpcPairResourceError: On validation or processing failures + """ unwanted_keys = unwanted_keys or [] override_exceptions = override_exceptions or [] @@ -247,6 +311,19 @@ def manage_state( raise VpcPairResourceError(msg=f"Invalid state: {state}") def _manage_create_update_state(self, state: str, unwanted_keys: List) -> None: + """ + Process proposed config items for create or update operations. + + Loops over each proposed config item, diffs against existing state. + Creates new pairs, updates changed pairs, and skips unchanged pairs. + + Args: + state: Current state (merged, replaced, overridden) + unwanted_keys: Keys to exclude from diff comparison + + Raises: + VpcPairResourceError: If create/update fails for an item + """ for proposed_item in self.proposed: identifier = proposed_item.get_identifier_value() try: @@ -337,6 +414,17 @@ def _manage_create_update_state(self, state: str, unwanted_keys: List) -> None: ) def _manage_override_deletions(self, override_exceptions: List) -> None: + """ + Delete pairs that exist on controller but are not in proposed config. + + Used by overridden state to remove unspecified pairs. + + Args: + override_exceptions: List of identifiers to skip (not delete) + + Raises: + VpcPairResourceError: If deletion fails for a pair + """ diff_identifiers = self.previous.get_diff_identifiers(self.proposed) for identifier in diff_identifiers: if identifier in override_exceptions: @@ -374,6 +462,15 @@ def _manage_override_deletions(self, override_exceptions: List) -> None: ) def _manage_delete_state(self) -> None: + """ + Process proposed config items for delete operations. + + Loops over each proposed delete item, finds matching existing pair, + calls orchestrator.delete(), and removes from collection. + + Raises: + VpcPairResourceError: If deletion fails for an item + """ for proposed_item in self.proposed: identifier = proposed_item.get_identifier_value() try: @@ -425,12 +522,33 @@ def __init__( deploy_handler: DeployHandler, needs_deployment_handler: NeedsDeployHandler, ): + """ + Initialize VpcPairResourceService. + + Args: + module: AnsibleModule instance with validated params + run_state_handler: Callback for state execution (run_vpc_module) + deploy_handler: Callback for deployment (custom_vpc_deploy) + needs_deployment_handler: Callback to check if deploy is needed (_needs_deployment) + """ self.module = module self.run_state_handler = run_state_handler self.deploy_handler = deploy_handler self.needs_deployment_handler = needs_deployment_handler def execute(self, fabric_name: str) -> Dict[str, Any]: + """ + Execute the full vpc_pair module lifecycle. + + Creates VpcPairStateMachine, runs state handler, optionally deploys. + + Args: + fabric_name: Fabric name to operate on + + Returns: + Dict with complete module result including before, after, current, + changed, deployment info, and ip_to_sn_mapping. + """ nd_manage_vpc_pair = VpcPairStateMachine(module=self.module) result = self.run_state_handler(nd_manage_vpc_pair) diff --git a/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py b/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py index 87e1f580..58d9ec6a 100644 --- a/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py +++ b/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py @@ -72,6 +72,16 @@ class VpcPairEndpoints: @staticmethod def _append_query(path: str, *query_groups: EndpointQueryParams) -> str: + """ + Append query parameters to an endpoint path. + + Args: + path: Base URL path + *query_groups: One or more EndpointQueryParams to serialize + + Returns: + Path with query string appended, or original path if no params. + """ composite_params = CompositeQueryParams() for query_group in query_groups: composite_params.add(query_group) @@ -80,36 +90,104 @@ def _append_query(path: str, *query_groups: EndpointQueryParams) -> str: @staticmethod def vpc_pair_base(fabric_name: str) -> str: + """ + Build base path for vPC pairs list endpoint. + + Args: + fabric_name: Fabric name + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/vpcPairs + """ endpoint = EpVpcPairsListGet(fabric_name=fabric_name) return endpoint.path @staticmethod def vpc_pairs_list(fabric_name: str) -> str: + """ + Build path for listing all vPC pairs in a fabric. + + Args: + fabric_name: Fabric name + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/vpcPairs + """ endpoint = EpVpcPairsListGet(fabric_name=fabric_name) return endpoint.path @staticmethod def vpc_pair_put(fabric_name: str, switch_id: str) -> str: + """ + Build path for PUT (create/update/delete) on a switch vPC pair. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + """ endpoint = EpVpcPairPut(fabric_name=fabric_name, switch_id=switch_id) return endpoint.path @staticmethod def fabric_switches(fabric_name: str) -> str: + """ + Build path for querying fabric switch inventory. + + Args: + fabric_name: Fabric name + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/switches + """ endpoint = EpFabricSwitchesGet(fabric_name=fabric_name) return endpoint.path @staticmethod def switch_vpc_pair(fabric_name: str, switch_id: str) -> str: + """ + Build path for GET/PUT on a specific switch vPC pair. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair + """ endpoint = EpVpcPairGet(fabric_name=fabric_name, switch_id=switch_id) return endpoint.path @staticmethod def switch_vpc_recommendations(fabric_name: str, switch_id: str) -> str: + """ + Build path for querying vPC pair recommendations for a switch. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + Path: .../switches/{switchId}/vpcPairRecommendation + """ endpoint = EpVpcPairRecommendationGet(fabric_name=fabric_name, switch_id=switch_id) return endpoint.path @staticmethod def switch_vpc_overview(fabric_name: str, switch_id: str, component_type: str = "full") -> str: + """ + Build path for querying vPC pair overview for a switch. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + component_type: Overview filter (default: "full") + + Returns: + Path: .../switches/{switchId}/vpcPairOverview?componentType={type} + """ endpoint = EpVpcPairOverviewGet(fabric_name=fabric_name, switch_id=switch_id) base_path = endpoint.path query_params = _ComponentTypeQueryParams(component_type=component_type) @@ -121,6 +199,17 @@ def switch_vpc_support( switch_id: str, component_type: str = ComponentTypeSupportEnum.CHECK_PAIRING.value, ) -> str: + """ + Build path for querying vPC pair support status for a switch. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + component_type: Support check type (default: checkPairing) + + Returns: + Path: .../switches/{switchId}/vpcPairSupport?componentType={type} + """ endpoint = EpVpcPairSupportGet( fabric_name=fabric_name, switch_id=switch_id, @@ -132,16 +221,45 @@ def switch_vpc_support( @staticmethod def switch_vpc_consistency(fabric_name: str, switch_id: str) -> str: + """ + Build path for querying vPC pair consistency diagnostics. + + Args: + fabric_name: Fabric name + switch_id: Switch serial number + + Returns: + Path: .../switches/{switchId}/vpcPairConsistency + """ endpoint = EpVpcPairConsistencyGet(fabric_name=fabric_name, switch_id=switch_id) return endpoint.path @staticmethod def fabric_config_save(fabric_name: str) -> str: + """ + Build path for fabric config-save action. + + Args: + fabric_name: Fabric name + + Returns: + Path: /api/v1/manage/fabrics/{fabricName}/actions/configSave + """ endpoint = EpFabricConfigSavePost(fabric_name=fabric_name) return endpoint.path @staticmethod def fabric_config_deploy(fabric_name: str, force_show_run: bool = True) -> str: + """ + Build path for fabric deploy action. + + Args: + fabric_name: Fabric name + force_show_run: Whether to include forceShowRun query param (default: True) + + Returns: + Path: .../fabrics/{fabricName}/actions/deploy?forceShowRun=true + """ endpoint = EpFabricDeployPost(fabric_name=fabric_name) base_path = endpoint.path query_params = _ForceShowRunQueryParams( diff --git a/plugins/module_utils/manage_vpc_pair/runtime_payloads.py b/plugins/module_utils/manage_vpc_pair/runtime_payloads.py index d697e028..16ad5a47 100644 --- a/plugins/module_utils/manage_vpc_pair/runtime_payloads.py +++ b/plugins/module_utils/manage_vpc_pair/runtime_payloads.py @@ -22,7 +22,15 @@ def _get_template_config(vpc_pair_model) -> Optional[Dict[str, Any]]: - """Extract template configuration from a vPC pair model if present.""" + """ + Extract template configuration from a vPC pair model if present. + + Args: + vpc_pair_model: VpcPairModel instance with optional vpc_pair_details field + + Returns: + Dict with serialized template config, or None if not present. + """ if not hasattr(vpc_pair_model, "vpc_pair_details"): return None @@ -34,7 +42,17 @@ def _get_template_config(vpc_pair_model) -> Optional[Dict[str, Any]]: def _build_vpc_pair_payload(vpc_pair_model) -> Dict[str, Any]: - """Build pair payload with vpcAction discriminator for ND 4.2 APIs.""" + """ + Build pair payload with vpcAction discriminator for ND 4.2 APIs. + + Args: + vpc_pair_model: VpcPairModel instance or dict with switchId, + peerSwitchId, useVirtualPeerLink fields + + Returns: + Dict with vpcAction, switchId, peerSwitchId, useVirtualPeerLink, + and optional vpcPairDetails keys. + """ if isinstance(vpc_pair_model, dict): switch_id = vpc_pair_model.get(VpcFieldNames.SWITCH_ID) peer_switch_id = vpc_pair_model.get(VpcFieldNames.PEER_SWITCH_ID) @@ -67,7 +85,17 @@ def _build_vpc_pair_payload(vpc_pair_model) -> Dict[str, Any]: def _get_api_field_value(api_response: Dict[str, Any], field_name: str, default=None): - """Get a field value across known ND API naming aliases.""" + """ + Get a field value across known ND API naming aliases. + + Args: + api_response: API response dict to search + field_name: Primary field name to look up + default: Default value if field not found in any alias + + Returns: + Field value from the response, or default if not found. + """ if not isinstance(api_response, dict): return default diff --git a/plugins/module_utils/models/manage_vpc_pair/base.py b/plugins/module_utils/models/manage_vpc_pair/base.py index 492de9dd..419a9251 100644 --- a/plugins/module_utils/models/manage_vpc_pair/base.py +++ b/plugins/module_utils/models/manage_vpc_pair/base.py @@ -20,7 +20,18 @@ def coerce_str_to_int(data): - """Convert string to int, handle None.""" + """ + Convert string to int, handle None. + + Args: + data: Value to coerce (str, int, or None) + + Returns: + Integer value, or None if input is None. + + Raises: + ValueError: If string cannot be converted to int + """ if data is None: return None if isinstance(data, str): @@ -31,7 +42,16 @@ def coerce_str_to_int(data): def coerce_to_bool(data): - """Convert various formats to bool.""" + """ + Convert various formats to bool. + + Args: + data: Value to coerce (str, bool, int, or None) + + Returns: + Boolean value, or None if input is None. + Strings 'true', '1', 'yes', 'on' map to True. + """ if data is None: return None if isinstance(data, str): @@ -40,7 +60,16 @@ def coerce_to_bool(data): def coerce_list_of_str(data): - """Ensure data is a list of strings.""" + """ + Ensure data is a list of strings. + + Args: + data: Value to coerce (str, list, or None) + + Returns: + List of strings, or None if input is None. + Comma-separated strings are split into list items. + """ if data is None: return None if isinstance(data, str): @@ -76,17 +105,43 @@ class NDVpcPairBaseModel(BaseModel, ABC): @abstractmethod def to_payload(self) -> Dict[str, Any]: - """Convert model to API payload format.""" + """ + Convert model to API payload format. + + Returns: + Dict with camelCase API field names. + """ pass @classmethod @abstractmethod def from_response(cls, response: Dict[str, Any]) -> Self: - """Create model instance from API response.""" + """ + Create model instance from API response. + + Args: + response: Dict from ND API response + + Returns: + Validated model instance. + """ pass def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: - """Extract identifier value(s) from this instance.""" + """ + Extract identifier value(s) from this instance. + + Uses the configured identifier_strategy (single, composite, or hierarchical) + to determine how to extract and return the identifier. + + Returns: + Single value for 'single' strategy, tuple for 'composite', + or (field_name, value) tuple for 'hierarchical'. + + Raises: + ValueError: If identifiers are not defined, required fields are None, + or strategy is unknown. + """ if not self.identifiers: raise ValueError(f"{self.__class__.__name__} has no identifiers defined") @@ -124,7 +179,15 @@ def get_identifier_value(self) -> Union[str, int, Tuple[Any, ...]]: raise ValueError(f"Unknown identifier strategy: {self.identifier_strategy}") def get_switch_pair_key(self) -> str: - """Generate a unique key for VPC pair (sorted switch IDs).""" + """ + Generate a unique key for VPC pair (sorted switch IDs). + + Returns: + Deterministic "ID1-ID2" string with sorted switch serial numbers. + + Raises: + ValueError: If identifier_strategy is not composite with 2 identifiers + """ if self.identifier_strategy != "composite" or len(self.identifiers) != 2: raise ValueError( "get_switch_pair_key only works with composite strategy and 2 identifiers" @@ -135,7 +198,12 @@ def get_switch_pair_key(self) -> str: return f"{sorted_ids[0]}-{sorted_ids[1]}" def to_diff_dict(self) -> Dict[str, Any]: - """Export for diff comparison (excludes sensitive fields).""" + """ + Export for diff comparison (excludes sensitive fields). + + Returns: + Dict with alias keys, excluding None and exclude_from_diff fields. + """ return self.model_dump( by_alias=True, exclude_none=True, @@ -143,7 +211,15 @@ def to_diff_dict(self) -> Dict[str, Any]: ) def get_diff(self, other: "NDVpcPairBaseModel") -> bool: - """Return True when ``other`` is a subset of this model for diff checks.""" + """ + Return True when ``other`` is a subset of this model for diff checks. + + Args: + other: Model instance to compare against + + Returns: + True if other's diff dict is a subset of self's diff dict. + """ self_data = self.to_diff_dict() other_data = other.to_diff_dict() return issubset(other_data, self_data) @@ -153,6 +229,15 @@ def merge(self, other: "NDVpcPairBaseModel") -> "NDVpcPairBaseModel": Merge another model's non-None values into this model instance. Nested NDVpcPairBaseModel values are merged recursively. + + Args: + other: Model instance whose non-None fields overwrite this model + + Returns: + Self with merged values. + + Raises: + TypeError: If other is not the same type as self """ if not isinstance(other, type(self)): raise TypeError( diff --git a/plugins/module_utils/models/manage_vpc_pair/model.py b/plugins/module_utils/models/manage_vpc_pair/model.py index 40530b9d..7b3c4983 100644 --- a/plugins/module_utils/models/manage_vpc_pair/model.py +++ b/plugins/module_utils/models/manage_vpc_pair/model.py @@ -57,13 +57,13 @@ class VpcPairModel(_VpcPairBaseModel): switch_id: str = Field( alias=VpcFieldNames.SWITCH_ID, - description="Peer-1 switch serial number", + description="Peer-1 switch serial number or management IP address", min_length=3, max_length=64, ) peer_switch_id: str = Field( alias=VpcFieldNames.PEER_SWITCH_ID, - description="Peer-2 switch serial number", + description="Peer-2 switch serial number or management IP address", min_length=3, max_length=64, ) @@ -82,12 +82,33 @@ class VpcPairModel(_VpcPairBaseModel): @field_validator("switch_id", "peer_switch_id") @classmethod def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Raw switch ID string + + Returns: + Stripped switch ID string. + + Raises: + ValueError: If switch ID is empty or whitespace-only + """ if not v or not v.strip(): raise ValueError("Switch ID cannot be empty or whitespace") return v.strip() @model_validator(mode="after") def validate_different_switches(self) -> "VpcPairModel": + """ + Validate that switch_id and peer_switch_id are not the same. + + Returns: + Self if validation passes. + + Raises: + ValueError: If both switch IDs are identical + """ if self.switch_id == self.peer_switch_id: raise ValueError( f"switch_id and peer_switch_id must be different: {self.switch_id}" @@ -95,9 +116,21 @@ def validate_different_switches(self) -> "VpcPairModel": return self def to_payload(self) -> Dict[str, Any]: + """ + Serialize model to camelCase API payload dict. + + Returns: + Dict with alias (camelCase) keys, excluding None values. + """ return self.model_dump(by_alias=True, exclude_none=True) def to_diff_dict(self) -> Dict[str, Any]: + """ + Serialize model for diff comparison, excluding configured fields. + + Returns: + Dict with alias keys, excluding None and exclude_from_diff fields. + """ return self.model_dump( by_alias=True, exclude_none=True, @@ -105,13 +138,39 @@ def to_diff_dict(self) -> Dict[str, Any]: ) def get_identifier_value(self): + """ + Return the unique identifier for this vPC pair. + + Returns: + Tuple of sorted (switch_id, peer_switch_id) for order-independent matching. + """ return tuple(sorted([self.switch_id, self.peer_switch_id])) def to_config(self, **kwargs) -> Dict[str, Any]: + """ + Serialize model to snake_case Ansible config dict. + + Args: + **kwargs: Additional kwargs passed to model_dump + + Returns: + Dict with Python-name keys, excluding None values. + """ return self.model_dump(by_alias=False, exclude_none=True, **kwargs) @classmethod def from_config(cls, ansible_config: Dict[str, Any]) -> "VpcPairModel": + """ + Construct VpcPairModel from playbook config dict. + + Accepts both snake_case module input and API camelCase aliases. + + Args: + ansible_config: Dict from playbook config item + + Returns: + Validated VpcPairModel instance. + """ data = dict(ansible_config or {}) # Accept both snake_case module input and API camelCase aliases. @@ -130,6 +189,18 @@ def from_config(cls, ansible_config: Dict[str, Any]) -> "VpcPairModel": return cls.model_validate(data, by_alias=True, by_name=True) def merge(self, other_model: "VpcPairModel") -> "VpcPairModel": + """ + Merge non-None values from another model into this instance. + + Args: + other_model: VpcPairModel whose non-None fields overwrite this model + + Returns: + Self with merged values. + + Raises: + TypeError: If other_model is not the same type + """ if not isinstance(other_model, type(self)): raise TypeError( "VpcPairModel.merge requires both models to be the same type" @@ -143,6 +214,15 @@ def merge(self, other_model: "VpcPairModel") -> "VpcPairModel": @classmethod def from_response(cls, response: Dict[str, Any]) -> "VpcPairModel": + """ + Construct VpcPairModel from an API response dict. + + Args: + response: Dict from ND API response + + Returns: + Validated VpcPairModel instance. + """ data = { VpcFieldNames.SWITCH_ID: response.get(VpcFieldNames.SWITCH_ID), VpcFieldNames.PEER_SWITCH_ID: response.get(VpcFieldNames.PEER_SWITCH_ID), @@ -179,13 +259,13 @@ class VpcPairPlaybookItemModel(BaseModel): peer1_switch_id: str = Field( alias="switch_id", - description="Peer-1 switch serial number", + description="Peer-1 switch serial number or management IP address", min_length=3, max_length=64, ) peer2_switch_id: str = Field( alias="peer_switch_id", - description="Peer-2 switch serial number", + description="Peer-2 switch serial number or management IP address", min_length=3, max_length=64, ) @@ -203,12 +283,33 @@ class VpcPairPlaybookItemModel(BaseModel): @field_validator("peer1_switch_id", "peer2_switch_id") @classmethod def validate_switch_id_format(cls, v: str) -> str: + """ + Validate switch ID is not empty or whitespace. + + Args: + v: Raw switch ID string + + Returns: + Stripped switch ID string. + + Raises: + ValueError: If switch ID is empty or whitespace-only + """ if not v or not v.strip(): raise ValueError("Switch ID cannot be empty or whitespace") return v.strip() @model_validator(mode="after") def validate_different_switches(self) -> "VpcPairPlaybookItemModel": + """ + Validate that peer1_switch_id and peer2_switch_id are not the same. + + Returns: + Self if validation passes. + + Raises: + ValueError: If both switch IDs are identical + """ if self.peer1_switch_id == self.peer2_switch_id: raise ValueError( "peer1_switch_id and peer2_switch_id must be different: " @@ -219,6 +320,10 @@ def validate_different_switches(self) -> "VpcPairPlaybookItemModel": def to_runtime_config(self) -> Dict[str, Any]: """ Normalize playbook keys into runtime keys consumed by state machine code. + + Returns: + Dict with both snake_case and camelCase keys for switch IDs, + use_virtual_peer_link, and vpc_pair_details. """ switch_id = self.peer1_switch_id peer_switch_id = self.peer2_switch_id diff --git a/plugins/module_utils/nd_manage_vpc_pair_common.py b/plugins/module_utils/nd_manage_vpc_pair_common.py index 9bc483b5..dc744dca 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_common.py +++ b/plugins/module_utils/nd_manage_vpc_pair_common.py @@ -14,6 +14,16 @@ def _collection_to_list_flex(collection) -> List[Dict[str, Any]]: """ Serialize NDConfigCollection across old/new framework variants. + + Tries multiple serialization methods in order to support different + NDConfigCollection implementations. + + Args: + collection: NDConfigCollection instance or None + + Returns: + List of dicts from the collection. Empty list if collection is None + or has no recognized serialization method. """ if collection is None: return [] @@ -27,7 +37,16 @@ def _collection_to_list_flex(collection) -> List[Dict[str, Any]]: def _raise_vpc_error(msg: str, **details: Any) -> None: - """Raise a structured vpc_pair error for main() to format via fail_json.""" + """ + Raise a structured vpc_pair error for main() to format via fail_json. + + Args: + msg: Human-readable error message + **details: Arbitrary keyword args passed to VpcPairResourceError + + Raises: + VpcPairResourceError: Always raised with msg and details + """ raise VpcPairResourceError(msg=msg, **details) @@ -40,6 +59,12 @@ def _canonicalize_for_compare(value: Any) -> Any: Lists are sorted by canonical JSON representation so list ordering does not trigger false-positive update detection. + + Args: + value: Any nested data structure (dict, list, or primitive) + + Returns: + Canonicalized copy with sorted dicts and sorted lists. """ if isinstance(value, dict): return { diff --git a/plugins/module_utils/nd_manage_vpc_pair_deploy.py b/plugins/module_utils/nd_manage_vpc_pair_deploy.py index 886ea69c..d4b23f04 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_deploy.py +++ b/plugins/module_utils/nd_manage_vpc_pair_deploy.py @@ -61,6 +61,13 @@ def _needs_deployment(result: Dict, nrm) -> bool: def _is_non_fatal_config_save_error(error: NDModuleError) -> bool: """ Return True only for known non-fatal configSave platform limitations. + + Args: + error: NDModuleError from config-save API call + + Returns: + True if the error matches a known non-fatal 500 signature + (e.g. fabric peering not supported). False otherwise. """ if not isinstance(error, NDModuleError): return False diff --git a/plugins/module_utils/nd_manage_vpc_pair_exceptions.py b/plugins/module_utils/nd_manage_vpc_pair_exceptions.py index 2bf77816..9c033582 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_exceptions.py +++ b/plugins/module_utils/nd_manage_vpc_pair_exceptions.py @@ -11,6 +11,14 @@ class VpcPairResourceError(Exception): """Structured error raised by vpc_pair runtime layers.""" def __init__(self, msg: str, **details: Any): + """ + Initialize VpcPairResourceError. + + Args: + msg: Human-readable error message + **details: Arbitrary keyword args for structured error context + (e.g. fabric, vpc_pair_key, missing_switches) + """ super().__init__(msg) self.msg = msg self.details = details diff --git a/plugins/module_utils/nd_manage_vpc_pair_query.py b/plugins/module_utils/nd_manage_vpc_pair_query.py index 1eb9272e..14fd918f 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_query.py +++ b/plugins/module_utils/nd_manage_vpc_pair_query.py @@ -5,6 +5,7 @@ from __future__ import absolute_import, division, print_function +import ipaddress from typing import Any, Dict, List, Optional from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum @@ -15,6 +16,12 @@ _is_switch_in_vpc_pair, _validate_fabric_switches, ) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_common import ( + _raise_vpc_error, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_exceptions import ( + VpcPairResourceError, +) from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.runtime_endpoints import ( VpcPairEndpoints, ) @@ -118,6 +125,13 @@ def _extract_vpc_pairs_from_list_response(vpc_pairs_response: Any) -> List[Dict[ Extract VPC pair list entries from /vpcPairs response payload. Supports common response wrappers used by ND API. + + Args: + vpc_pairs_response: Raw API response dict from /vpcPairs list endpoint + + Returns: + List of dicts with switchId, peerSwitchId, useVirtualPeerLink keys. + Empty list if response is invalid or contains no pairs. """ if not isinstance(vpc_pairs_response, dict): return [] @@ -173,6 +187,16 @@ def _enrich_pairs_from_direct_vpc( The /vpcPairs list response may omit fields like useVirtualPeerLink. This helper preserves lightweight list discovery while improving field accuracy for gathered output. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + pairs: List of pair dicts from list endpoint + timeout: Per-switch query timeout in seconds + + Returns: + List of enriched pair dicts with updated field values from direct queries. + Original values preserved when direct query fails. """ if not pairs: return [] @@ -227,6 +251,15 @@ def _filter_stale_vpc_pairs( `/vpcPairs` can briefly lag after unpair operations. We perform a lightweight best-effort membership check and drop entries that are explicitly reported as not part of a vPC pair. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + pairs: List of pair dicts to validate + module: AnsibleModule instance for warnings + + Returns: + Filtered list of pair dicts with stale entries removed. """ if not pairs: return [] @@ -259,6 +292,14 @@ def _filter_vpc_pairs_by_requested_config( If gathered config is empty or does not contain complete switch pairs, return the unfiltered pair list. + + Args: + pairs: List of discovered pair dicts from API + config: List of user-requested pair dicts from playbook + + Returns: + Filtered list of pair dicts matching requested config keys. + Returns full pair list when config is empty or has no complete pairs. """ if not pairs or not config: return list(pairs or []) @@ -285,6 +326,189 @@ def _filter_vpc_pairs_by_requested_config( return filtered_pairs +def _is_ip_literal(value: Any) -> bool: + """ + Return True when value is a valid IPv4/IPv6 literal string. + + Args: + value: Any value to check + + Returns: + True if value is a valid IP address string, False otherwise. + """ + if not isinstance(value, str): + return False + candidate = value.strip() + if not candidate: + return False + try: + ipaddress.ip_address(candidate) + return True + except ValueError: + return False + + +def _resolve_config_switch_ips( + nd_v2, + module, + fabric_name: str, + config: List[Dict[str, Any]], +): + """ + Resolve switch identifiers from management IPs to serial numbers. + + If config contains IP literals in switch fields, query fabric switch inventory + and replace those IPs with serial numbers in both snake_case and API keys. + + Args: + nd_v2: NDModuleV2 instance for RestSend + module: AnsibleModule instance for warnings + fabric_name: Fabric name for inventory lookup + config: List of config item dicts (may contain IP-based switch IDs) + + Returns: + Tuple of (normalized_config, ip_to_sn_mapping, fabric_switches_dict). + Returns (original_config, {}, None) when no IPs are found. + """ + if not config: + return list(config or []), {}, None + + has_ip_inputs = False + for item in config: + if not isinstance(item, dict): + continue + for key in ("switch_id", VpcFieldNames.SWITCH_ID, "peer_switch_id", VpcFieldNames.PEER_SWITCH_ID): + if _is_ip_literal(item.get(key)): + has_ip_inputs = True + break + if has_ip_inputs: + break + + if not has_ip_inputs: + return list(config), {}, None + + fabric_switches = _validate_fabric_switches(nd_v2, fabric_name) + ip_to_sn = { + str(sw.get(VpcFieldNames.FABRIC_MGMT_IP)).strip(): sw.get(VpcFieldNames.SERIAL_NUMBER) + for sw in fabric_switches.values() + if sw.get(VpcFieldNames.FABRIC_MGMT_IP) and sw.get(VpcFieldNames.SERIAL_NUMBER) + } + + if not ip_to_sn: + module.warn( + "Switch IP identifiers were provided in config, but no " + "fabricManagementIp to serialNumber mapping was discovered. " + "Continuing with identifiers as provided." + ) + return list(config), {}, fabric_switches + + normalized_config: List[Dict[str, Any]] = [] + resolved_inputs: Dict[str, str] = {} + unresolved_inputs = set() + + for item in config: + if not isinstance(item, dict): + normalized_config.append(item) + continue + + normalized_item = dict(item) + for snake_key, api_key in ( + ("switch_id", VpcFieldNames.SWITCH_ID), + ("peer_switch_id", VpcFieldNames.PEER_SWITCH_ID), + ): + raw_identifier = normalized_item.get(snake_key) + if raw_identifier is None: + raw_identifier = normalized_item.get(api_key) + if raw_identifier is None: + continue + + resolved_identifier = raw_identifier + if _is_ip_literal(raw_identifier): + ip_value = str(raw_identifier).strip() + mapped_serial = ip_to_sn.get(ip_value) + if mapped_serial: + resolved_identifier = mapped_serial + resolved_inputs[ip_value] = mapped_serial + else: + unresolved_inputs.add(ip_value) + + normalized_item[snake_key] = resolved_identifier + normalized_item[api_key] = resolved_identifier + + normalized_config.append(normalized_item) + + for ip_value, serial in sorted(resolved_inputs.items()): + module.warn( + f"Resolved playbook switch IP {ip_value} to switch serial {serial} " + f"for fabric {fabric_name}." + ) + + if unresolved_inputs: + module.warn( + "Could not resolve playbook switch IP(s) to serial numbers for " + f"fabric {fabric_name}: {', '.join(sorted(unresolved_inputs))}. " + "Those values will be processed as provided." + ) + + return normalized_config, ip_to_sn, fabric_switches + + +def normalize_vpc_playbook_switch_identifiers( + module, + nd_v2=None, + fabric_name: Optional[str] = None, + state: Optional[str] = None, +): + """ + Normalize playbook switch identifiers from management IPs to serial numbers. + + Updates module params in-place: + - merged/replaced/overridden/deleted: module.params["config"] + - gathered: module.params["_gather_filter_config"] + + Also merges resolved IP->serial mappings into module.params["_ip_to_sn_mapping"]. + + Args: + module: AnsibleModule instance + nd_v2: Optional NDModuleV2 instance (created internally if None) + fabric_name: Optional fabric name override (defaults to module param) + state: Optional state override (defaults to module param) + + Returns: + Optional[Dict[str, Dict]]: Preloaded fabric switches map when queried, else None. + """ + effective_state = state or module.params.get("state", "merged") + effective_fabric = fabric_name if fabric_name is not None else module.params.get("fabric_name") + + if effective_state == "gathered": + config = module.params.get("_gather_filter_config") or [] + else: + config = module.params.get("config") or [] + + if nd_v2 is None: + nd_v2 = NDModuleV2(module) + + config, resolved_ip_to_sn, preloaded_fabric_switches = _resolve_config_switch_ips( + nd_v2=nd_v2, + module=module, + fabric_name=effective_fabric, + config=config, + ) + + if effective_state == "gathered": + module.params["_gather_filter_config"] = list(config) + else: + module.params["config"] = list(config) + + if resolved_ip_to_sn: + existing_map = module.params.get("_ip_to_sn_mapping") or {} + merged_map = dict(existing_map) if isinstance(existing_map, dict) else {} + merged_map.update(resolved_ip_to_sn) + module.params["_ip_to_sn_mapping"] = merged_map + + return preloaded_fabric_switches + + def custom_vpc_query_all(nrm) -> List[Dict]: """ Query existing VPC pairs with state-aware enrichment. @@ -294,6 +518,17 @@ def custom_vpc_query_all(nrm) -> List[Dict]: - gathered/deleted: use lightweight list-only data when available - merged/replaced/overridden: enrich with switch inventory and recommendation APIs to build have/pending_create/pending_delete sets + + Args: + nrm: VpcPairStateMachine or query context with .module attribute + + Returns: + List of existing pair dicts for NDConfigCollection initialization. + Also populates module params: _have, _pending_create, _pending_delete, + _fabric_switches, _fabric_switches_count, _ip_to_sn_mapping. + + Raises: + VpcPairResourceError: On unrecoverable query failures """ fabric_name = nrm.module.params.get("fabric_name") @@ -301,18 +536,27 @@ def custom_vpc_query_all(nrm) -> List[Dict]: raise ValueError(f"fabric_name must be a non-empty string. Got: {fabric_name!r}") state = nrm.module.params.get("state", "merged") + # Initialize RestSend via NDModuleV2 + nd_v2 = NDModuleV2(nrm.module) + preloaded_fabric_switches = normalize_vpc_playbook_switch_identifiers( + module=nrm.module, + nd_v2=nd_v2, + fabric_name=fabric_name, + state=state, + ) + if state == "gathered": config = nrm.module.params.get("_gather_filter_config") or [] else: config = nrm.module.params.get("config") or [] - # Initialize RestSend via NDModuleV2 - nd_v2 = NDModuleV2(nrm.module) - def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dict[str, Any]]: nrm.module.params["_fabric_switches"] = [] nrm.module.params["_fabric_switches_count"] = 0 - nrm.module.params["_ip_to_sn_mapping"] = {} + existing_map = nrm.module.params.get("_ip_to_sn_mapping") + nrm.module.params["_ip_to_sn_mapping"] = ( + dict(existing_map) if isinstance(existing_map, dict) else {} + ) nrm.module.params["_have"] = lightweight_have nrm.module.params["_pending_create"] = [] nrm.module.params["_pending_delete"] = [] @@ -445,7 +689,9 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic ) # Step 2 (write-state enrichment): Query and validate fabric switches. - fabric_switches = _validate_fabric_switches(nd_v2, fabric_name) + fabric_switches = preloaded_fabric_switches + if fabric_switches is None: + fabric_switches = _validate_fabric_switches(nd_v2, fabric_name) if not fabric_switches: nrm.module.warn(f"No switches found in fabric {fabric_name}") @@ -467,7 +713,10 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic for sw in fabric_switches.values() if VpcFieldNames.FABRIC_MGMT_IP in sw } - nrm.module.params["_ip_to_sn_mapping"] = ip_to_sn + existing_map = nrm.module.params.get("_ip_to_sn_mapping") or {} + merged_map = dict(existing_map) if isinstance(existing_map, dict) else {} + merged_map.update(ip_to_sn) + nrm.module.params["_ip_to_sn_mapping"] = merged_map # Step 3: Track 3-state VPC pairs (have/pending_create/pending_delete). pending_create = [] diff --git a/plugins/module_utils/nd_manage_vpc_pair_runner.py b/plugins/module_utils/nd_manage_vpc_pair_runner.py index ca7376b0..ded8f549 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_runner.py +++ b/plugins/module_utils/nd_manage_vpc_pair_runner.py @@ -18,7 +18,16 @@ def run_vpc_module(nrm) -> Dict[str, Any]: """ Run VPC module state machine with VPC-specific gathered output. - gathered is the query/read-only mode for VPC pairs. + Top-level state router. For gathered: builds read-only output filtering out + pending-delete pairs. For deleted/overridden with empty config: synthesizes + explicit delete intents. Otherwise delegates to nrm.manage_state(). + + Args: + nrm: VpcPairStateMachine instance + + Returns: + Dict with module result including current, gathered, before, after, + changed, created, deleted, updated keys. """ state = nrm.module.params.get("state", "merged") config = nrm.module.params.get("config", []) diff --git a/plugins/module_utils/nd_manage_vpc_pair_validation.py b/plugins/module_utils/nd_manage_vpc_pair_validation.py index 70ab8e9b..52cbd994 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_validation.py +++ b/plugins/module_utils/nd_manage_vpc_pair_validation.py @@ -34,6 +34,20 @@ def _get_pairing_support_details( ) -> Optional[Dict[str, Any]]: """ Query /vpcPairSupport endpoint to validate pairing support. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + component_type: Support check type (default: checkPairing) + timeout: Optional timeout override (uses module query_timeout if not specified) + + Returns: + Dict with support details, or None if response is not a dict. + + Raises: + ValueError: If fabric_name or switch_id are invalid + NDModuleError: On API errors """ if not fabric_name or not isinstance(fabric_name, str): raise ValueError(f"Invalid fabric_name: {fabric_name}") @@ -75,6 +89,14 @@ def _validate_fabric_peering_support( If API explicitly reports unsupported fabric peering, logs warning and continues. If support API is unavailable, logs warning and continues. + + Args: + nrm: VpcPairStateMachine instance for logging warnings + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Primary switch serial number + peer_switch_id: Peer switch serial number + use_virtual_peer_link: Whether virtual peer link is requested """ if not use_virtual_peer_link: return @@ -121,6 +143,19 @@ def _get_consistency_details( ) -> Optional[Dict[str, Any]]: """ Query /vpcPairConsistency endpoint for consistency diagnostics. + + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + timeout: Optional timeout override (uses module query_timeout if not specified) + + Returns: + Dict with consistency details, or None if response is not a dict. + + Raises: + ValueError: If fabric_name or switch_id are invalid + NDModuleError: On API errors """ if not fabric_name or not isinstance(fabric_name, str): raise ValueError(f"Invalid fabric_name: {fabric_name}") @@ -154,10 +189,16 @@ def _is_switch_in_vpc_pair( """ Best-effort active-membership check via vPC overview endpoint. + Args: + nd_v2: NDModuleV2 instance for RestSend + fabric_name: Fabric name + switch_id: Switch serial number + timeout: Optional timeout override (uses module query_timeout if not specified) + Returns: - - True: overview query succeeded (switch is part of a vPC pair) - - False: API explicitly reports switch is not in a vPC pair - - None: unknown/error (do not block caller logic) + True: overview query succeeded (switch is part of a vPC pair) + False: API explicitly reports switch is not in a vPC pair + None: unknown/error (do not block caller logic) """ if not fabric_name or not switch_id: return None @@ -347,6 +388,15 @@ def _validate_switches_exist_in_fabric( This check is mandatory for create/update. Empty inventory is treated as a validation error to avoid bypassing guardrails and failing later with a less actionable API error. + + Args: + nrm: VpcPairStateMachine instance with module params containing _fabric_switches + fabric_name: Fabric name for error messages + switch_id: Primary switch serial number + peer_switch_id: Peer switch serial number + + Raises: + VpcPairResourceError: If switches are missing from fabric inventory """ fabric_switches = nrm.module.params.get("_fabric_switches") diff --git a/plugins/module_utils/orchestrators/manage_vpc_pair.py b/plugins/module_utils/orchestrators/manage_vpc_pair.py index 492b2eb6..abc92820 100644 --- a/plugins/module_utils/orchestrators/manage_vpc_pair.py +++ b/plugins/module_utils/orchestrators/manage_vpc_pair.py @@ -17,13 +17,25 @@ ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_query import ( custom_vpc_query_all, + normalize_vpc_playbook_switch_identifiers, ) class _VpcPairQueryContext: - """Minimal context object for query_all during NDStateMachine initialization.""" + """ + Minimal context object for query_all during NDStateMachine initialization. + + Provides a .module attribute so custom_vpc_query_all can access module params + before the full state machine is constructed. + """ def __init__(self, module: AnsibleModule): + """ + Initialize query context. + + Args: + module: AnsibleModule instance + """ self.module = module @@ -43,6 +55,17 @@ def __init__( sender: Optional[Any] = None, **kwargs, ): + """ + Initialize VpcPairOrchestrator. + + Args: + module: AnsibleModule instance (preferred) + sender: Optional NDModule/NDModuleV2 with .module attribute + **kwargs: Ignored (for framework compatibility) + + Raises: + ValueError: If neither module nor sender provides an AnsibleModule + """ _ = kwargs if module is None and sender is not None: module = getattr(sender, "module", None) @@ -57,12 +80,32 @@ def __init__( self.state_machine = None def bind_state_machine(self, state_machine: Any) -> None: + """ + Link orchestrator to its parent state machine. + + Args: + state_machine: VpcPairStateMachine instance for CRUD handler access + """ self.state_machine = state_machine def query_all(self): + """ + Query all existing vPC pairs from the controller. + + If suppress_previous is True, skips the controller query and only + normalizes switch IP identifiers. Otherwise delegates to + custom_vpc_query_all for full discovery. + + Returns: + List of existing pair dicts for NDConfigCollection initialization. + """ # Optional performance knob: skip initial query used to build "before" # state and baseline diff in NDStateMachine initialization. if self.state_machine is None and self.module.params.get("suppress_previous", False): + # Even when the before-query is skipped, normalize any IP-based + # switch identifiers in playbook config so downstream model/action + # code always receives serial numbers. + normalize_vpc_playbook_switch_identifiers(self.module) return [] context = ( @@ -73,18 +116,57 @@ def query_all(self): return custom_vpc_query_all(context) def create(self, model_instance, **kwargs): + """ + Create a new vPC pair via custom_vpc_create handler. + + Args: + model_instance: VpcPairModel instance (unused, context from state machine) + **kwargs: Ignored + + Returns: + API response from create operation. + + Raises: + RuntimeError: If orchestrator is not bound to a state machine + """ _ = (model_instance, kwargs) if self.state_machine is None: raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") return custom_vpc_create(self.state_machine) def update(self, model_instance, **kwargs): + """ + Update an existing vPC pair via custom_vpc_update handler. + + Args: + model_instance: VpcPairModel instance (unused, context from state machine) + **kwargs: Ignored + + Returns: + API response from update operation. + + Raises: + RuntimeError: If orchestrator is not bound to a state machine + """ _ = (model_instance, kwargs) if self.state_machine is None: raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") return custom_vpc_update(self.state_machine) def delete(self, model_instance, **kwargs): + """ + Delete a vPC pair via custom_vpc_delete handler. + + Args: + model_instance: VpcPairModel instance (unused, context from state machine) + **kwargs: Ignored + + Returns: + API response from delete operation, or False if already unpaired. + + Raises: + RuntimeError: If orchestrator is not bound to a state machine + """ _ = (model_instance, kwargs) if self.state_machine is None: raise RuntimeError("VpcPairOrchestrator is not bound to a state machine") diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index 53e58dd5..258da2d9 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -94,12 +94,12 @@ suboptions: peer1_switch_id: description: - - Peer1 switch serial number for the vPC pair. + - Peer1 switch serial number or management IP address for the vPC pair. required: true type: str peer2_switch_id: description: - - Peer2 switch serial number for the vPC pair. + - Peer2 switch serial number or management IP address for the vPC pair. required: true type: str use_virtual_peer_link: @@ -134,6 +134,16 @@ - peer1_switch_id: "FDO23040Q85" peer2_switch_id: "FDO23040Q86" +# Create a new vPC pair using management IPs +- name: Create vPC pair with switch management IPs + cisco.nd.nd_manage_vpc_pair: + fabric_name: myFabric + state: merged + config: + - peer1_switch_id: "10.10.10.11" + peer2_switch_id: "10.10.10.12" + use_virtual_peer_link: true + # Gather existing vPC pairs - name: Gather all vPC pairs cisco.nd.nd_manage_vpc_pair: @@ -368,10 +378,17 @@ def main(): """ Module entry point combining framework + RestSend. + Builds argument spec from Pydantic models, validates state-level rules, + normalizes config keys, creates VpcPairResourceService with handler + callbacks, and delegates execution. + Architecture: - Thin module entrypoint delegates to VpcPairResourceService - VpcPairResourceService handles NDStateMachine orchestration - Custom actions use RestSend (NDModuleV2) for HTTP with retry logic + + Raises: + VpcPairResourceError: Converted to module.fail_json with structured details """ argument_spec = VpcPairPlaybookConfigModel.get_argument_spec() diff --git a/tests/integration/targets/nd_vpc_pair/tasks/main.yaml b/tests/integration/targets/nd_vpc_pair/tasks/main.yaml index 430f621b..8eda593f 100644 --- a/tests/integration/targets/nd_vpc_pair/tasks/main.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/main.yaml @@ -1,41 +1,46 @@ --- # Test discovery and execution for nd_vpc_pair integration tests. # -# Optional: -# -e testcase=nd_vpc_pair_merge -# --tags merge +# Usage: +# ansible-playbook -i hosts.yaml tasks/main.yaml # run all tests +# ansible-playbook -i hosts.yaml tasks/main.yaml -e testcase=nd_vpc_pair_merge # run one +# ansible-playbook -i hosts.yaml tasks/main.yaml --tags merge # run by tag -- 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: nd_vpc_pair integration tests + hosts: nd + gather_facts: false + tasks: + - 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: Discover nd_vpc_pair test cases - ansible.builtin.find: - paths: "{{ role_path }}/tasks" - patterns: "{{ testcase | default('nd_vpc_pair_*') }}.yaml" - file_type: file - connection: local - register: nd_vpc_pair_testcases + - name: Discover nd_vpc_pair test cases + ansible.builtin.find: + paths: "{{ playbook_dir }}" + patterns: "{{ testcase | default('nd_vpc_pair_*') }}.yaml" + file_type: file + connection: local + register: nd_vpc_pair_testcases -- name: Build list of test items - ansible.builtin.set_fact: - test_items: "{{ nd_vpc_pair_testcases.files | map(attribute='path') | sort | list }}" + - name: Build list of test items + ansible.builtin.set_fact: + test_items: "{{ nd_vpc_pair_testcases.files | map(attribute='path') | sort | list }}" -- name: Assert nd_vpc_pair test discovery has matches - ansible.builtin.assert: - that: - - test_items | length > 0 - fail_msg: >- - No nd_vpc_pair test cases matched pattern - '{{ testcase | default("nd_vpc_pair_*") }}.yaml' under '{{ role_path }}/tasks'. + - name: Assert nd_vpc_pair test discovery has matches + ansible.builtin.assert: + that: + - test_items | length > 0 + fail_msg: >- + No nd_vpc_pair test cases matched pattern + '{{ testcase | default("nd_vpc_pair_*") }}.yaml' under '{{ playbook_dir }}'. -- name: Display discovered tests - ansible.builtin.debug: - msg: "Discovered {{ test_items | length }} test file(s): {{ test_items | map('basename') | list }}" + - name: Display discovered tests + ansible.builtin.debug: + msg: "Discovered {{ test_items | length }} test file(s): {{ test_items | map('basename') | list }}" -- name: Run nd_vpc_pair test cases - ansible.builtin.include_tasks: "{{ test_case_to_run }}" - loop: "{{ test_items }}" - loop_control: - loop_var: test_case_to_run + - name: Run nd_vpc_pair test cases + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + loop: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run From 1ba1cf778929ea9b35b40c3cafb8afc4274aac49 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 25 Mar 2026 10:59:18 +0530 Subject: [PATCH 36/39] Intermediate fixes --- .../manage_fabrics_switches_vpc_pair.py | 41 ++++++++++++++++--- ...e_fabrics_switches_vpc_pair_consistency.py | 23 +++++++++-- ...nage_fabrics_switches_vpc_pair_overview.py | 28 ++++++++++--- ...abrics_switches_vpc_pair_recommendation.py | 28 ++++++++++--- ...anage_fabrics_switches_vpc_pair_support.py | 28 ++++++++++--- .../v1/manage/manage_fabrics_vpc_pairs.py | 28 ++++++++++--- .../manage_vpc_pair/runtime_endpoints.py | 24 +++++------ .../nd_vpc_pair/tasks/conf_prep_tasks.yaml | 6 +-- 8 files changed, 159 insertions(+), 47 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py index 2a11b488..fa352b07 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair.py @@ -19,6 +19,9 @@ SwitchIdMixin, TicketIdMixin, ) +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, ) @@ -32,7 +35,6 @@ class _EpVpcPairBase( FabricNameMixin, SwitchIdMixin, - FromClusterMixin, NDEndpointBaseModel, ): model_config = COMMON_CONFIG @@ -41,13 +43,25 @@ class _EpVpcPairBase( def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return BasePath.path( + base_path = BasePath.path( "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPair", ) + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + +class VpcPairGetEndpointParams(FromClusterMixin, EndpointQueryParams): + """Endpoint-specific query parameters for vPC pair GET endpoint.""" + + +class VpcPairPutEndpointParams(VpcPairGetEndpointParams, TicketIdMixin): + """Endpoint-specific query parameters for vPC pair PUT endpoint.""" class EpVpcPairGet(_EpVpcPairBase): @@ -57,25 +71,40 @@ class EpVpcPairGet(_EpVpcPairBase): api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") - class_name: Literal["EpVpcPairGet"] = Field(default="EpVpcPairGet") + class_name: Literal["EpVpcPairGet"] = Field( + default="EpVpcPairGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairGetEndpointParams = Field( + default_factory=VpcPairGetEndpointParams, description="Endpoint-specific query parameters" + ) @property def verb(self) -> HttpVerbEnum: return HttpVerbEnum.GET -class EpVpcPairPut(_EpVpcPairBase, TicketIdMixin): +class EpVpcPairPut(_EpVpcPairBase): """ PUT /api/v1/manage/fabrics/{fabricName}/switches/{switchId}/vpcPair """ api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") - class_name: Literal["EpVpcPairPut"] = Field(default="EpVpcPairPut") + class_name: Literal["EpVpcPairPut"] = Field( + default="EpVpcPairPut", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairPutEndpointParams = Field( + default_factory=VpcPairPutEndpointParams, description="Endpoint-specific query parameters" + ) @property def verb(self) -> HttpVerbEnum: return HttpVerbEnum.PUT -__all__ = ["EpVpcPairGet", "EpVpcPairPut"] +__all__ = [ + "EpVpcPairGet", + "EpVpcPairPut", + "VpcPairGetEndpointParams", + "VpcPairPutEndpointParams", +] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py index 8dcb78e6..869c408e 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_consistency.py @@ -18,6 +18,9 @@ FromClusterMixin, SwitchIdMixin, ) +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, ) @@ -28,10 +31,13 @@ COMMON_CONFIG = ConfigDict(validate_assignment=True) +class VpcPairConsistencyEndpointParams(FromClusterMixin, EndpointQueryParams): + """Endpoint-specific query parameters for vPC pair consistency endpoint.""" + + class EpVpcPairConsistencyGet( FabricNameMixin, SwitchIdMixin, - FromClusterMixin, NDEndpointBaseModel, ): """ @@ -41,23 +47,32 @@ class EpVpcPairConsistencyGet( model_config = COMMON_CONFIG api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") - class_name: Literal["EpVpcPairConsistencyGet"] = Field(default="EpVpcPairConsistencyGet") + class_name: Literal["EpVpcPairConsistencyGet"] = Field( + default="EpVpcPairConsistencyGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairConsistencyEndpointParams = Field( + default_factory=VpcPairConsistencyEndpointParams, description="Endpoint-specific query parameters" + ) @property def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return BasePath.path( + base_path = BasePath.path( "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairConsistency", ) + 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 HttpVerbEnum.GET -__all__ = ["EpVpcPairConsistencyGet"] +__all__ = ["EpVpcPairConsistencyGet", "VpcPairConsistencyEndpointParams"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py index 85137ffd..717e3db7 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_overview.py @@ -19,6 +19,9 @@ FromClusterMixin, SwitchIdMixin, ) +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, ) @@ -29,11 +32,17 @@ COMMON_CONFIG = ConfigDict(validate_assignment=True) +class VpcPairOverviewEndpointParams( + FromClusterMixin, + ComponentTypeMixin, + EndpointQueryParams, +): + """Endpoint-specific query parameters for vPC pair overview endpoint.""" + + class EpVpcPairOverviewGet( FabricNameMixin, SwitchIdMixin, - FromClusterMixin, - ComponentTypeMixin, NDEndpointBaseModel, ): """ @@ -43,23 +52,32 @@ class EpVpcPairOverviewGet( model_config = COMMON_CONFIG api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") - class_name: Literal["EpVpcPairOverviewGet"] = Field(default="EpVpcPairOverviewGet") + class_name: Literal["EpVpcPairOverviewGet"] = Field( + default="EpVpcPairOverviewGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairOverviewEndpointParams = Field( + default_factory=VpcPairOverviewEndpointParams, description="Endpoint-specific query parameters" + ) @property def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return BasePath.path( + base_path = BasePath.path( "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairOverview", ) + 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 HttpVerbEnum.GET -__all__ = ["EpVpcPairOverviewGet"] +__all__ = ["EpVpcPairOverviewGet", "VpcPairOverviewEndpointParams"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py index cd340804..c06a00ff 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py @@ -19,6 +19,9 @@ SwitchIdMixin, UseVirtualPeerLinkMixin, ) +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, ) @@ -29,11 +32,17 @@ COMMON_CONFIG = ConfigDict(validate_assignment=True) +class VpcPairRecommendationEndpointParams( + FromClusterMixin, + UseVirtualPeerLinkMixin, + EndpointQueryParams, +): + """Endpoint-specific query parameters for vPC pair recommendation endpoint.""" + + class EpVpcPairRecommendationGet( FabricNameMixin, SwitchIdMixin, - FromClusterMixin, - UseVirtualPeerLinkMixin, NDEndpointBaseModel, ): """ @@ -43,23 +52,32 @@ class EpVpcPairRecommendationGet( model_config = COMMON_CONFIG api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") - class_name: Literal["EpVpcPairRecommendationGet"] = Field(default="EpVpcPairRecommendationGet") + class_name: Literal["EpVpcPairRecommendationGet"] = Field( + default="EpVpcPairRecommendationGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairRecommendationEndpointParams = Field( + default_factory=VpcPairRecommendationEndpointParams, description="Endpoint-specific query parameters" + ) @property def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return BasePath.path( + base_path = BasePath.path( "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairRecommendation", ) + 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 HttpVerbEnum.GET -__all__ = ["EpVpcPairRecommendationGet"] +__all__ = ["EpVpcPairRecommendationGet", "VpcPairRecommendationEndpointParams"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py index a38d644c..8732782f 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_support.py @@ -19,6 +19,9 @@ FromClusterMixin, SwitchIdMixin, ) +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, ) @@ -29,11 +32,17 @@ COMMON_CONFIG = ConfigDict(validate_assignment=True) +class VpcPairSupportEndpointParams( + FromClusterMixin, + ComponentTypeMixin, + EndpointQueryParams, +): + """Endpoint-specific query parameters for vPC pair support endpoint.""" + + class EpVpcPairSupportGet( FabricNameMixin, SwitchIdMixin, - FromClusterMixin, - ComponentTypeMixin, NDEndpointBaseModel, ): """ @@ -43,23 +52,32 @@ class EpVpcPairSupportGet( model_config = COMMON_CONFIG api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") - class_name: Literal["EpVpcPairSupportGet"] = Field(default="EpVpcPairSupportGet") + class_name: Literal["EpVpcPairSupportGet"] = Field( + default="EpVpcPairSupportGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairSupportEndpointParams = Field( + default_factory=VpcPairSupportEndpointParams, description="Endpoint-specific query parameters" + ) @property def path(self) -> str: if self.fabric_name is None or self.switch_id is None: raise ValueError("fabric_name and switch_id are required") - return BasePath.path( + base_path = BasePath.path( "fabrics", self.fabric_name, "switches", self.switch_id, "vpcPairSupport", ) + 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 HttpVerbEnum.GET -__all__ = ["EpVpcPairSupportGet"] +__all__ = ["EpVpcPairSupportGet", "VpcPairSupportEndpointParams"] diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py index 303f9cf0..9fc2dce2 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_vpc_pairs.py @@ -21,6 +21,9 @@ SortMixin, ViewMixin, ) +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, ) @@ -31,13 +34,19 @@ COMMON_CONFIG = ConfigDict(validate_assignment=True) -class EpVpcPairsListGet( - FabricNameMixin, +class VpcPairsListEndpointParams( FromClusterMixin, FilterMixin, PaginationMixin, SortMixin, ViewMixin, + EndpointQueryParams, +): + """Endpoint-specific query parameters for vPC pairs list endpoint.""" + + +class EpVpcPairsListGet( + FabricNameMixin, NDEndpointBaseModel, ): """ @@ -47,17 +56,26 @@ class EpVpcPairsListGet( model_config = COMMON_CONFIG api_version: Literal["v1"] = Field(default="v1") min_controller_version: str = Field(default="3.0.0") - class_name: Literal["EpVpcPairsListGet"] = Field(default="EpVpcPairsListGet") + class_name: Literal["EpVpcPairsListGet"] = Field( + default="EpVpcPairsListGet", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: VpcPairsListEndpointParams = Field( + default_factory=VpcPairsListEndpointParams, description="Endpoint-specific query parameters" + ) @property def path(self) -> str: if self.fabric_name is None: raise ValueError("fabric_name is required") - return BasePath.path("fabrics", self.fabric_name, "vpcPairs") + base_path = BasePath.path("fabrics", self.fabric_name, "vpcPairs") + 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 HttpVerbEnum.GET -__all__ = ["EpVpcPairsListGet"] +__all__ = ["EpVpcPairsListGet", "VpcPairsListEndpointParams"] diff --git a/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py b/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py index 58d9ec6a..708ba268 100644 --- a/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py +++ b/plugins/module_utils/manage_vpc_pair/runtime_endpoints.py @@ -25,12 +25,14 @@ ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_overview import ( EpVpcPairOverviewGet, + VpcPairOverviewEndpointParams, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_recommendation import ( EpVpcPairRecommendationGet, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_support import ( EpVpcPairSupportGet, + VpcPairSupportEndpointParams, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( EpVpcPairsListGet, @@ -43,12 +45,6 @@ ) -class _ComponentTypeQueryParams(EndpointQueryParams): - """Query params for endpoints that require componentType.""" - - component_type: Optional[str] = None - - class _ForceShowRunQueryParams(EndpointQueryParams): """Query params for deploy endpoint.""" @@ -188,10 +184,12 @@ def switch_vpc_overview(fabric_name: str, switch_id: str, component_type: str = Returns: Path: .../switches/{switchId}/vpcPairOverview?componentType={type} """ - endpoint = EpVpcPairOverviewGet(fabric_name=fabric_name, switch_id=switch_id) - base_path = endpoint.path - query_params = _ComponentTypeQueryParams(component_type=component_type) - return VpcPairEndpoints._append_query(base_path, query_params) + endpoint = EpVpcPairOverviewGet( + fabric_name=fabric_name, + switch_id=switch_id, + endpoint_params=VpcPairOverviewEndpointParams(component_type=component_type), + ) + return endpoint.path @staticmethod def switch_vpc_support( @@ -213,11 +211,9 @@ def switch_vpc_support( endpoint = EpVpcPairSupportGet( fabric_name=fabric_name, switch_id=switch_id, - component_type=component_type, + endpoint_params=VpcPairSupportEndpointParams(component_type=component_type), ) - base_path = endpoint.path - query_params = _ComponentTypeQueryParams(component_type=component_type) - return VpcPairEndpoints._append_query(base_path, query_params) + return endpoint.path @staticmethod def switch_vpc_consistency(fabric_name: str, switch_id: str) -> str: diff --git a/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml b/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml index c4031560..feac6041 100644 --- a/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml @@ -11,11 +11,11 @@ - name: Build vPC Pair Config Data from Template ansible.builtin.template: - src: "{{ role_path }}/templates/nd_vpc_pair_conf.j2" - dest: "{{ role_path }}/files/nd_vpc_pair_{{ file }}_conf.yaml" + src: "{{ playbook_dir }}/../templates/nd_vpc_pair_conf.j2" + dest: "{{ playbook_dir }}/../files/nd_vpc_pair_{{ file }}_conf.yaml" delegate_to: localhost - name: Load Configuration Data into Variable ansible.builtin.set_fact: - "{{ 'nd_vpc_pair_' + file + '_conf' }}": "{{ lookup('file', role_path + '/files/nd_vpc_pair_' + file + '_conf.yaml') | from_yaml }}" + "{{ 'nd_vpc_pair_' + file + '_conf' }}": "{{ lookup('file', playbook_dir + '/../files/nd_vpc_pair_' + file + '_conf.yaml') | from_yaml }}" delegate_to: localhost From 78b90932fe8355758394507ecf69a2848ba7d71f Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 25 Mar 2026 18:30:03 +0530 Subject: [PATCH 37/39] Intermediate fix removing suppress_previous --- ...abrics_switches_vpc_pair_recommendation.py | 5 ++- .../module_utils/manage_vpc_pair/resources.py | 21 +++++++++--- .../models/manage_vpc_pair/model.py | 12 ------- .../module_utils/nd_manage_vpc_pair_query.py | 12 +++++-- .../module_utils/nd_manage_vpc_pair_runner.py | 7 ++++ .../orchestrators/manage_vpc_pair.py | 14 +------- plugins/modules/nd_manage_vpc_pair.py | 33 ------------------- .../nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml | 6 ++-- 8 files changed, 40 insertions(+), 70 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py index c06a00ff..0822b67a 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches_vpc_pair_recommendation.py @@ -4,7 +4,7 @@ # 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 Literal +from typing import Literal, Optional from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( ConfigDict, @@ -39,6 +39,9 @@ class VpcPairRecommendationEndpointParams( ): """Endpoint-specific query parameters for vPC pair recommendation endpoint.""" + # Keep this optional for this endpoint so query param is omitted unless explicitly set. + use_virtual_peer_link: Optional[bool] = Field(default=None, description="Optional virtual peer link flag") + class EpVpcPairRecommendationGet( FabricNameMixin, diff --git a/plugins/module_utils/manage_vpc_pair/resources.py b/plugins/module_utils/manage_vpc_pair/resources.py index a748f953..6d0e7e8c 100644 --- a/plugins/module_utils/manage_vpc_pair/resources.py +++ b/plugins/module_utils/manage_vpc_pair/resources.py @@ -51,6 +51,7 @@ def __init__(self, module: AnsibleModule): """ super().__init__(module=module, model_orchestrator=VpcPairOrchestrator) self.model_orchestrator.bind_state_machine(self) + self.current_identifier = None self.existing_config: Dict[str, Any] = {} self.proposed_config: Dict[str, Any] = {} @@ -113,6 +114,7 @@ def add_logs_and_outputs(self) -> None: formatted["deleted"] = class_diff["deleted"] formatted["updated"] = class_diff["updated"] formatted["class_diff"] = class_diff + if self.logs and "logs" not in formatted: formatted["logs"] = self.logs self.result = formatted @@ -140,6 +142,13 @@ def _refresh_after_state(self) -> None: return if not self.module.params.get("refresh_after_apply", True): return + if self.logs and not any( + log.get("status") in ("created", "updated", "deleted") + for log in self.logs + ): + # Skip refresh for pure no-op runs to avoid false changed flips from + # stale/synthetic before-state fallbacks. + return refresh_timeout = self.module.params.get("refresh_after_timeout") had_original_timeout = "query_timeout" in self.module.params @@ -439,11 +448,12 @@ def _manage_override_deletions(self, override_exceptions: List) -> None: by_alias=True, exclude_none=True ) delete_changed = self.model_orchestrator.delete(existing_item) - self.existing.delete(identifier) + if delete_changed is not False: + self.existing.delete(identifier) self.format_log( identifier=identifier, status="deleted" if delete_changed is not False else "no_change", - after_data={}, + after_data={} if delete_changed is not False else self.existing_config, ) except VpcPairResourceError as e: error_msg = f"Failed to delete {identifier}: {e.msg}" @@ -484,11 +494,12 @@ def _manage_delete_state(self) -> None: by_alias=True, exclude_none=True ) delete_changed = self.model_orchestrator.delete(existing_item) - self.existing.delete(identifier) + if delete_changed is not False: + self.existing.delete(identifier) self.format_log( identifier=identifier, status="deleted" if delete_changed is not False else "no_change", - after_data={}, + after_data={} if delete_changed is not False else self.existing_config, ) except VpcPairResourceError as e: error_msg = f"Failed to delete {identifier}: {e.msg}" @@ -556,7 +567,7 @@ def execute(self, fabric_name: str) -> Dict[str, Any]: result["ip_to_sn_mapping"] = self.module.params["_ip_to_sn_mapping"] deploy = self.module.params.get("deploy", False) - if deploy and not self.module.check_mode: + if deploy: deploy_result = self.deploy_handler(nd_manage_vpc_pair, fabric_name, result) result["deployment"] = deploy_result result["deployment_needed"] = deploy_result.get( diff --git a/plugins/module_utils/models/manage_vpc_pair/model.py b/plugins/module_utils/models/manage_vpc_pair/model.py index 7b3c4983..0ee775f8 100644 --- a/plugins/module_utils/models/manage_vpc_pair/model.py +++ b/plugins/module_utils/models/manage_vpc_pair/model.py @@ -390,10 +390,6 @@ class VpcPairPlaybookConfigModel(BaseModel): default=None, description="Optional timeout for post-apply refresh query", ) - suppress_previous: bool = Field( - default=False, - description="Skip initial before-state query (merged state only)", - ) suppress_verification: bool = Field( default=False, description="Skip final after-state refresh query", @@ -455,14 +451,6 @@ def get_argument_spec(cls) -> Dict[str, Any]: "refresh query" ), ), - suppress_previous=dict( - type="bool", - default=False, - description=( - "Skip initial controller query for before/diff baseline. " - "Supported only with state=merged." - ), - ), suppress_verification=dict( type="bool", default=False, diff --git a/plugins/module_utils/nd_manage_vpc_pair_query.py b/plugins/module_utils/nd_manage_vpc_pair_query.py index 14fd918f..7de29add 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_query.py +++ b/plugins/module_utils/nd_manage_vpc_pair_query.py @@ -536,6 +536,7 @@ def custom_vpc_query_all(nrm) -> List[Dict]: raise ValueError(f"fabric_name must be a non-empty string. Got: {fabric_name!r}") state = nrm.module.params.get("state", "merged") + nrm.module.params["_pending_state_known"] = True # Initialize RestSend via NDModuleV2 nd_v2 = NDModuleV2(nrm.module) preloaded_fabric_switches = normalize_vpc_playbook_switch_identifiers( @@ -550,7 +551,10 @@ def custom_vpc_query_all(nrm) -> List[Dict]: else: config = nrm.module.params.get("config") or [] - def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def _set_lightweight_context( + lightweight_have: List[Dict[str, Any]], + pending_state_known: bool = True, + ) -> List[Dict[str, Any]]: nrm.module.params["_fabric_switches"] = [] nrm.module.params["_fabric_switches_count"] = 0 existing_map = nrm.module.params.get("_ip_to_sn_mapping") @@ -560,6 +564,7 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic nrm.module.params["_have"] = lightweight_have nrm.module.params["_pending_create"] = [] nrm.module.params["_pending_delete"] = [] + nrm.module.params["_pending_state_known"] = pending_state_known return lightweight_have try: @@ -629,7 +634,10 @@ def _set_lightweight_context(lightweight_have: List[Dict[str, Any]]) -> List[Dic module=nrm.module, ) if have: - return _set_lightweight_context(have) + return _set_lightweight_context( + lightweight_have=have, + pending_state_known=False, + ) nrm.module.warn( "vPC list query returned no active pairs for gathered workflow. " "Falling back to switch-level discovery." diff --git a/plugins/module_utils/nd_manage_vpc_pair_runner.py b/plugins/module_utils/nd_manage_vpc_pair_runner.py index ded8f549..fdd2fdc0 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_runner.py +++ b/plugins/module_utils/nd_manage_vpc_pair_runner.py @@ -37,6 +37,7 @@ def run_vpc_module(nrm) -> Dict[str, Any]: nrm.result["changed"] = False current_pairs = nrm.result.get("current", []) or [] + pending_state_known = nrm.module.params.get("_pending_state_known", True) pending_delete = nrm.module.params.get("_pending_delete", []) or [] # Exclude pairs in pending-delete from active gathered set. @@ -62,7 +63,13 @@ def run_vpc_module(nrm) -> Dict[str, Any]: "vpc_pairs": filtered_current, "pending_create_vpc_pairs": nrm.module.params.get("_pending_create", []), "pending_delete_vpc_pairs": pending_delete, + "pending_state_known": pending_state_known, } + if not pending_state_known: + nrm.result["gathered"]["pending_state_note"] = ( + "Pending create/delete lists are unavailable in lightweight gather mode " + "and are provided as empty placeholders." + ) return nrm.result # state=deleted with empty config means "delete all existing pairs in this fabric". diff --git a/plugins/module_utils/orchestrators/manage_vpc_pair.py b/plugins/module_utils/orchestrators/manage_vpc_pair.py index abc92820..fde20351 100644 --- a/plugins/module_utils/orchestrators/manage_vpc_pair.py +++ b/plugins/module_utils/orchestrators/manage_vpc_pair.py @@ -17,7 +17,6 @@ ) from ansible_collections.cisco.nd.plugins.module_utils.nd_manage_vpc_pair_query import ( custom_vpc_query_all, - normalize_vpc_playbook_switch_identifiers, ) @@ -92,22 +91,11 @@ def query_all(self): """ Query all existing vPC pairs from the controller. - If suppress_previous is True, skips the controller query and only - normalizes switch IP identifiers. Otherwise delegates to - custom_vpc_query_all for full discovery. + Delegates to custom_vpc_query_all for discovery and runtime context. Returns: List of existing pair dicts for NDConfigCollection initialization. """ - # Optional performance knob: skip initial query used to build "before" - # state and baseline diff in NDStateMachine initialization. - if self.state_machine is None and self.module.params.get("suppress_previous", False): - # Even when the before-query is skipped, normalize any IP-based - # switch identifiers in playbook config so downstream model/action - # code always receives serial numbers. - normalize_vpc_playbook_switch_identifiers(self.module) - return [] - context = ( self.state_machine if self.state_machine is not None diff --git a/plugins/modules/nd_manage_vpc_pair.py b/plugins/modules/nd_manage_vpc_pair.py index 258da2d9..e2f289f3 100644 --- a/plugins/modules/nd_manage_vpc_pair.py +++ b/plugins/modules/nd_manage_vpc_pair.py @@ -71,14 +71,6 @@ - Optional timeout in seconds for the post-apply refresh query. - When omitted, C(query_timeout) is used. type: int - suppress_previous: - description: - - Skip initial controller query for C(before) state and diff baseline. - - Performance optimization for trusted upsert workflows. - - May reduce idempotency and diff accuracy because existing controller state is not pre-fetched. - - Supported only with C(state=merged). - type: bool - default: false suppress_verification: description: - Skip post-apply controller query for final C(after) state verification. @@ -180,15 +172,6 @@ - peer1_switch_id: "FDO23040Q85" peer2_switch_id: "FDO23040Q86" -# Advanced performance mode: skip initial before-state query (merged only) -- name: Create/update vPC pair without initial before query - cisco.nd.nd_manage_vpc_pair: - fabric_name: myFabric - state: merged - suppress_previous: true - config: - - peer1_switch_id: "FDO23040Q85" - peer2_switch_id: "FDO23040Q86" """ RETURN = """ @@ -201,7 +184,6 @@ description: - vPC pair state before changes. - May contain controller read-only properties because it is queried from controller state. - - Empty when C(suppress_previous=true). type: list returned: always sample: [{"switchId": "FDO123", "peerSwitchId": "FDO456", "useVirtualPeerLink": false}] @@ -408,26 +390,11 @@ def main(): # State-specific parameter validations state = module_config.state deploy = module_config.deploy - suppress_previous = module_config.suppress_previous suppress_verification = module_config.suppress_verification if state == "gathered" and deploy: module.fail_json(msg="Deploy parameter cannot be used with 'gathered' state") - if suppress_previous and state != "merged": - module.fail_json( - msg=( - "Parameter 'suppress_previous' is supported only with state 'merged' " - "for nd_manage_vpc_pair." - ) - ) - - if suppress_previous: - module.warn( - "suppress_previous=true skips initial controller query. " - "before/diff accuracy and idempotency checks may be reduced." - ) - if suppress_verification: if module.params.get("refresh_after_apply", True): module.warn( diff --git a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml index e9ba11d3..7372b7b3 100644 --- a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml @@ -411,13 +411,12 @@ peer_switch_keep_alive_local_ip: "192.0.2.12" keep_alive_vrf: management register: result - ignore_errors: true tags: merge - name: MERGE - TC7 - ASSERT - Verify default vpc_pair_details path ansible.builtin.assert: that: - - result.failed == false or (result.failed == true and ("Failed to update VPC pair" in result.msg or "Failed to create VPC pair" in result.msg)) + - result.failed == false tags: merge # TC8 - Merge with vpc_pair_details custom template settings @@ -436,13 +435,12 @@ domainId: "20" customConfig: "vpc domain 20" register: result - ignore_errors: true tags: merge - name: MERGE - TC8 - ASSERT - Verify custom vpc_pair_details path ansible.builtin.assert: that: - - result.failed == false or (result.failed == true and ("Failed to update VPC pair" in result.msg or "Failed to create VPC pair" in result.msg)) + - result.failed == false tags: merge # TC9 - Test invalid configurations From ea2e89c9bec1c4cfd8f5f7f4c93fc19fb0458c68 Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Wed, 25 Mar 2026 23:06:52 +0530 Subject: [PATCH 38/39] Intermediate fixes --- .../module_utils/models/manage_vpc_pair/model.py | 11 ++++++++--- plugins/module_utils/nd_manage_vpc_pair_query.py | 7 ++++--- .../targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml | 7 +++++++ .../nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml | 14 ++++++++------ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/plugins/module_utils/models/manage_vpc_pair/model.py b/plugins/module_utils/models/manage_vpc_pair/model.py index 0ee775f8..d41074fd 100644 --- a/plugins/module_utils/models/manage_vpc_pair/model.py +++ b/plugins/module_utils/models/manage_vpc_pair/model.py @@ -206,11 +206,16 @@ def merge(self, other_model: "VpcPairModel") -> "VpcPairModel": "VpcPairModel.merge requires both models to be the same type" ) - for field, value in other_model: + merged_data = self.model_dump(by_alias=False, exclude_none=False) + incoming_data = other_model.model_dump(by_alias=False, exclude_none=False) + for field, value in incoming_data.items(): if value is None: continue - setattr(self, field, value) - return self + merged_data[field] = value + + # Validate once after the full merge so reversed pair updates do not + # fail on transient assignment states with validate_assignment=True. + return type(self).model_validate(merged_data, by_name=True, by_alias=True) @classmethod def from_response(cls, response: Dict[str, Any]) -> "VpcPairModel": diff --git a/plugins/module_utils/nd_manage_vpc_pair_query.py b/plugins/module_utils/nd_manage_vpc_pair_query.py index 7de29add..baccbfc0 100644 --- a/plugins/module_utils/nd_manage_vpc_pair_query.py +++ b/plugins/module_utils/nd_manage_vpc_pair_query.py @@ -588,9 +588,10 @@ def _set_lightweight_context( f"{str(list_error).splitlines()[0]}." ) - # Lightweight path for read-only and delete workflows. - # Keep heavy discovery/enrichment only for write states. - if state in ("deleted", "gathered"): + # Lightweight path for gathered and targeted delete workflows. + # For delete-all (state=deleted with empty config), use full switch-level + # discovery so stale/lagging list responses do not miss active pairs. + if state == "gathered" or (state == "deleted" and bool(config)): if list_query_succeeded: if state == "deleted" and config and not have: fallback_have = [] diff --git a/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml b/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml index feac6041..c9c05ea6 100644 --- a/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/conf_prep_tasks.yaml @@ -9,6 +9,13 @@ # # Requires: vpc_pair_conf variable to be set before importing. +- name: Build vPC Pair Config Data from Template + ansible.builtin.file: + path: "{{ playbook_dir }}/../files" + state: directory + mode: "0755" + delegate_to: localhost + - name: Build vPC Pair Config Data from Template ansible.builtin.template: src: "{{ playbook_dir }}/../templates/nd_vpc_pair_conf.j2" diff --git a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml index 8c4bd68c..24223bc2 100644 --- a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml @@ -184,20 +184,22 @@ - result.msg is search("Deploy parameter cannot be used") tags: gather -# TC9 - gathered with native check_mode should succeed -- name: GATHER - TC9 - GATHER - Gather with check_mode enabled +# TC9 - gathered + dry_run validation (must fail) +- name: GATHER - TC9 - GATHER - Gather with dry_run enabled (invalid) cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" state: gathered - check_mode: true + dry_run: true register: result + ignore_errors: true tags: gather -- name: GATHER - TC9 - ASSERT - Verify gathered+check_mode behavior +- name: GATHER - TC9 - ASSERT - Verify gathered+dry_run validation ansible.builtin.assert: that: - - result.failed == false - - result.gathered is defined + - result.failed == true + - result.msg is search("Unsupported parameters") + - result.msg is search("dry_run") tags: gather # TC10 - Validate /vpcPairs list API alignment with module gathered output From fcc42a5df218c5d4a9669796ceb3dd2d787bf6bc Mon Sep 17 00:00:00 2001 From: Sivakami Sivaraman Date: Fri, 27 Mar 2026 16:37:59 +0530 Subject: [PATCH 39/39] UT and small corrections in IT --- .../nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml | 79 ++++++ .../nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml | 246 ++++++++++++++-- .../module_utils/endpoints/test_base_path.py | 244 ++++++++++++++++ .../test_endpoints_api_v1_manage_vpc_pair.py | 267 ++++++++++++++++++ .../test_manage_vpc_pair_model.py | 109 +++++++ 5 files changed, 917 insertions(+), 28 deletions(-) create mode 100644 tests/unit/module_utils/endpoints/test_base_path.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_vpc_pair.py create mode 100644 tests/unit/module_utils/test_manage_vpc_pair_model.py diff --git a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml index 24223bc2..178ea52d 100644 --- a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_gather.yaml @@ -252,6 +252,85 @@ loop: "{{ vpc_pairs_list_result.current.vpcPairs | default([]) }}" tags: gather +- name: GATHER - TC10 - PREP - Extract target pair from /vpcPairs response + ansible.builtin.set_fact: + tc10_pair_from_list: >- + {{ + ( + vpc_pairs_list_result.current.vpcPairs + | default([]) + | selectattr('switchId', 'equalto', test_switch1) + | selectattr('peerSwitchId', 'equalto', test_switch2) + | list + | first + ) + | default( + ( + vpc_pairs_list_result.current.vpcPairs + | default([]) + | selectattr('switchId', 'equalto', test_switch2) + | selectattr('peerSwitchId', 'equalto', test_switch1) + | list + | first + ), + true + ) + }} + tags: gather + +- name: GATHER - TC10 - PREP - Extract target pair from gathered output + ansible.builtin.set_fact: + tc10_pair_from_gathered: >- + {{ + ( + gathered_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', test_switch1) + | selectattr('peer_switch_id', 'equalto', test_switch2) + | list + | first + ) + | default( + ( + gathered_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', test_switch2) + | selectattr('peer_switch_id', 'equalto', test_switch1) + | list + | first + ), + true + ) + }} + tags: gather + +- name: GATHER - TC10 - ASSERT - Verify useVirtualPeerLink alignment for target pair + ansible.builtin.assert: + quiet: true + that: + - tc10_pair_from_list is mapping + - tc10_pair_from_gathered is mapping + - tc10_gather_vpl == true + - (not tc10_list_has_vpl) or (tc10_list_vpl == tc10_gather_vpl) + vars: + tc10_list_has_vpl: >- + {{ + (tc10_pair_from_list.useVirtualPeerLink is defined) + or + (tc10_pair_from_list.useVirtualPeerlink is defined) + }} + tc10_list_vpl: >- + {{ + tc10_pair_from_list.useVirtualPeerLink + | default(tc10_pair_from_list.useVirtualPeerlink | default(false)) + | bool + }} + tc10_gather_vpl: >- + {{ + tc10_pair_from_gathered.use_virtual_peer_link + | default(tc10_pair_from_gathered.useVirtualPeerLink | default(false)) + | bool + }} + tags: gather + # TC11 - Validate normalized pair matching for reversed switch order - name: GATHER - TC11 - GATHER - Gather with reversed/duplicate pair filters cisco.nd.nd_manage_vpc_pair: diff --git a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml index 7372b7b3..effdadea 100644 --- a/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml +++ b/tests/integration/targets/nd_vpc_pair/tasks/nd_vpc_pair_merge.yaml @@ -474,33 +474,6 @@ ignore_errors: true tags: merge -- name: MERGE - TC10 - PREP - Query fabric peering support for switch1 - cisco.nd.nd_rest: - path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch1 }}/vpcPairSupport?componentType=checkFabricPeeringSupport" - method: get - register: tc10_support_switch1 - ignore_errors: true - tags: merge - -- name: MERGE - TC10 - PREP - Query fabric peering support for switch2 - cisco.nd.nd_rest: - path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch2 }}/vpcPairSupport?componentType=checkFabricPeeringSupport" - method: get - register: tc10_support_switch2 - ignore_errors: true - tags: merge - -- name: MERGE - TC10 - PREP - Decide virtual peer link flag for deploy test - ansible.builtin.set_fact: - tc10_use_virtual_peer_link: >- - {{ - (not (tc10_support_switch1.failed | default(false))) and - (not (tc10_support_switch2.failed | default(false))) and - (tc10_support_switch1.current.isVpcFabricPeeringSupported | default(false) | bool) and - (tc10_support_switch2.current.isVpcFabricPeeringSupported | default(false) | bool) - }} - tags: merge - - name: MERGE - TC10 - MERGE - Create vPC pair with deploy true cisco.nd.nd_manage_vpc_pair: fabric_name: "{{ test_fabric }}" @@ -509,7 +482,7 @@ config: - peer1_switch_id: "{{ test_switch1 }}" peer2_switch_id: "{{ test_switch2 }}" - use_virtual_peer_link: "{{ tc10_use_virtual_peer_link }}" + use_virtual_peer_link: true register: result tags: merge @@ -520,6 +493,223 @@ - result.deployment is defined tags: merge +- name: MERGE - TC10 - ASSERT - Verify config-save and deploy API traces + ansible.builtin.assert: + that: + - result.deployment.response is defined + - (result.deployment.response | length) >= 2 + - > + ( + result.deployment.response + | selectattr('REQUEST_PATH', 'search', '/actions/configSave') + | list + | length + ) > 0 + - > + ( + result.deployment.response + | selectattr('REQUEST_PATH', 'search', '/actions/deploy') + | list + | length + ) > 0 + tags: merge + +- name: MERGE - TC10 - GATHER - Query gathered pair after deploy flow + cisco.nd.nd_manage_vpc_pair: + state: gathered + fabric_name: "{{ test_fabric }}" + config: + - peer1_switch_id: "{{ test_switch1 }}" + peer2_switch_id: "{{ test_switch2 }}" + register: tc10_gather_result + tags: merge + +- name: MERGE - TC10 - PREP - Extract target pair from gathered output + ansible.builtin.set_fact: + tc10_gathered_pair: >- + {{ + ( + tc10_gather_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', test_switch1) + | selectattr('peer_switch_id', 'equalto', test_switch2) + | list + | first + ) + | default( + ( + tc10_gather_result.gathered.vpc_pairs + | selectattr('switch_id', 'equalto', test_switch2) + | selectattr('peer_switch_id', 'equalto', test_switch1) + | list + | first + ), + true + ) + }} + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify use_virtual_peer_link is true in gathered output + ansible.builtin.assert: + that: + - tc10_gather_result.failed == false + - tc10_gathered_pair is mapping + - > + ( + tc10_gathered_pair.use_virtual_peer_link + | default(tc10_gathered_pair.useVirtualPeerLink | default(false)) + | bool + ) == true + tags: merge + +- name: MERGE - TC10 - API - Query vpcPairSupport checkPairing for both peers + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ item }}/vpcPairSupport?componentType=checkPairing" + method: get + loop: + - "{{ test_switch1 }}" + - "{{ test_switch2 }}" + register: tc10_pairing_support + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify checkPairing support responses + ansible.builtin.assert: + that: + - item.failed == false + - item.current is mapping + - item.current.isPairingAllowed is defined + - item.current.isPairingAllowed | bool + loop: "{{ tc10_pairing_support.results | default([]) }}" + tags: merge + +- name: MERGE - TC10 - API - Query vpcPairSupport checkFabricPeeringSupport for both peers + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ item }}/vpcPairSupport?componentType=checkFabricPeeringSupport" + method: get + loop: + - "{{ test_switch1 }}" + - "{{ test_switch2 }}" + register: tc10_fabric_peering_support + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify fabric peering support endpoint responses + ansible.builtin.assert: + that: + - item.failed == false + - item.current is mapping + - item.current.isVpcFabricPeeringSupported is defined + - > + ( + item.current.isVpcFabricPeeringSupported | bool + ) or ( + (item.current.status | default("") | lower) is search("not supported") + ) + quiet: true + loop: "{{ tc10_fabric_peering_support.results | default([]) }}" + tags: merge + +- name: MERGE - TC10 - API - Query vpcPairOverview with componentType=full + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch1 }}/vpcPairOverview?componentType=full" + method: get + register: tc10_overview_full + tags: merge + +- name: MERGE - TC10 - API - Query vpcPairOverview with componentType=pairsInfo + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch1 }}/vpcPairOverview?componentType=pairsInfo" + method: get + register: tc10_overview_pairs_info + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify vpcPairOverview endpoint responses + ansible.builtin.assert: + that: + - tc10_overview_full.failed == false + - tc10_overview_full.current is defined + - tc10_overview_pairs_info.failed == false + - tc10_overview_pairs_info.current is defined + tags: merge + +- name: MERGE - TC10 - API - Query vpcPairConsistency for switch1 + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/switches/{{ test_switch1 }}/vpcPairConsistency" + method: get + register: tc10_consistency + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify vpcPairConsistency endpoint response + ansible.builtin.assert: + that: + - tc10_consistency.failed == false + - tc10_consistency.current is defined + tags: merge + +- name: MERGE - TC10 - API - Query vPC pairs list endpoint directly + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_fabric }}/vpcPairs" + method: get + register: tc10_vpc_pairs + tags: merge + +- name: MERGE - TC10 - PREP - Extract target pair from /vpcPairs response + ansible.builtin.set_fact: + tc10_pair_from_list: >- + {{ + ( + tc10_vpc_pairs.current.vpcPairs + | default([]) + | selectattr('switchId', 'equalto', test_switch1) + | selectattr('peerSwitchId', 'equalto', test_switch2) + | list + | first + ) + | default( + ( + tc10_vpc_pairs.current.vpcPairs + | default([]) + | selectattr('switchId', 'equalto', test_switch2) + | selectattr('peerSwitchId', 'equalto', test_switch1) + | list + | first + ), + true + ) + }} + tags: merge + +- name: MERGE - TC10 - ASSERT - Verify /vpcPairs useVirtualPeerLink alignment + ansible.builtin.assert: + that: + - tc10_vpc_pairs.failed == false + - tc10_pair_from_list is mapping + - tc10_gather_vpl == true + - not tc10_list_has_vpl or tc10_list_vpl == tc10_gather_vpl + quiet: true + vars: + tc10_list_has_vpl: >- + {{ + tc10_pair_from_list.useVirtualPeerLink is defined + or + tc10_pair_from_list.useVirtualPeerlink is defined + }} + tc10_list_vpl: >- + {{ + ( + tc10_pair_from_list.useVirtualPeerLink + | default(tc10_pair_from_list.useVirtualPeerlink | default(false)) + ) + | bool + }} + tc10_gather_vpl: >- + {{ + ( + tc10_gathered_pair.use_virtual_peer_link + | default(tc10_gathered_pair.useVirtualPeerLink | default(false)) + ) + | bool + }} + tags: merge + # TC11 - Delete with custom api_timeout - name: MERGE - TC11 - DELETE - Delete vPC pair with api_timeout override cisco.nd.nd_manage_vpc_pair: diff --git a/tests/unit/module_utils/endpoints/test_base_path.py b/tests/unit/module_utils/endpoints/test_base_path.py new file mode 100644 index 00000000..4a4e64d6 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_base_path.py @@ -0,0 +1,244 @@ +# 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_path.py + +Tests the ApiPath enum defined in base_path.py +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base_path import ( + ApiPath, +) +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + + +# ============================================================================= +# Test: ApiPath Enum Values +# ============================================================================= + + +def test_base_path_00010(): + """ + # Summary + + Verify ApiPath.ANALYZE value + + ## Test + + - ApiPath.ANALYZE equals "/api/v1/analyze" + + ## Classes and Methods + + - ApiPath.ANALYZE + """ + with does_not_raise(): + result = ApiPath.ANALYZE.value + assert result == "/api/v1/analyze" + + +def test_base_path_00020(): + """ + # Summary + + Verify ApiPath.INFRA value + + ## Test + + - ApiPath.INFRA equals "/api/v1/infra" + + ## Classes and Methods + + - ApiPath.INFRA + """ + with does_not_raise(): + result = ApiPath.INFRA.value + assert result == "/api/v1/infra" + + +def test_base_path_00030(): + """ + # Summary + + Verify ApiPath.MANAGE value + + ## Test + + - ApiPath.MANAGE equals "/api/v1/manage" + + ## Classes and Methods + + - ApiPath.MANAGE + """ + with does_not_raise(): + result = ApiPath.MANAGE.value + assert result == "/api/v1/manage" + + +def test_base_path_00040(): + """ + # Summary + + Verify ApiPath.ONEMANAGE value + + ## Test + + - ApiPath.ONEMANAGE equals "/api/v1/onemanage" + + ## Classes and Methods + + - ApiPath.ONEMANAGE + """ + with does_not_raise(): + result = ApiPath.ONEMANAGE.value + assert result == "/api/v1/onemanage" + + +# ============================================================================= +# Test: ApiPath Enum Properties +# ============================================================================= + + +def test_base_path_00100(): + """ + # Summary + + Verify ApiPath enum members are strings + + ## Test + + - ApiPath enum extends str + - Enum members can be used directly in string operations + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + assert isinstance(ApiPath.INFRA, str) + assert isinstance(ApiPath.MANAGE, str) + assert isinstance(ApiPath.ANALYZE, str) + assert isinstance(ApiPath.ONEMANAGE, str) + + +def test_base_path_00110(): + """ + # Summary + + Verify all API paths start with forward slash + + ## Test + + - All ApiPath values start with "/" + - This ensures proper path concatenation + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + for member in ApiPath: + assert member.value.startswith("/"), f"{member.name} does not start with /" + + +def test_base_path_00120(): + """ + # Summary + + Verify no API paths end with trailing slash + + ## Test + + - No ApiPath values end with "/" + - This prevents double slashes when building paths + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + for member in ApiPath: + assert not member.value.endswith("/"), f"{member.name} ends with /" + + +def test_base_path_00130(): + """ + # Summary + + Verify ApiPath enum provides all expected members + + ## Test + + - All 4 API paths available as enum members + - Enum is iterable + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + paths = list(ApiPath) + + assert len(paths) == 4 + assert ApiPath.ANALYZE in paths + assert ApiPath.INFRA in paths + assert ApiPath.MANAGE in paths + assert ApiPath.ONEMANAGE in paths + + +# ============================================================================= +# Test: Path Uniqueness +# ============================================================================= + + +def test_base_path_00200(): + """ + # Summary + + Verify all ApiPath values are unique + + ## Test + + - Each enum member has a different value + - No duplicate paths exist + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + values = [member.value for member in ApiPath] + assert len(values) == len(set(values)), "Duplicate paths found" + + +# ============================================================================= +# Test: ND API Path Structure +# ============================================================================= + + +def test_base_path_00300(): + """ + # Summary + + Verify all ApiPath members follow /api/v1/ pattern + + ## Test + + - All ApiPath values start with "/api/v1/" + + ## Classes and Methods + + - ApiPath + """ + with does_not_raise(): + for member in ApiPath: + assert member.value.startswith("/api/v1/"), f"{member.name} does not follow /api/v1/ pattern" diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_vpc_pair.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_vpc_pair.py new file mode 100644 index 00000000..c3e6c778 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_vpc_pair.py @@ -0,0 +1,267 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for vPC pair endpoint models under plugins/module_utils/endpoints/v1/manage. + +Mirrors the style used in PR198 endpoint unit tests. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from urllib.parse import parse_qsl, urlsplit + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair import ( + EpVpcPairGet, + EpVpcPairPut, + VpcPairGetEndpointParams, + VpcPairPutEndpointParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_consistency import ( + EpVpcPairConsistencyGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_overview import ( + EpVpcPairOverviewGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_recommendation import ( + EpVpcPairRecommendationGet, + VpcPairRecommendationEndpointParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches_vpc_pair_support import ( + EpVpcPairSupportGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_vpc_pairs import ( + EpVpcPairsListGet, +) +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 _assert_path_with_query(path: str, expected_base_path: str, expected_query: dict[str, str]) -> None: + parsed = urlsplit(path) + assert parsed.path == expected_base_path + assert dict(parse_qsl(parsed.query, keep_blank_values=True)) == expected_query + + +# ============================================================================= +# Test: manage_fabrics_switches_vpc_pair.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00010(): + """Verify VpcPairGetEndpointParams query serialization.""" + with does_not_raise(): + params = VpcPairGetEndpointParams(from_cluster="cluster-a") + result = params.to_query_string() + assert result == "fromCluster=cluster-a" + + +def test_endpoints_api_v1_manage_vpc_pair_00020(): + """Verify VpcPairPutEndpointParams query serialization.""" + with does_not_raise(): + params = VpcPairPutEndpointParams(from_cluster="cluster-a", ticket_id="CHG123") + result = params.to_query_string() + parsed = dict(parse_qsl(result, keep_blank_values=True)) + assert parsed == {"fromCluster": "cluster-a", "ticketId": "CHG123"} + + +def test_endpoints_api_v1_manage_vpc_pair_00030(): + """Verify EpVpcPairGet basics.""" + with does_not_raise(): + instance = EpVpcPairGet() + assert instance.class_name == "EpVpcPairGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_manage_vpc_pair_00040(): + """Verify EpVpcPairGet path raises when required path fields are missing.""" + instance = EpVpcPairGet() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_vpc_pair_00050(): + """Verify EpVpcPairGet path without query params.""" + with does_not_raise(): + instance = EpVpcPairGet(fabric_name="fab1", switch_id="SN01") + result = instance.path + assert result == "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPair" + + +def test_endpoints_api_v1_manage_vpc_pair_00060(): + """Verify EpVpcPairGet path with query params.""" + with does_not_raise(): + instance = EpVpcPairGet(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.from_cluster = "cluster-a" + result = instance.path + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPair", + {"fromCluster": "cluster-a"}, + ) + + +def test_endpoints_api_v1_manage_vpc_pair_00070(): + """Verify EpVpcPairPut basics and query path.""" + with does_not_raise(): + instance = EpVpcPairPut(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.from_cluster = "cluster-a" + instance.endpoint_params.ticket_id = "CHG1" + result = instance.path + assert instance.class_name == "EpVpcPairPut" + assert instance.verb == HttpVerbEnum.PUT + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPair", + {"fromCluster": "cluster-a", "ticketId": "CHG1"}, + ) + + +# ============================================================================= +# Test: manage_fabrics_switches_vpc_pair_consistency.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00100(): + """Verify EpVpcPairConsistencyGet basics and path.""" + with does_not_raise(): + instance = EpVpcPairConsistencyGet(fabric_name="fab1", switch_id="SN01") + result = instance.path + assert instance.class_name == "EpVpcPairConsistencyGet" + assert instance.verb == HttpVerbEnum.GET + assert result == "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPairConsistency" + + +def test_endpoints_api_v1_manage_vpc_pair_00110(): + """Verify EpVpcPairConsistencyGet query params.""" + with does_not_raise(): + instance = EpVpcPairConsistencyGet(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.from_cluster = "cluster-a" + result = instance.path + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPairConsistency", + {"fromCluster": "cluster-a"}, + ) + + +# ============================================================================= +# Test: manage_fabrics_switches_vpc_pair_overview.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00200(): + """Verify EpVpcPairOverviewGet query params.""" + with does_not_raise(): + instance = EpVpcPairOverviewGet(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.from_cluster = "cluster-a" + instance.endpoint_params.component_type = "health" + result = instance.path + assert instance.class_name == "EpVpcPairOverviewGet" + assert instance.verb == HttpVerbEnum.GET + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPairOverview", + {"fromCluster": "cluster-a", "componentType": "health"}, + ) + + +# ============================================================================= +# Test: manage_fabrics_switches_vpc_pair_recommendation.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00300(): + """Verify recommendation params keep use_virtual_peer_link optional.""" + with does_not_raise(): + params = VpcPairRecommendationEndpointParams() + assert params.use_virtual_peer_link is None + assert params.to_query_string() == "" + + +def test_endpoints_api_v1_manage_vpc_pair_00310(): + """Verify EpVpcPairRecommendationGet path with optional useVirtualPeerLink.""" + with does_not_raise(): + instance = EpVpcPairRecommendationGet(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.use_virtual_peer_link = True + result = instance.path + assert instance.class_name == "EpVpcPairRecommendationGet" + assert instance.verb == HttpVerbEnum.GET + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPairRecommendation", + {"useVirtualPeerLink": "true"}, + ) + + +# ============================================================================= +# Test: manage_fabrics_switches_vpc_pair_support.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00400(): + """Verify EpVpcPairSupportGet query params.""" + with does_not_raise(): + instance = EpVpcPairSupportGet(fabric_name="fab1", switch_id="SN01") + instance.endpoint_params.from_cluster = "cluster-a" + instance.endpoint_params.component_type = "checkPairing" + result = instance.path + assert instance.class_name == "EpVpcPairSupportGet" + assert instance.verb == HttpVerbEnum.GET + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/switches/SN01/vpcPairSupport", + {"fromCluster": "cluster-a", "componentType": "checkPairing"}, + ) + + +# ============================================================================= +# Test: manage_fabrics_vpc_pairs.py +# ============================================================================= + + +def test_endpoints_api_v1_manage_vpc_pair_00500(): + """Verify EpVpcPairsListGet basics.""" + with does_not_raise(): + instance = EpVpcPairsListGet() + assert instance.class_name == "EpVpcPairsListGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_manage_vpc_pair_00510(): + """Verify EpVpcPairsListGet raises when fabric_name is missing.""" + instance = EpVpcPairsListGet() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_vpc_pair_00520(): + """Verify EpVpcPairsListGet full query serialization.""" + with does_not_raise(): + instance = EpVpcPairsListGet(fabric_name="fab1") + instance.endpoint_params.from_cluster = "cluster-a" + instance.endpoint_params.filter = "switchId:SN01" + instance.endpoint_params.max = 50 + instance.endpoint_params.offset = 10 + instance.endpoint_params.sort = "switchId:asc" + instance.endpoint_params.view = "discoveredPairs" + result = instance.path + + _assert_path_with_query( + result, + "/api/v1/manage/fabrics/fab1/vpcPairs", + { + "fromCluster": "cluster-a", + "filter": "switchId:SN01", + "max": "50", + "offset": "10", + "sort": "switchId:asc", + "view": "discoveredPairs", + }, + ) diff --git a/tests/unit/module_utils/test_manage_vpc_pair_model.py b/tests/unit/module_utils/test_manage_vpc_pair_model.py new file mode 100644 index 00000000..6ea055a3 --- /dev/null +++ b/tests/unit/module_utils/test_manage_vpc_pair_model.py @@ -0,0 +1,109 @@ +# Copyright: (c) 2026, Sivakami Sivaraman + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for manage_vpc_pair model layer. +""" + +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.common.pydantic_compat import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.manage_vpc_pair.enums import VpcFieldNames + +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_vpc_pair.model import ( + VpcPairModel, + VpcPairPlaybookConfigModel, + VpcPairPlaybookItemModel, +) +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + + +def test_manage_vpc_pair_model_00010(): + """Verify VpcPairModel.from_config accepts snake_case keys.""" + with does_not_raise(): + model = VpcPairModel.from_config( + { + "switch_id": "SN01", + "peer_switch_id": "SN02", + "use_virtual_peer_link": True, + } + ) + assert model.switch_id == "SN01" + assert model.peer_switch_id == "SN02" + assert model.use_virtual_peer_link is True + + +def test_manage_vpc_pair_model_00020(): + """Verify VpcPairModel identifier is order-independent.""" + with does_not_raise(): + model = VpcPairModel.from_config( + { + "switch_id": "SN02", + "peer_switch_id": "SN01", + } + ) + assert model.get_identifier_value() == ("SN01", "SN02") + + +def test_manage_vpc_pair_model_00030(): + """Verify merge handles reversed switch order without transient validation failure.""" + with does_not_raise(): + base = VpcPairModel.from_config( + { + "switch_id": "SN01", + "peer_switch_id": "SN02", + "use_virtual_peer_link": True, + } + ) + incoming = VpcPairModel.from_config( + { + "switch_id": "SN02", + "peer_switch_id": "SN01", + "use_virtual_peer_link": False, + } + ) + merged = base.merge(incoming) + + assert merged.switch_id == "SN02" + assert merged.peer_switch_id == "SN01" + assert merged.use_virtual_peer_link is False + + +def test_manage_vpc_pair_model_00040(): + """Verify playbook item normalization includes both snake_case and API keys.""" + with does_not_raise(): + item = VpcPairPlaybookItemModel( + peer1_switch_id="SN01", + peer2_switch_id="SN02", + use_virtual_peer_link=False, + ) + runtime = item.to_runtime_config() + + assert runtime["switch_id"] == "SN01" + assert runtime["peer_switch_id"] == "SN02" + assert runtime["use_virtual_peer_link"] is False + assert runtime[VpcFieldNames.SWITCH_ID] == "SN01" + assert runtime[VpcFieldNames.PEER_SWITCH_ID] == "SN02" + assert runtime[VpcFieldNames.USE_VIRTUAL_PEER_LINK] is False + + +def test_manage_vpc_pair_model_00050(): + """Verify playbook item model rejects identical peer switch IDs.""" + with pytest.raises(ValidationError): + VpcPairPlaybookItemModel(peer1_switch_id="SN01", peer2_switch_id="SN01") + + +def test_manage_vpc_pair_model_00060(): + """Verify argument_spec keeps vPC pair config aliases.""" + with does_not_raise(): + spec = VpcPairPlaybookConfigModel.get_argument_spec() + + config_options = spec["config"]["options"] + assert config_options["peer1_switch_id"]["aliases"] == ["switch_id"] + assert config_options["peer2_switch_id"]["aliases"] == ["peer_switch_id"]