diff --git a/plugins/module_utils/common/log.py b/plugins/module_utils/common/log.py index f43d9018e..ae506fee6 100644 --- a/plugins/module_utils/common/log.py +++ b/plugins/module_utils/common/log.py @@ -417,7 +417,7 @@ def develop(self, value: bool) -> None: logging.raiseExceptions = value -def setup_logging(module: "AnsibleModule", develop: bool = False) -> Log: +def setup_logging(module: "AnsibleModule", develop: bool = False, config: Optional[str] = None) -> Log: """ # Summary @@ -434,6 +434,9 @@ def setup_logging(module: "AnsibleModule", develop: bool = False) -> Log: - Calls `module.fail_json()` if logging configuration fails, which exits the module with an error message rather than raising an exception. + - If `config` is provided it overrides the `ND_LOGGING_CONFIG` environment + variable. Pass `None` (the default) to rely solely on the environment + variable. ## Usage @@ -445,6 +448,12 @@ def main(): log = setup_logging(module) ``` + To point at a specific config file (e.g. during local development): + + ```python + log = setup_logging(module, config="/path/to/logging_config.json") + ``` + To enable logging exceptions during development, pass `develop=True`: ```python @@ -453,6 +462,8 @@ def main(): """ try: log = Log(develop=develop) + if config is not None: + log.config = config log.commit() except ValueError as error: module.fail_json(msg=str(error)) diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index 68e0338cb..54a13d297 100644 --- a/plugins/module_utils/endpoints/mixins.py +++ b/plugins/module_utils/endpoints/mixins.py @@ -32,6 +32,12 @@ class FabricNameMixin(BaseModel): fabric_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="Fabric name") +class FilterMixin(BaseModel): + """Mixin for endpoints that require a Lucene filter expression.""" + + filter: Optional[str] = Field(default=None, min_length=1, description="Lucene filter expression") + + class ForceShowRunMixin(BaseModel): """Mixin for endpoints that require force_show_run parameter.""" @@ -68,16 +74,28 @@ class LoginIdMixin(BaseModel): login_id: Optional[str] = Field(default=None, min_length=1, description="Login ID") +class MaxMixin(BaseModel): + """Mixin for endpoints that require a max results parameter.""" + + max: Optional[int] = Field(default=None, ge=1, description="Maximum number of results") + + 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.""" +class OffsetMixin(BaseModel): + """Mixin for endpoints that require a pagination offset parameter.""" - node_name: Optional[str] = Field(default=None, min_length=1, description="Node name") + offset: Optional[int] = Field(default=None, ge=0, description="Pagination offset") + + +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 or ID") class SwitchSerialNumberMixin(BaseModel): @@ -86,7 +104,25 @@ class SwitchSerialNumberMixin(BaseModel): switch_sn: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") +class TenantNameMixin(BaseModel): + """Mixin for endpoints that require tenant_name parameter.""" + + tenant_name: Optional[str] = Field(default=None, min_length=1, description="Tenant name") + + +class TicketIdMixin(BaseModel): + """Mixin for endpoints that require ticket_id parameter.""" + + ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") + + class VrfNameMixin(BaseModel): """Mixin for endpoints that require vrf_name parameter.""" vrf_name: Optional[str] = Field(default=None, min_length=1, max_length=64, description="VRF name") + + +class NodeNameMixin(BaseModel): + """Mixin for endpoints that require node_name parameter.""" + + node_name: Optional[str] = Field(default=None, min_length=1, description="Node name") diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_resources.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_resources.py new file mode 100644 index 000000000..7708c1cdf --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_resources.py @@ -0,0 +1,299 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Resources endpoint models. + +This module contains endpoint definitions for resource management operations +in the ND Manage API. +""" + +from __future__ import annotations + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ClusterNameMixin, + FabricNameMixin, + SwitchIdMixin, + TenantNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, + LuceneQueryParams, +) +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) + + +class ResourcesQueryParams(ClusterNameMixin, SwitchIdMixin, TenantNameMixin, EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for resources endpoint. + + ## Parameters + + - cluster_name: Name of the cluster (optional) + - switch_id: Serial Number or Id of the switch/leaf (optional) + - pool_name: Name of the Pool (optional) + + ## Usage + + ```python + params = ResourcesQueryParams(cluster_name="cluster1", switch_id="leaf-101", pool_name="networkVlan") + query_string = params.to_query_string() + # Returns: "clusterName=cluster1&switchId=leaf-101&poolName=networkVlan" + ``` + """ + + pool_name: str | None = Field(default=None, min_length=1, description="Name of the Pool") + + +# ============================================================================= +# RESOURCES ENDPOINTS +# ============================================================================= + + +class _EpManageFabricResourcesBase(FabricNameMixin, NDEndpointBaseModel): + """Base class for fabric resource endpoints.""" + + @property + def _base_path(self) -> str: + """Build the fabric resources endpoint path.""" + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + return BasePath.path("fabrics", self.fabric_name, "resources") + + +class EpManageFabricResourcesGet(_EpManageFabricResourcesBase): + """ + # Summary + + ND Manage Fabrics Resources GET Endpoint + + ## Description + + Endpoint to retrieve all resources for the given fabric. + Supports both endpoint-specific parameters (switch_id, pool_name) and + Lucene-style filtering (filter, max, offset, sort). + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/resources + - /api/v1/manage/fabrics/{fabricName}/resources?switchId=leaf-101 + - /api/v1/manage/fabrics/{fabricName}/resources?poolName=networkVlan + - /api/v1/manage/fabrics/{fabricName}/resources?filter=isPreAllocated:true + - /api/v1/manage/fabrics/{fabricName}/resources?max=10&offset=0&sort=poolName:asc + + ## Verb + + - GET + + ## Usage + + ```python + # Get all resources in a fabric + request = EpManageFabricResourcesGet() + request.fabric_name = "fabric1" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/fabric1/resources + + # Get resources filtered by switch + request = EpManageFabricResourcesGet() + request.fabric_name = "fabric1" + request.endpoint_params.switch_id = "leaf-101" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/fabric1/resources?switchId=leaf-101 + + # Get resources with pagination + request = EpManageFabricResourcesGet() + request.fabric_name = "fabric1" + request.endpoint_params.pool_name = "networkVlan" + request.lucene_params.max = 10 + request.lucene_params.offset = 0 + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/fabric1/resources?poolName=networkVlan&max=10&offset=0 + ``` + """ + + model_config = COMMON_CONFIG + + class_name: Literal["EpManageFabricResourcesGet"] = Field(default="EpManageFabricResourcesGet", description="Class name for backward compatibility") + endpoint_params: ResourcesQueryParams = Field(default_factory=ResourcesQueryParams, description="Endpoint-specific query parameters") + lucene_params: LuceneQueryParams = Field(default_factory=LuceneQueryParams, description="Lucene-style 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 + """ + + query_parts = [ + self.endpoint_params.to_query_string(), + self.lucene_params.to_query_string(), + ] + query_string = "&".join(part for part in query_parts if part) + if query_string: + return f"{self._base_path}?{query_string}" + return self._base_path + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class EpManageFabricResourcesPost(_EpManageFabricResourcesBase): + """ + # Summary + + ND Manage Fabrics Resources POST Endpoint + + ## Description + + Endpoint to allocate an ID or IP/Subnet resource from the specified pool. + If a specific resource value is provided in the request, that exact value + will be allocated. Otherwise, the next available resource will be + automatically allocated. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/resources + - /api/v1/manage/fabrics/{fabricName}/resources?tenantName=tenant1 + + ## Verb + + - POST + + ## Usage + + ```python + # Allocate resource + request = EpManageFabricResourcesPost() + request.fabric_name = "fabric1" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/fabric1/resources + + # Allocate resource with tenant + request = EpManageFabricResourcesPost() + request.fabric_name = "fabric1" + request.endpoint_params.tenant_name = "tenant1" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/fabric1/resources?tenantName=tenant1 + ``` + """ + + model_config = COMMON_CONFIG + + class_name: Literal["EpManageFabricResourcesPost"] = Field(default="EpManageFabricResourcesPost", description="Class name for backward compatibility") + endpoint_params: ResourcesQueryParams = Field(default_factory=ResourcesQueryParams, 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 + """ + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{self._base_path}?{query_string}" + return self._base_path + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +# ============================================================================= +# RESOURCES ACTIONS ENDPOINTS +# ============================================================================= + + +class EpManageFabricResourcesActionsRemovePost(_EpManageFabricResourcesBase): + """ + # Summary + + ND Manage Fabrics Resources Actions Remove POST Endpoint + + ## Description + + Endpoint to release allocated resource IDs from the fabric, returning them + to the available resource pool. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/resources/actions/remove + + ## Verb + + - POST + + ## Usage + + ```python + # Release resource IDs + request = EpManageFabricResourcesActionsRemovePost() + request.fabric_name = "fabric1" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/fabric1/resources/actions/remove + ``` + """ + + model_config = COMMON_CONFIG + + class_name: Literal["EpManageFabricResourcesActionsRemovePost"] = Field( + default="EpManageFabricResourcesActionsRemovePost", description="Class name for backward compatibility" + ) + endpoint_params: ResourcesQueryParams = Field(default_factory=ResourcesQueryParams, description="Endpoint-specific query parameters") + + @property + def path(self) -> str: + """ + # Summary + + Build the endpoint path. + + ## Returns + + - Complete endpoint path string + """ + + base_path = BasePath.path("fabrics", self.fabric_name, "resources", "actions", "remove") + 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.POST 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 000000000..deda299ba --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat C S (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Fabric Switches endpoint models. + +This module contains endpoint definitions for switch CRUD operations +within fabrics in the ND Manage API. + +Endpoints covered: +- List switches in a fabric +- Add switches to a fabric +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +__author__ = "Akshayanat C S" +# pylint: enable=invalid-name + +from typing import Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ClusterNameMixin, + FabricNameMixin, + FilterMixin, + MaxMixin, + OffsetMixin, + 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, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) + + +class FabricSwitchesGetEndpointParams(FilterMixin, MaxMixin, OffsetMixin, EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for list fabric switches endpoint. + + ## Parameters + + - hostname: Filter by switch hostname (optional) + - max: Maximum number of results (optional, from `MaxMixin`) + - offset: Pagination offset (optional, from `OffsetMixin`) + - filter: Lucene filter expression (optional, from `FilterMixin`) + + ## Usage + + ```python + params = FabricSwitchesGetEndpointParams(hostname="leaf1", max=100) + query_string = params.to_query_string() + # Returns: "hostname=leaf1&max=100" + ``` + """ + + hostname: Optional[str] = Field(default=None, min_length=1, description="Filter by switch hostname") + + +class FabricSwitchesAddEndpointParams(ClusterNameMixin, TicketIdMixin, EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for add switches to fabric endpoint. + + ## Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional, from `ClusterNameMixin`) + - ticket_id: Change control ticket ID (optional, from `TicketIdMixin`) + + ## Usage + + ```python + params = FabricSwitchesAddEndpointParams(cluster_name="cluster1", ticket_id="CHG12345") + query_string = params.to_query_string() + # Returns: "clusterName=cluster1&ticketId=CHG12345" + ``` + """ + + +class _EpManageFabricsSwitchesBase(FabricNameMixin, NDEndpointBaseModel): + """ + Base class for Fabric Switches endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabricName}/switches endpoint. + """ + + @property + def _base_path(self) -> str: + """Build the base endpoint path.""" + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + return BasePath.path("fabrics", self.fabric_name, "switches") + + +class EpManageFabricsSwitchesGet(_EpManageFabricsSwitchesBase): + """ + # Summary + + List Fabric Switches Endpoint + + ## Description + + Endpoint to list all switches in a specific fabric with optional filtering. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches + - /api/v1/manage/fabrics/{fabricName}/switches?hostname=leaf1&max=100 + + ## Verb + + - GET + + ## Query Parameters + + - hostname: Filter by switch hostname (optional) + - max: Maximum number of results (optional) + - offset: Pagination offset (optional) + - filter: Lucene filter expression (optional) + + ## Usage + + ```python + # List all switches + request = EpManageFabricsSwitchesGet() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # List with filtering + request = EpManageFabricsSwitchesGet() + request.fabric_name = "MyFabric" + request.endpoint_params.hostname = "leaf1" + request.endpoint_params.max = 100 + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switches?hostname=leaf1&max=100 + ``` + """ + + class_name: Literal["EpManageFabricsSwitchesGet"] = Field( + default="EpManageFabricsSwitchesGet", + frozen=True, + description="Class name for backward compatibility", + ) + endpoint_params: FabricSwitchesGetEndpointParams = Field( + default_factory=FabricSwitchesGetEndpointParams, + 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 + """ + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{self._base_path}?{query_string}" + return self._base_path + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET diff --git a/plugins/module_utils/fabric_inventory.py b/plugins/module_utils/fabric_inventory.py new file mode 100644 index 000000000..0d8136621 --- /dev/null +++ b/plugins/module_utils/fabric_inventory.py @@ -0,0 +1,173 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) + +# 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 logging +from typing import Any, Dict, List, Optional, Type + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches import ( + EpManageFabricsSwitchesGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import ( + NDConfigCollection, +) + +# ========================================================================= +# Exceptions +# ========================================================================= + + +class SwitchOperationError(Exception): + """Raised when a switch operation fails.""" + + +# ========================================================================= +# API Response Validation +# ========================================================================= + + +class ApiDataChecker: + """Detect controller-embedded errors in API response DATA payloads. + + The Nexus Dashboard API signals certain errors by embedding an error + object inside ``DATA`` as ``{"code": , "message": ""}`` even + when the transport-level result is marked successful. Any payload dict + that contains a ``"code"`` key is treated as an error; the absence of + ``"code"`` means the payload is a genuine data body. + """ + + @staticmethod + def check( + data: Any, + context: str, + log: logging.Logger, + fail_callback=None, + ) -> None: + """Fail or raise if the response DATA contains an embedded error code. + + Args: + data: Value returned by ``nd.request()`` or extracted from + ``response_current["DATA"]``. + context: Human-readable description of the operation. + log: Logger instance. + fail_callback: Optional callable (e.g. ``module.fail_json``) that + accepts a ``msg`` keyword argument. When provided + it is called on error instead of raising + ``SwitchOperationError``. + """ + if isinstance(data, dict) and "code" in data: + error_msg = data.get("message", "Unknown error") + msg = f"{context} failed \u2014 controller returned error: " f"{error_msg} (code={data['code']})" + log.error(msg) + if fail_callback is not None: + fail_callback(msg=msg) + else: + raise SwitchOperationError(msg) + + +# ========================================================================= +# Fabric Switch Inventory +# ========================================================================= + + +class FabricSwitchInventory: + """Index a list of switch model instances for fast lookup by IP or ID. + + Use :meth:`from_fabric` to fetch, parse, and index in a single call, or + construct directly from an already-parsed list. :meth:`by_ip` and + :meth:`by_id` return keyed lookup dicts. + + Example:: + + inventory = FabricSwitchInventory.from_fabric(nd, fabric, log, SwitchDataModel) + switch = inventory.by_ip().get("192.0.2.1") + switch = inventory.by_id().get("FDO123456AB") + collection = inventory.collection # NDConfigCollection + """ + + def __init__(self, switches: List) -> None: + """Initialise the index from an already-parsed list of switch models. + + Args: + switches: List of parsed switch model instances. + """ + self.switches: List = switches + self.collection: Optional[NDConfigCollection] = None + + @classmethod + def from_fabric(cls, nd, fabric: str, log: logging.Logger, model_class: Type) -> "FabricSwitchInventory": + """Fetch, parse, and index the switch inventory for a fabric in one call. + + Args: + nd: NDModule instance used for the API request. + fabric: Fabric name to query. + log: Logger instance. + model_class: Pydantic model class to parse switch entries into + (e.g. ``SwitchDataModel``). + + Returns: + A new ``FabricSwitchInventory`` with ``switches`` and + ``collection`` populated. + """ + raw = cls.query_fabric_switches(nd, fabric, log) + collection = NDConfigCollection.from_api_response(response_data=raw, model_class=model_class) + instance = cls(list(collection)) + instance.collection = collection + return instance + + def by_ip(self) -> Dict[str, Any]: + """Return switches keyed by fabric management IP address. + + Returns: + Dict mapping ``fabric_management_ip`` → model instance. + Entries with an empty or ``None`` IP are excluded. + """ + return {sw.fabric_management_ip: sw for sw in self.switches if sw.fabric_management_ip} + + def by_id(self) -> Dict[str, Any]: + """Return switches keyed by switch ID (serial number). + + Returns: + Dict mapping ``switch_id`` → model instance. + Entries with an empty or ``None`` ID are excluded. + """ + return {sw.switch_id: sw for sw in self.switches if sw.switch_id} + + @staticmethod + def query_fabric_switches(nd, fabric: str, log: logging.Logger) -> List[Dict[str, Any]]: + """Fetch the raw switch inventory list for a fabric from the controller. + + Args: + nd: NDModule instance used for the API request. + fabric: Fabric name to query. + log: Logger instance. + + Returns: + List of raw switch dicts as returned by the controller API. + """ + endpoint = EpManageFabricsSwitchesGet() + endpoint.fabric_name = fabric + log.debug("query_fabric_switches: querying inventory for fabric '%s'", fabric) + + try: + response = nd.request(path=endpoint.path, verb=endpoint.verb) + except Exception as exc: + msg = f"Failed to retrieve switch inventory for fabric '{fabric}': {exc}" + log.error(msg) + nd.module.fail_json(msg=msg) + return [] + + ApiDataChecker.check( + response, + f"Switch inventory query for fabric '{fabric}'", + log, + nd.module.fail_json, + ) + + if isinstance(response, list): + return response + if isinstance(response, dict): + return response.get("switches", []) + return [] diff --git a/plugins/module_utils/manage_resource_manager/__init__.py b/plugins/module_utils/manage_resource_manager/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/manage_resource_manager/nd_manage_resource_manager_resources.py b/plugins/module_utils/manage_resource_manager/nd_manage_resource_manager_resources.py new file mode 100644 index 000000000..65fb21745 --- /dev/null +++ b/plugins/module_utils/manage_resource_manager/nd_manage_resource_manager_resources.py @@ -0,0 +1,885 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +import copy +import logging +import time + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule +from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results +from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum, OperationType +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_config_model import ( + ResourceManagerConfigModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_response_model import ResourceManagerResponse +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.remove_resource_by_id_request_model import ( + RemoveResourcesByIdsRequest, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.remove_resource_by_id_response_model import ( + RemoveResourcesByIdsResponse, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_request_model import ( + ResourceManagerBatchRequest, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_response_model import ( + ResourcesManagerBatchResponse, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_resources import ( + EpManageFabricResourcesGet, + EpManageFabricResourcesPost, + EpManageFabricResourcesActionsRemovePost, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.switch_data_models import ( + SwitchDataModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.fabric_inventory import FabricSwitchInventory +from ansible_collections.cisco.nd.plugins.module_utils.manage_resource_manager.resource_manager_diff import ResourceManagerDiffEngine +from ansible_collections.cisco.nd.plugins.module_utils.manage_resource_manager.resource_manager_helpers import ResourceManagerResourceHelpersMixin + +# ========================================================================= +# Resource Manager module +# ========================================================================= + + +class NDResourceManagerModule(ResourceManagerResourceHelpersMixin): + """ + Manage resources in Cisco Nexus Dashboard via the ND Manage v1 API. + + Uses pydantic models for input validation and smart endpoints for path/verb generation. + Preserves the same business logic as nd_manage_resource_manager.py. + """ + + def __init__( + self, + nd: NDModule, + results: Results, + log: logging.Logger | None = None, + ): + """Initialise the module, resolve fabric/state from ND params, and pre-fetch all resources. + + Queries the ND Manage API for all existing resources in ``fabric`` at construction + time and caches the result in ``self._all_resources``. The cached list is used as + the ``existing`` baseline for diff computation in both merged and deleted states, + avoiding repeated GET requests during the same module run. + + Args: + nd: Initialised ``NDModule`` wrapper that holds the Ansible module params + and the underlying ``RestSend`` HTTP client. + results: ``Results`` instance used to accumulate API call results and + build the final module output. + log: External logger if provided. If not, a module-level logger + (``logging.getLogger("nd.NDResourceManagerModule")``) is used. + """ + self.nd = nd + self.results = results + self.log = log if log is not None else logging.getLogger("nd.NDResourceManagerModule") + self.fabric = nd.params["fabric"] + self.state = nd.params["state"] + self.config = nd.params.get("config") or [] + + # ND-compatible tracking dicts + self.changed_dict = [{"merged": [], "deleted": [], "gathered": [], "debugs": []}] + self.api_responses = [] + + # Cached GET results — resources + self._all_resources = [] + self._resources_fetched = False + + # Get All resources for the given fabric and cache them for matching during merged/deleted operations + self._get_all_resources() + + # Translate playbook switch IPs to switch IDs through the shared fabric inventory helper. + self.config = self._resolve_switch_ids_in_config(self.config) + + # Resource collections — existing/previous snapshot at init, proposed populated in manage_state + self.existing: list[ResourceManagerResponse] = list(self._all_resources) + self.previous: list[ResourceManagerResponse] = list(self._all_resources) + self.proposed: list[ResourceManagerConfigModel] = [] + + # NDOutput for building consistent Ansible output across all states + self.output: NDOutput = NDOutput(output_level=nd.params.get("output_level", "normal")) + + # Proposed config list (plain dicts) for NDOutput proposed field + self._proposed_list: list = [] + + # Propagate Results metadata so every register_api_call() inherits state/check_mode + self.results.state = self.state + self.results.check_mode = nd.module.check_mode + + self.log.info( + "NDResourceManagerModule initialized: fabric=%s, state=%s, config_count=%s", + self.fabric, + self.state, + len(self.config), + ) + + # ------------------------------------------------------------------ + # Results registration helper + # ------------------------------------------------------------------ + + def _register_result(self, action, operation_type, message, changed, diff=None, verb=HttpVerbEnum.GET, path="", payload=None): + """Register a successful API call result with the Results tracker. + + Centralises the repeated pattern of setting action, operation_type, + response_current, result_current, diff_current and calling + ``register_api_call()``. All calls use ``RETURN_CODE=200`` and + ``success=True``; error paths in the main module entry point set + these fields directly. + + Args: + action: Short label for the operation (e.g. ``'merge'``, ``'delete'``, ``'gathered'``). + operation_type: ``OperationType`` enum value. + message: Human-readable message for ``response_current["MESSAGE"]``. + changed: Whether the operation mutated state. + diff: Diff dict to attach when provided. Defaults to ``{}``. + verb: ``HttpVerbEnum`` value for the HTTP method used. Defaults to ``GET``. + path: API endpoint path string. Defaults to ``""``. + payload: Request payload dict, or ``None`` for GET / no-body requests. + """ + self.results.action = action + self.results.operation_type = operation_type + self.results.verb_current = verb + self.results.path_current = path + self.results.payload_current = payload + self.results.response_current = {"RETURN_CODE": 200, "MESSAGE": message} + self.results.result_current = {"success": True, "changed": changed} + self.results.diff_current = diff if diff is not None else {} + self.results.register_api_call() + + # ------------------------------------------------------------------ + # Input validation + # ------------------------------------------------------------------ + + def _validate_required_fields_compat(self): + """Preserve legacy first-missing-field error messages for modifying states.""" + for item in self.config: + for field in ("scope_type", "pool_type", "pool_name", "entity_name"): + if item.get(field) is None: + self.log.error( + "Mandatory parameter '%s' is missing in config item: %s", + field, + item, + ) + raise ValueError("Mandatory parameter '{0}' missing".format(field)) + + if item.get("scope_type") != "fabric" and not item.get("switches"): + self.log.error( + "'switches' is required for scope_type='%s' but is missing in config item: %s", + item.get("scope_type"), + item, + ) + raise ValueError("switches : Required parameter not found") + + def _validate_input(self): + """Validate playbook config items and return typed proposed config. + + ``ResourceManagerConfigModel`` is the primary validation surface. For + ``merged`` and ``deleted`` it enforces mandatory fields, resource format, + strict ID pool names, pool/scope compatibility, and required switch lists. + For ``gathered`` it validates any supplied filter fields while allowing + partial criteria such as only ``entity_name`` or only ``pool_name``. + + Raises: + ValueError: On any validation failure. + + Returns: + Validated config models for ``merged``/``deleted``. Gathered returns + an empty list because gathered config is used as raw filter criteria. + """ + self.log.info( + "Validating input: state=%s, config_count=%s", + self.state, + len(self.config), + ) + + if not self.config: + if self.state in ("merged", "deleted"): + self.log.error( + "'config' is mandatory for state '%s' but was not provided", + self.state, + ) + raise ValueError("'config' element is mandatory for state '{0}'".format(self.state)) + return [] + + if self.state != "gathered": + self._validate_required_fields_compat() + return ResourceManagerDiffEngine.validate_configs(self.config, self.state, log=self.log) + + for idx, item in enumerate(self.config): + try: + ResourceManagerConfigModel.model_validate(item, context={"state": self.state}) + except ValidationError as exc: + error_detail = exc.errors() if hasattr(exc, "errors") else str(exc) + error_msg = f"Gathered filter validation failed for config index {idx}: {error_detail}" + self.log.error(error_msg) + raise ValueError(error_msg) from exc + except Exception as exc: + error_msg = f"Gathered filter validation failed for config index {idx}: {str(exc)}" + self.log.error(error_msg) + raise ValueError(error_msg) from exc + self.log.debug( + "Gathered filter [%s] validated: entity_name=%s, pool_name=%s, switches=%s", + idx, + item.get("entity_name"), + item.get("pool_name"), + item.get("switches"), + ) + + return [] + + # ------------------------------------------------------------------ + # ND API interaction helpers + # ------------------------------------------------------------------ + + def _get_all_resources(self): + """Fetch all existing resources for the fabric from the ND Manage API and cache them. + + Issues a single GET request to the fabric resources endpoint. The response is + normalised to a flat list of ``ResourceManagerResponse`` model instances (or raw + dicts when model parsing fails) and stored in ``self._all_resources``. Subsequent + calls return immediately without hitting the API again (``self._resources_fetched`` + flag). + + A 404 response is treated as an empty fabric (no resources allocated yet) rather + than an error. Any other ``NDModuleError`` is re-raised to the caller. + """ + if self._resources_fetched: + self.log.debug( + "Resources already cached for fabric=%s: %s resource(s)", + self.fabric, + len(self._all_resources), + ) + return + + self.log.info("Fetching all resources for fabric=%s", self.fabric) + + ep = EpManageFabricResourcesGet(fabric_name=self.fabric) + api_start = time.monotonic() + try: + data = self.nd.request(ep.path, ep.verb) + except NDModuleError as exc: + api_elapsed = time.monotonic() - api_start + if exc.status == 404: + # Fabric has no resources yet — that is valid + self.log.info( + "_get_all_resources: GET resources API response time %.3f second(s) (path=%s, state=%s, status=404)", + api_elapsed, + ep.path, + self.state, + ) + self.log.info( + "No resources found (404) for fabric=%s, treating as empty", + self.fabric, + ) + self._resources_fetched = True + return + self.log.exception( + "_get_all_resources: GET resources API call failed after %.3f second(s) (path=%s, state=%s)", + api_elapsed, + ep.path, + self.state, + ) + raise ValueError( + f"_get_all_resources: GET resources API call failed after {api_elapsed:.3f} second(s) (path={ep.path}, state={self.state})" + ) from exc + except Exception: + self.log.exception( + "_get_all_resources: GET resources API call failed after %.3f second(s) (path=%s, state=%s)", + time.monotonic() - api_start, + ep.path, + self.state, + ) + raise ValueError( + f"_get_all_resources: GET resources API call failed after {time.monotonic() - api_start:.3f} second(s) (path={ep.path}, state={self.state})" + ) + api_elapsed = time.monotonic() - api_start + _resp_count = len(data) if isinstance(data, list) else len(data["resources"]) if isinstance(data, dict) and "resources" in data else 0 + self.log.info( + "_get_all_resources: GET resources API response time %.3f second(s) (path=%s, state=%s, response_count=%s)", + api_elapsed, + ep.path, + self.state, + _resp_count, + ) + + # The ND API may return a list directly or {"resources": [...], "meta": {...}} + if isinstance(data, list): + self.log.debug("API returned a list with %s item(s)", len(data)) + raw_list = data + elif isinstance(data, dict) and "resources" in data: + self.log.debug( + "API returned dict with 'resources' key, %s resource(s)", + len(data["resources"]), + ) + raw_list = data["resources"] + elif isinstance(data, dict) and data: + self.log.debug("API returned a non-empty dict without 'resources' key, wrapping in list") + raw_list = [data] + else: + self.log.debug("API returned empty or unexpected data, treating as empty list") + raw_list = [] + + for raw in raw_list: + try: + resource_model = ResourceManagerResponse.from_response(raw) + self.log.debug( + "Parsed resource: entity_name=%s, pool_name=%s", + getattr(resource_model, "entity_name", None), + getattr(resource_model, "pool_name", None), + ) + self._all_resources.append(resource_model) + except Exception as exc: + # If parsing fails, keep the raw dict so we can still match on it + self.log.warning( + "Failed to parse resource into ResourceManagerResponse (keeping raw): %s | raw=%s", + exc, + raw, + ) + self._all_resources.append(raw) + + self._resources_fetched = True + self.log.info( + "Fetched %s resource(s) for fabric=%s", + len(self._all_resources), + self.fabric, + ) + + def _resolve_switch_ids_in_config(self, config): + """Translate config ``switches`` values from management IPs to switch IDs. + + Uses ``FabricSwitchInventory`` from ``fabric_inventory.py`` with ``SwitchDataModel`` so + resource manager shares the same inventory lookup behavior as switch manager. + Values already provided as switch IDs are preserved. Unresolved values fail + early with a clear validation error instead of being passed to the ND API. + + Args: + config: Raw config list from ``nd.params["config"]``. Not mutated. + + Returns: + A deep copy of ``config`` with switch IPs replaced by switch IDs. + """ + config_copy = copy.deepcopy(config or []) + + needs_inventory = any(item.get("switches") for item in config_copy if isinstance(item, dict)) + if not needs_inventory: + self.log.debug( + "_resolve_switch_ids_in_config: no switches found in %s config item(s), skipping inventory lookup", + len(config_copy), + ) + return config_copy + + self.log.debug( + "_resolve_switch_ids_in_config: querying switch inventory for fabric=%s to translate %s config item(s)", + self.fabric, + len(config or []), + ) + + inventory = FabricSwitchInventory.from_fabric(self.nd, self.fabric, self.log, SwitchDataModel) + switches_by_ip = inventory.by_ip() + switches_by_id = inventory.by_id() + + self.log.debug( + "_resolve_switch_ids_in_config: inventory indexes built for fabric=%s (by_ip=%s, by_id=%s)", + self.fabric, + len(switches_by_ip), + len(switches_by_id), + ) + + for idx, item in enumerate(config_copy): + raw_switch_list = item.get("switches") or [] + entity_name = item.get("entity_name") + scope_type = item.get("scope_type") + + self.log.debug( + "_resolve_switch_ids_in_config: [%s] entity='%s', scope_type='%s', raw_switch_list=%s", + idx, + entity_name, + scope_type, + raw_switch_list, + ) + + if not raw_switch_list: + self.log.debug( + "_resolve_switch_ids_in_config: [%s] entity='%s' — no switch list present, skipping translation", + idx, + entity_name, + ) + continue + + resolved = [] + for switch_value in raw_switch_list: + switch_key = str(switch_value).strip() + + if switch_key in switches_by_ip: + sw_id = switches_by_ip[switch_key].switch_id + self.log.debug( + "_resolve_switch_ids_in_config: [%s] entity='%s' switch '%s' -> resolved switchId='%s'", + idx, + entity_name, + switch_value, + sw_id, + ) + resolved.append(sw_id) + continue + + if switch_key in switches_by_id: + self.log.debug( + "_resolve_switch_ids_in_config: [%s] entity='%s' switch '%s' is already a switchId", + idx, + entity_name, + switch_value, + ) + resolved.append(switch_key) + continue + + msg = ( + "Switch '{0}' from config item index {1} (entity_name='{2}', " + "scope_type='{3}') was not found in fabric '{4}' by management IP " + "or switch ID." + ).format(switch_value, idx, entity_name, scope_type, self.fabric) + self.log.error("_resolve_switch_ids_in_config: %s", msg) + raise ValueError(msg) + + item["switches"] = resolved + self.log.debug( + "_resolve_switch_ids_in_config: [%s] entity='%s' final switches list: %s -> %s", + idx, + entity_name, + raw_switch_list, + resolved, + ) + + self.log.debug( + "_resolve_switch_ids_in_config: completed, returning %s translated config item(s)", + len(config_copy), + ) + return config_copy + + def manage_merged(self): + """Create or update resources to match the desired state defined in the playbook. + + Delegates diff computation to ``ResourceManagerDiffEngine.compute_changes`` to + classify each proposed resource as ``to_add`` (new) or ``to_update`` (value + changed). Idempotent resources (already matching) are skipped. + + In check mode, logs what would be created without issuing any API calls. + Otherwise, sends a single batch POST request containing all pending payloads and + validates each item in the response against the sent config via + ``ResourceManagerDiffEngine.validate_resource_api_fields``. + + Raises: + NDModuleError: Propagated from ``self.nd.request`` on API failure. + """ + self.log.info( + "manage_merged: Processing %s config item(s) for fabric=%s", + len(self.config), + self.fabric, + ) + + # Use compute_changes as the canonical diff engine. + changes = ResourceManagerDiffEngine.compute_changes(self.proposed, self.existing, log=self.log) + + # Propagate partial-match mismatch diagnostics to the output diff (GAP-7). + self.changed_dict[0]["debugs"].extend(changes["debugs"]) + + # Resources that need to be created: new (to_add) or value changed (to_update). + pending_items: list[tuple[ResourceManagerConfigModel, str, ResourceManagerResponse]] = changes["to_add"] + changes["to_update"] + + if not pending_items: + self.log.debug("manage_merged: No resources to create (all idempotent).") + self._register_result("merge", OperationType.QUERY, "all resources idempotent", changed=False) + return + + # Build payload list alongside a cfg reference for post-create validation (GAP-5). + pending_payloads = [] + for cfg, sw, _existing in pending_items: + payload = self._build_create_payload(cfg, switch_ip=sw) + pending_payloads.append((cfg, payload)) + self.log.debug( + "manage_merged: Queuing resource for batch create: entity_name=%s, pool_name=%s, scope_type=%s, switch_ip=%s", + cfg.entity_name, + cfg.pool_name, + cfg.scope_type, + sw, + ) + + # Track diff BEFORE the API call so --check mode also shows what would change (GAP-3). + self.changed_dict[0]["merged"].extend(p for _cfg, p in pending_payloads) + + ep = EpManageFabricResourcesPost(fabric_name=self.fabric) + if self.nd.module.check_mode: + self.log.info( + "Check mode: would create %s resource(s) for fabric=%s", + len(pending_payloads), + self.fabric, + ) + + payloads_only = [p for _cfg, p in pending_payloads] + batch_payload = ResourceManagerBatchRequest.model_validate({"resources": payloads_only}).to_payload() + self._register_result( + "merge", + OperationType.CREATE, + "check mode — skipped", + changed=False, + diff={"merged": payloads_only}, + verb=HttpVerbEnum.POST, + path=ep.path, + payload=batch_payload, + ) + return + + self.log.info( + "manage_merged: Making batch API call with %s resource(s) for fabric=%s", + len(pending_payloads), + self.fabric, + ) + + payloads_only = [p for _cfg, p in pending_payloads] + batch = ResourceManagerBatchRequest.model_validate({"resources": payloads_only}) + api_start = time.monotonic() + try: + resp_data = self.nd.request(ep.path, ep.verb, data=batch.to_payload()) + except Exception: + self.log.exception( + "manage_merged: Batch create API call failed after %.3f second(s) (path=%s, resource_count=%s)", + time.monotonic() - api_start, + ep.path, + len(pending_payloads), + ) + raise ValueError( + f"manage_merged: Batch create API call failed {time.monotonic() - api_start:.3f} second(s)" + f" (path={ep.path}, resource_count={len(pending_payloads)})" + ) + api_elapsed = time.monotonic() - api_start + self.log.info( + "manage_merged: Batch create API response time %.3f second(s) (path=%s, resource_count=%s)", + api_elapsed, + ep.path, + len(pending_payloads), + ) + + # Parse batch response. + batch_response = ResourcesManagerBatchResponse.from_response(resp_data) + self.log.debug( + "manage_merged: Batch API response parsed — %s item(s) returned", + len(batch_response.resources), + ) + + # Build a normalised entity_name → cfg lookup for GAP-5 field validation. + # If two items share a normalised name (unusual), the last one wins; that is + # acceptable because validate_resource_api_fields uses order-insensitive comparison. + cfg_by_entity: dict[str, ResourceManagerConfigModel] = { + ResourceManagerDiffEngine._normalize_entity_key(cfg.entity_name, log=self.log): cfg for cfg, _payload in pending_payloads + } + + for resp_item in batch_response.resources: + self.api_responses.append({"RETURN_CODE": 200, "DATA": resp_item.model_dump(by_alias=True, exclude_none=True)}) + # GAP-5: Validate that the API response fields match what we sent. + if resp_item.entity_name is not None: + norm_key = ResourceManagerDiffEngine._normalize_entity_key(resp_item.entity_name, log=self.log) + matched_cfg = cfg_by_entity.get(norm_key) + if matched_cfg is not None: + ResourceManagerDiffEngine.validate_resource_api_fields(self.nd, matched_cfg, resp_item, "Resource", log=self.log) + + self.log.info( + "manage_merged: Batch create successful — %s resource(s) created for fabric=%s", + len(pending_payloads), + self.fabric, + ) + + # Register the batch create with Results + self._register_result( + "merge", + OperationType.CREATE, + f"batch create successful — {len(pending_payloads)} resource(s)", + changed=True, + diff={"merged": [p for _cfg, p in pending_payloads]}, + verb=HttpVerbEnum.POST, + path=ep.path, + payload=batch.to_payload(), + ) + + def manage_deleted(self): + """Delete resources that are listed in the playbook config and exist in the fabric. + + Uses ``ResourceManagerDiffEngine.compute_changes`` to identify which proposed + resources are present in the ND fabric (``idempotent`` or ``to_update`` buckets). + Only explicitly listed resources are deleted; unrelated existing resources are + left untouched, matching the ND nd_rm_get_diff_deleted() behaviour. + + In check mode, records which resource IDs would be removed without issuing any + API calls. Otherwise, sends a batch remove POST request with the collected + resource IDs. + + Raises: + NDModuleError: Propagated from ``self.nd.request`` on API failure. + """ + self.log.info( + "manage_deleted: Processing %s config item(s) for fabric=%s", + len(self.config), + self.fabric, + ) + + # Use compute_changes as the canonical diff engine. + changes = ResourceManagerDiffEngine.compute_changes(self.proposed, self.existing, log=self.log) + + # Propagate partial-match mismatch diagnostics to the output diff (GAP-7). + self.changed_dict[0]["debugs"].extend(changes["debugs"]) + + # Collect resource IDs for entries that exist in the fabric. + # idempotent → resource exists with the same value → still delete it. + # to_update → resource exists but with a different value → still delete it. + # to_add → resource does not exist → nothing to delete. + # to_delete → "override" bucket (unmatched existing) → ignored; deleted state + # only removes what is explicitly listed in the playbook config, + # matching ND's nd_rm_get_diff_deleted() behaviour. + resource_ids = [] + for _cfg, _sw, existing_res in changes["idempotent"] + changes["to_update"]: + rid = self._get_resource_id(existing_res) + if rid is not None and rid not in resource_ids: + self.log.debug( + "manage_deleted: Queuing resource ID '%s' for deletion (entity_name=%s, pool_name=%s, switch_ip=%s)", + rid, + _cfg.entity_name, + _cfg.pool_name, + _sw, + ) + resource_ids.append(rid) + elif rid is not None: + self.log.debug( + "manage_deleted: Resource ID '%s' already queued, skipping duplicate", + rid, + ) + else: + self.log.debug( + "manage_deleted: Matched resource has no resource ID, skipping: %s", + existing_res, + ) + + if not resource_ids: + # Nothing to delete — idempotent + self.log.info( + "manage_deleted: No matching resources found to delete for fabric=%s, nothing to do", + self.fabric, + ) + self._register_result("delete", OperationType.QUERY, "no matching resources to delete", changed=False) + return + + self.log.info( + "manage_deleted: Collected %s resource ID(s) to delete: %s", + len(resource_ids), + resource_ids, + ) + + self.changed_dict[0]["deleted"].extend(str(r) for r in resource_ids) + + if self.nd.module.check_mode: + self.log.info( + "Check mode: would delete %s resource(s): %s", + len(resource_ids), + resource_ids, + ) + self.api_responses.append({"RETURN_CODE": 200, "DATA": {"resourceIds": resource_ids}}) + ep = EpManageFabricResourcesActionsRemovePost(fabric_name=self.fabric) + remove_req = RemoveResourcesByIdsRequest(resource_ids=resource_ids) + self._register_result( + "delete", + OperationType.DELETE, + "check mode — skipped", + changed=False, + diff={"deleted": resource_ids}, + verb=HttpVerbEnum.POST, + path=ep.path, + payload=remove_req.to_payload(), + ) + return + + ep = EpManageFabricResourcesActionsRemovePost(fabric_name=self.fabric) + remove_req = RemoveResourcesByIdsRequest(resource_ids=resource_ids) + api_start = time.monotonic() + try: + resp_data = self.nd.request(ep.path, ep.verb, data=remove_req.to_payload()) + except Exception: + self.log.exception( + "manage_deleted: Delete API call failed after %.3f second(s) (path=%s, resource_count=%s)", + time.monotonic() - api_start, + ep.path, + len(resource_ids), + ) + raise ValueError( + f"manage_deleted: Delete API call failed {time.monotonic() - api_start:.3f} second(s) (path={ep.path}, resource_count={len(resource_ids)})" + ) + api_elapsed = time.monotonic() - api_start + self.log.info( + "manage_deleted: Delete API response time %.3f second(s) (path=%s, resource_count=%s)", + api_elapsed, + ep.path, + len(resource_ids), + ) + + remove_response = RemoveResourcesByIdsResponse.from_response(resp_data) + + self.log.debug( + "manage_deleted: Delete API response parsed — %s item(s) returned", + len(remove_response.resources), + ) + + for resp_item in remove_response.resources: + self.api_responses.append({"RETURN_CODE": 200, "DATA": resp_item.model_dump(by_alias=True, exclude_none=True)}) + + self.log.info( + "manage_deleted: Successfully deleted %s resource(s): %s", + len(resource_ids), + resource_ids, + ) + + # Register the delete with Results + self._register_result( + "delete", + OperationType.DELETE, + f"deleted {len(resource_ids)} resource(s)", + changed=True, + diff={"deleted": resource_ids}, + verb=HttpVerbEnum.POST, + path=ep.path, + payload=remove_req.to_payload(), + ) + + def manage_gathered(self): + """Return resources from the ND fabric, optionally filtered by config criteria. + + When no ``config`` is provided, all resources cached in ``self._all_resources`` are + translated to the playbook format and returned. When ``config`` is provided, each + filter item is processed in sequence; a resource must satisfy every non-None + criterion in the filter (``entity_name``, ``pool_name``, ``switches``) to be + included. Deduplication is applied across filter items using the resource ID so + that a resource matching multiple filters appears only once in the output. + + Results are stored in ``self.changed_dict[0]['gathered']`` and + ``self.api_responses``. + """ + config_count = len(self.config) if self.config else 0 + self.log.info( + "manage_gathered: Gathering resources for fabric=%s, filter_count=%s", + self.fabric, + config_count, + ) + + if not self.config: + # No filters — return everything translated to merged format + results = self.translate_gathered_results(self._all_resources) + self.log.info( + "manage_gathered: No filter criteria provided, returning all %s resource(s)", + len(results), + ) + self.api_responses.extend(results) + self.changed_dict[0]["gathered"].extend(results) + return + + results = self._apply_gathered_filters() + + self.log.info( + "manage_gathered: Gather complete, %s resource(s) matched filters", + len(results), + ) + self.api_responses.extend(results) + self.changed_dict[0]["gathered"].extend(results) + + # Register the gathered query with Results + ep = EpManageFabricResourcesGet(fabric_name=self.fabric) + self._register_result("gathered", OperationType.QUERY, f"gathered {len(results)} resource(s)", changed=False, path=ep.path) + + def manage_state(self): + """Validate input and dispatch to the appropriate state handler. + + Runs model-backed validation on the raw config and dispatches to + ``manage_merged``, ``manage_deleted``, or ``manage_gathered`` through a + small state handler map. + """ + self.log.info("manage_state: Dispatching to state handler: state=%s", self.state) + validated_configs = self._validate_input() + + if self.state != "gathered": + self.proposed = validated_configs + self._proposed_list = [cfg.model_dump(by_alias=True, exclude_none=True) for cfg in self.proposed] + + state_handlers = { + "merged": self.manage_merged, + "deleted": self.manage_deleted, + "gathered": self.manage_gathered, + } + handler = state_handlers.get(self.state) + if handler is None: + raise ValueError("Unsupported state '{0}'".format(self.state)) + + self.log.info("manage_state: Dispatching to %s()", handler.__name__) + handler() + + self.log.info("manage_state: State handler completed for state=%s", self.state) + + def exit_module(self): + """Build the final module result and call ``exit_json`` to return it to Ansible. + + Uses ``NDOutput.format_with_verbosity()`` so generic module output stays + stable and API-call metadata is exposed only under ``api_*`` keys when + Ansible verbosity requests it. + """ + verbosity = self.nd.module._verbosity if hasattr(self.nd.module, "_verbosity") else 0 + + if self.state == "gathered": + self.log.info( + "exit_module: gathered state, returning %s resource(s)", + len(self.changed_dict[0]["gathered"]), + ) + final = self.output.format_with_verbosity( + verbosity, + self.results, + changed=False, + after=self.translate_gathered_results(self.existing), + gathered=self.changed_dict[0]["gathered"], + ) + self.nd.module.exit_json(**final) + return + + changed = len(self.changed_dict[0]["merged"]) > 0 or len(self.changed_dict[0]["deleted"]) > 0 + if self.nd.module.check_mode: + self.log.info( + "exit_module: check_mode is enabled, overriding changed=False (would have been changed=%s)", + changed, + ) + changed = False + + self.log.info( + "exit_module: merged=%s, deleted=%s, gathered=%s, changed=%s, check_mode=%s", + len(self.changed_dict[0]["merged"]), + len(self.changed_dict[0]["deleted"]), + len(self.changed_dict[0]["gathered"]), + changed, + self.nd.module.check_mode, + ) + + # Re-query to capture post-operation state for current snapshot + if not self.nd.module.check_mode and changed: + self._resources_fetched = False + self._all_resources = [] + self._get_all_resources() + self.existing = list(self._all_resources) + + final_results_data = { + "changed": changed, + "before": self.translate_gathered_results(self.previous), + "after": self.translate_gathered_results(self.existing), + "diff": self.changed_dict, + } + + output_level = self.nd.params.get("output_level", "normal") + if output_level in ("info", "debug"): + final_results_data["proposed"] = self._proposed_list + + final = self.output.format_with_verbosity(verbosity, self.results, **final_results_data) + self.nd.module.exit_json(**final) diff --git a/plugins/module_utils/manage_resource_manager/resource_manager_diff.py b/plugins/module_utils/manage_resource_manager/resource_manager_diff.py new file mode 100644 index 000000000..7a0478d39 --- /dev/null +++ b/plugins/module_utils/manage_resource_manager/resource_manager_diff.py @@ -0,0 +1,801 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +import ipaddress +import logging +from typing import Any + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ValidationError +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_config_model import ( + ResourceManagerConfigModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_response_model import ResourceManagerResponse +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_request_model import ( + FabricScope, + DeviceScope, + DeviceInterfaceScope, + DevicePairScope, + LinkScope, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.constants import ( + API_SCOPE_TYPE_TO_PLAYBOOK, + POOL_SCOPE_MAP, +) + +# ========================================================================= +# Validation & Diff +# ========================================================================= + + +class ResourceManagerDiffEngine: + """Provide stateless validation and diff computation helpers.""" + + @staticmethod + def _normalize_pool_name(pool_name: str, log: logging.Logger) -> str | None: + """Normalize pool_name to canonical constant form based on ``POOL_SCOPE_MAP`` keys. + + Converts API-style names like ``loopbackId`` to playbook constant names like + ``LOOPBACK_ID`` when a token-equivalent key exists in ``POOL_SCOPE_MAP``. + + Args: + pool_name: Raw pool name from config or API. + log: Logger instance. + + Returns: + Canonical pool constant when recognized, otherwise the stripped input value. + """ + if pool_name is None: + log.debug("_normalize_pool_name: pool_name is None, returning None") + return None + + raw = str(pool_name).strip() + if not raw: + log.debug("_normalize_pool_name: pool_name stripped to empty string, returning ''") + return raw + + token = "".join(ch.lower() for ch in raw if ch.isalnum()) + if not token: + log.debug( + "_normalize_pool_name: no alphanumeric chars in pool_name='%s', returning raw='%s'", + pool_name, + raw, + ) + return raw + + canonical_by_token = {"".join(ch.lower() for ch in key if ch.isalnum()): key for key in POOL_SCOPE_MAP} + result = canonical_by_token.get(token, raw) + if result != raw: + log.debug( + "_normalize_pool_name: pool_name='%s' normalized to canonical='%s' (token='%s')", + pool_name, + result, + token, + ) + else: + log.debug( + "_normalize_pool_name: pool_name='%s' not found in POOL_SCOPE_MAP by token='%s', returning raw='%s'", + pool_name, + token, + raw, + ) + log.debug("Returning normalized pool_name='%s' from raw='%s' ", result, raw) + return result + + @staticmethod + def _normalize_entity_key(entity_name: str, log: logging.Logger) -> str: + """Normalize entity_name for order-insensitive comparison. + + Args: + entity_name: Raw entity name string. + log: Logger instance. + + Returns: + Tilde-separated string with parts sorted alphabetically. + """ + normalize_entity_name = "~".join(sorted(entity_name.split("~"))) + log.debug("Returning normalized entity_name='%s' from raw='%s'", normalize_entity_name, entity_name) + return normalize_entity_name + + @staticmethod + def _resource_attr(resource, model_attr: str, dict_key: str | None = None): + """Return a field from either a ResourceManagerResponse model or raw API dict.""" + if hasattr(resource, model_attr): + return getattr(resource, model_attr) + if isinstance(resource, dict): + return resource.get(dict_key or model_attr) + return None + + @staticmethod + def _scope_details(resource): + """Return scope details from either a model resource or raw API dict.""" + if hasattr(resource, "scope_details"): + return getattr(resource, "scope_details", None) + if isinstance(resource, dict): + return resource.get("scopeDetails") + return None + + @staticmethod + def _dict_scope_key(attr_name: str) -> str: + """Convert a snake_case scope attribute name to the ND API camelCase key.""" + parts = attr_name.split("_") + return parts[0] + "".join(part.title() for part in parts[1:]) + + @staticmethod + def _extract_scope_switch_key_val(scope_details, switch_key, src_switch_key, log: logging.Logger) -> str | None: + """Extract a switch identifier from a scope_details model using the correct attribute name. + + Selects between ``switch_key`` (for single-switch scopes: device, device_interface) + and ``src_switch_key`` (for dual-switch scopes: device_pair, link). Returns None + for fabric-scoped resources which carry no switch identity. + + Args: + scope_details: A scope model instance (FabricScope, DeviceScope, + DeviceInterfaceScope, DevicePairScope, LinkScope) or None. + switch_key: Attribute name to read for single-switch scopes + (e.g. ``'switch_id'`` or ``'switch_ip'``). + src_switch_key: Attribute name to read for dual-switch scopes + (e.g. ``'src_switch_id'`` or ``'src_switch_ip'``). + log: Logger instance. + + Returns: + The switch identifier string, or None if the scope is fabric-level + or ``scope_details`` is None. + """ + if scope_details is None: + log.debug("_extract_scope_switch_key_val: scope_details is None, returning None") + return None + if isinstance(scope_details, dict): + scope_type = scope_details.get("scopeType") + if scope_type == "fabric": + log.debug("_extract_scope_switch_key_val: fabric scope dict has no switch identity, returning None") + return None + switch_dict_key = ResourceManagerDiffEngine._dict_scope_key(switch_key) + src_switch_dict_key = ResourceManagerDiffEngine._dict_scope_key(src_switch_key) + if scope_type in ("device", "deviceInterface"): + value = scope_details.get(switch_dict_key) + log.debug("_extract_scope_switch_key_val: dict %s scope, %s='%s'", scope_type, switch_dict_key, value) + return value + if scope_type in ("devicePair", "link"): + value = scope_details.get(src_switch_dict_key) + log.debug("_extract_scope_switch_key_val: dict %s scope, %s='%s'", scope_type, src_switch_dict_key, value) + return value + value = scope_details.get(switch_dict_key) or scope_details.get(src_switch_dict_key) + log.debug("_extract_scope_switch_key_val: unknown dict scope type %s, fallback value='%s'", scope_type, value) + return value + if isinstance(scope_details, FabricScope): + log.debug("_extract_scope_switch_key_val: FabricScope has no switch identity, returning None") + return None + if isinstance(scope_details, (DeviceScope, DeviceInterfaceScope)): + value = getattr(scope_details, switch_key, None) + log.debug("_extract_scope_switch_key_val: %s scope, %s='%s'", type(scope_details).__name__, switch_key, value) + return value + if isinstance(scope_details, (DevicePairScope, LinkScope)): + value = getattr(scope_details, src_switch_key, None) + log.debug("_extract_scope_switch_key_val: %s scope, %s='%s'", type(scope_details).__name__, src_switch_key, value) + return value + # Fallback: try common attribute names + value = getattr(scope_details, switch_key, None) or getattr(scope_details, src_switch_key, None) + log.debug("_extract_scope_switch_key_val: unknown scope type %s, fallback value='%s'", type(scope_details).__name__, value) + return value + + @staticmethod + def _extract_scope_type(scope_details, log: logging.Logger) -> str | None: + """Extract and map the playbook-style scope_type from a scope_details model. + + Args: + scope_details: A scope model instance. + log: Logger instance. + + Returns: + Playbook-style scope_type string (e.g. 'device_interface'), or None. + """ + if scope_details is None: + log.debug("_extract_scope_type: scope_details is None, returning None") + return None + if isinstance(scope_details, dict): + raw = scope_details.get("scopeType") + else: + raw = getattr(scope_details, "scope_type", None) + if not raw: + log.debug("_extract_scope_type: no scope_type attribute on %s, returning None", type(scope_details).__name__) + return None + mapped = API_SCOPE_TYPE_TO_PLAYBOOK.get(raw, raw) + log.debug("_extract_scope_type: raw='%s' mapped to '%s'", raw, mapped) + return mapped + + @staticmethod + def _compare_resource_values(have: str, want: str, log: logging.Logger) -> bool: + """Compare resource values with IPv4/IPv6 network awareness. + + Args: + have: Existing resource value from the API. + want: Proposed resource value from the playbook. + log: Logger instance + + Returns: + True if the values are functionally equivalent, False otherwise. + """ + if have is None and want is None: + log.debug("_compare_resource_values: both have and want are None, returning True") + return True + if have is None or want is None: + log.debug("_compare_resource_values: one of have or want is None (have=%s, want=%s), returning False", have, want) + return False + + have = str(have).strip() + want = str(want).strip() + + def _classify(val): + if "/" in val: + try: + parsed = ipaddress.ip_network(val, strict=False) + log.debug("_compare_resource_values: classified '%s' as network: %s", val, parsed) + return "network", parsed + except ValueError: + log.debug("_compare_resource_values: failed to parse '%s' as network, continuing", val) + try: + parsed = ipaddress.ip_address(val) + log.debug("_compare_resource_values: classified '%s' as address: %s", val, parsed) + return "address", parsed + except ValueError: + log.debug("_compare_resource_values: failed to parse '%s' as address, classifying as raw", val) + log.debug("_compare_resource_values: classified '%s' as raw string", val) + return "raw", val + + th, vh = _classify(have) + tw, vw = _classify(want) + + if th == tw == "address": + result = vh.exploded == vw.exploded + log.debug("_compare_resource_values: both are addresses (have=%s, want=%s), exploded comparison result=%s", vh.exploded, vw.exploded, result) + return result + if th == tw == "network": + result = vh == vw + log.debug("_compare_resource_values: both are networks (have=%s, want=%s), comparison result=%s", vh, vw, result) + return result + result = have == want + log.debug("_compare_resource_values: raw string comparison (have='%s', want='%s'), result=%s", have, want, result) + return result + + @staticmethod + def _make_resource_key( + entity_name: str | None, + pool_name: str | None, + scope_type: str | None, + switch_ip: str | None, + log: logging.Logger, + ) -> tuple: + """Build a normalized deduplication key for a resource entry. + + Args: + entity_name: Resource entity name (will be tilde-normalized). + pool_name: Pool name. + scope_type: Playbook-style scope type. + switch_ip: Switch IP, or None for fabric-scoped resources. + log: Logger instance. + + Returns: + Tuple used as a dict key for matching proposed vs existing. + """ + norm_entity = ResourceManagerDiffEngine._normalize_entity_key(entity_name, log=log) if entity_name else None + log.debug("_make_resource_key: entity_name provided %s, normalized to '%s'", entity_name, norm_entity) + + norm_pool = ResourceManagerDiffEngine._normalize_pool_name(pool_name, log=log) + log.debug("_make_resource_key: pool_name='%s' normalized to '%s'", pool_name, norm_pool) + + # device_pair and link encode both endpoints in entity_name; + # normalize switch to None so existing_index and proposed lookups align. + if scope_type in ("device_pair", "link"): + norm_switch = None + log.debug("_make_resource_key: scope_type='%s' is multi-endpoint, setting norm_switch=None (original switch_ip='%s')", scope_type, switch_ip) + else: + norm_switch = switch_ip + log.debug("_make_resource_key: scope_type='%s' is single-endpoint, keeping norm_switch='%s'", scope_type, norm_switch) + + key = (norm_entity, norm_pool, scope_type, norm_switch) + log.debug( + "_make_resource_key: built key=%s from entity_name='%s', pool_name='%s', scope_type='%s', switch_ip='%s'", + key, + entity_name, + pool_name, + scope_type, + switch_ip, + ) + + return key + + @staticmethod + def validate_configs( + config: dict[str, Any] | list[dict[str, Any]], + state: str, + log: logging.Logger, + ) -> list[ResourceManagerConfigModel]: + """Validate raw module config and return typed resource configurations. + + Args: + config: Raw config dict or list of dicts from module parameters. + state: Requested module state. + log: Logger instance. + + Returns: + list of validated ``ResourceManagerConfigModel`` objects. + """ + log.debug("ENTER: validate_configs()") + + configs_list = config if isinstance(config, list) else [config] + log.debug("Normalized to %s configuration(s)", len(configs_list)) + + validated_configs: list[ResourceManagerConfigModel] = [] + for idx, cfg in enumerate(configs_list): + try: + validated = ResourceManagerConfigModel.model_validate(cfg, context={"state": state}) + validated_configs.append(validated) + except ValidationError as e: + error_detail = e.errors() if hasattr(e, "errors") else str(e) + error_msg = f"Configuration validation failed for config index {idx}: {error_detail}" + log.error(error_msg) + raise ValueError(error_msg) from e + except Exception as e: + error_msg = f"Configuration validation failed for config index {idx}: {str(e)}" + log.error(error_msg) + raise ValueError(error_msg) from e + + if not validated_configs: + log.warning("No valid configurations found in input") + return validated_configs + + # Duplicate check: (entity_name, pool_name, scope_type, frozenset(switch)) + seen_keys: set = set() + duplicate_keys: set = set() + log.debug( + "validate_configs: starting duplicate check on %s validated config(s)", + len(validated_configs), + ) + for cfg_dup_idx, cfg in enumerate(validated_configs): + key = ( + cfg.entity_name, + cfg.pool_name, + cfg.scope_type, + frozenset(cfg.switches or []), + ) + log.debug( + "validate_configs: duplicate-check [%s] — entity_name='%s', pool_name='%s', scope_type='%s', switches=%s, key_seen_before=%s", + cfg_dup_idx, + cfg.entity_name, + cfg.pool_name, + cfg.scope_type, + list(cfg.switches or []), + key in seen_keys, + ) + if key in seen_keys: + log.warning( + "validate_configs: [%s] duplicate key detected — entity_name='%s', pool_name='%s', scope_type='%s'", + cfg_dup_idx, + cfg.entity_name, + cfg.pool_name, + cfg.scope_type, + ) + duplicate_keys.add(key) + else: + log.debug( + "validate_configs: [%s] key is unique so far — entity_name='%s'", + cfg_dup_idx, + cfg.entity_name, + ) + seen_keys.add(key) + + if duplicate_keys: + error_msg = f"Duplicate config entries found: {[str(k) for k in duplicate_keys]}. Each resource must appear only once." + log.error(error_msg) + raise ValueError(error_msg) + + log.info( + "Successfully validated %s configuration(s)", + len(validated_configs), + ) + log.debug("EXIT: validate_configs() -> %s configs", len(validated_configs)) + return validated_configs + + @staticmethod + def compute_changes( + proposed: list[ResourceManagerConfigModel], + existing: list[ResourceManagerResponse], + log: logging.Logger, + ) -> dict[str, list]: + """Compare proposed and existing resources and categorize changes. + + Uses ``ResourceManagerResponse`` fields (``entity_name``, ``pool_name``, + ``scope_details``, ``resource_value``) to build a matching index and + classify each proposed entry. + + Args: + proposed: Validated ``ResourceManagerConfigModel`` objects + representing desired state. + existing: ``ResourceManagerResponse`` models from the ND API + representing current state. + log: Logger instance. + + Returns: + dict mapping change buckets to item lists: + - ``to_add``: ``(ResourceManagerConfigModel, switch_ip)`` tuples + - ``to_update``: ``(ResourceManagerConfigModel, switch_ip)`` tuples + - ``to_delete``: ``ResourceManagerResponse`` items + - ``idempotent``: ``(ResourceManagerConfigModel, switch_ip)`` tuples + """ + log.debug("ENTER: compute_changes()") + log.debug( + "Comparing %s proposed vs %s existing resources", + len(proposed), + len(existing), + ) + + # Build index of existing resources keyed by + # (normalized_entity, pool_name, playbook_scope_type, switch_id) + existing_index: dict[tuple, ResourceManagerResponse] = {} + for res in existing: + entity = ResourceManagerDiffEngine._resource_attr(res, "entity_name", "entityName") + pool = ResourceManagerDiffEngine._resource_attr(res, "pool_name", "poolName") + scope_details = ResourceManagerDiffEngine._scope_details(res) + scope_type = ResourceManagerDiffEngine._extract_scope_type(scope_details, log=log) + switch_id = ResourceManagerDiffEngine._extract_scope_switch_key_val(scope_details, switch_key="switch_id", src_switch_key="src_switch_id", log=log) + key = ResourceManagerDiffEngine._make_resource_key(entity, pool, scope_type, switch_id, log=log) + existing_index[key] = res + log.debug( + "Existing index entry: entity=%s, pool=%s, scope_type=%s, switch_id=%s", + entity, + pool, + scope_type, + switch_id, + ) + + log.debug("Built existing index with %s entries", len(existing_index)) + + changes: dict[str, list] = { + "to_add": [], + "to_update": [], + "to_delete": [], + "idempotent": [], + "debugs": [], + } + + # Build a secondary index keyed by normalised entity_name only. + # Used to detect partial matches (same entity, different pool/scope/switch) + # and populate the debugs bucket to mirror ND's mismatch logging. + entity_only_index: dict[str, list[ResourceManagerResponse]] = {} + for res in existing: + entity_name = ResourceManagerDiffEngine._resource_attr(res, "entity_name", "entityName") or "" + norm = ResourceManagerDiffEngine._normalize_entity_key(entity_name, log=log) + entity_only_index.setdefault(norm, []).append(res) + log.debug( + "entity_only_index: added entity='%s' under norm_key='%s' (total under key: %s)", + entity_name, + norm, + len(entity_only_index[norm]), + ) + + log.debug("Built entity_only_index with %s unique normalised key(s)", len(entity_only_index)) + + # Track which existing keys matched at least one proposed entry + matched_existing_keys: set = set() + # Track partial-match diagnostics already emitted to avoid duplicates. + seen_mismatch_keys: set = set() + + # Categorise proposed resources + for cfg in proposed: + scope_type = cfg.scope_type + pool_name = cfg.pool_name + entity_name = cfg.entity_name + resource_value = cfg.resource + + log.debug( + "Processing proposed cfg: entity=%s, pool=%s, scope=%s, resource=%s, switches=%s", + entity_name, + pool_name, + scope_type, + resource_value, + cfg.switches, + ) + + # device_pair and link encode both endpoints in entity_name; one lookup covers the pair. + if scope_type in ("device_pair", "link"): + switches = [None] + log.debug( + "scope_type='%s' is multi-endpoint — using single switch=None lookup for entity='%s'", + scope_type, + entity_name, + ) + else: + switches = cfg.switches if (scope_type != "fabric" and cfg.switches) else [None] + log.debug( + "scope_type='%s' — resolved switches=%s for entity='%s'", + scope_type, + switches, + entity_name, + ) + + for sw in switches: + key = ResourceManagerDiffEngine._make_resource_key(entity_name, pool_name, scope_type, sw, log=log) + log.debug( + "Lookup key=%s for entity='%s', pool='%s', scope='%s', switch=%s", + key, + entity_name, + pool_name, + scope_type, + sw, + ) + existing_res = existing_index.get(key) + + if existing_res is None: + log.info( + "Resource (entity=%s, pool=%s, scope=%s, switch=%s) not found in existing — marking to_add", + entity_name, + pool_name, + scope_type, + sw, + ) + changes["to_add"].append((cfg, sw, None)) + + # GAP-7: Partial-match detection — same entity_name, different + # pool_name / scope_type / switch_ip. Mirrors ND's + # nd_rm_get_mismatched_values() / changed_dict["debugs"] logic. + norm = ResourceManagerDiffEngine._normalize_entity_key(entity_name, log=log) + partials = entity_only_index.get(norm, []) + log.debug( + "Partial-match scan for entity='%s' (norm='%s'): %s candidate(s)", + entity_name, + norm, + len(partials), + ) + for partial in partials: + partial_resource_id = ResourceManagerDiffEngine._resource_attr(partial, "resource_id", "resourceId") + mismatch_key = ( + entity_name, + partial_resource_id, + ) + if mismatch_key in seen_mismatch_keys: + log.debug( + "compute_changes: skipping duplicate partial match for entity='%s', resource_id=%s", + entity_name, + ResourceManagerDiffEngine._resource_attr(partial, "resource_id", "resourceId"), + ) + continue + seen_mismatch_keys.add(mismatch_key) + + partial_scope_details = ResourceManagerDiffEngine._scope_details(partial) + partial_pool = ResourceManagerDiffEngine._normalize_pool_name( + ResourceManagerDiffEngine._resource_attr(partial, "pool_name", "poolName"), + log=log, + ) + desired_pool = ResourceManagerDiffEngine._normalize_pool_name(pool_name, log=log) + partial_scope = ResourceManagerDiffEngine._extract_scope_type(partial_scope_details, log=log) + partial_sw = ResourceManagerDiffEngine._extract_scope_switch_key_val( + partial_scope_details, switch_key="switch_ip", src_switch_key="src_switch_ip", log=log + ) + partial_resource_value = ResourceManagerDiffEngine._resource_attr(partial, "resource_value", "resourceValue") + existing_values = { + "resource_id": partial_resource_id, + "pool_name": partial_pool, + "scope_type": partial_scope, + "switch_ip": partial_sw, + "resource_value": partial_resource_value, + } + mismatch = { + "resource_id": partial_resource_id, + "have_pool_name": partial_pool, + "want_pool_name": desired_pool, + "have_scope_type": partial_scope, + "want_scope_type": scope_type, + "have_switch_ip": partial_sw, + "have_resource_value": partial_resource_value, + "want_resource_value": resource_value, + } + log.debug( + "compute_changes: partial match for entity='%s': existing=%s mismatch=%s", + entity_name, + existing_values, + mismatch, + ) + changes["debugs"].append( + { + "Entity Name": entity_name, + "EXISTING_VALUES": existing_values, + "MISMATCHED_VALUES": mismatch, + } + ) + else: + log.debug( + "Resource (entity=%s, pool=%s, scope=%s, switch=%s) found in existing — resource_id=%s, existing_value='%s'", + entity_name, + pool_name, + scope_type, + sw, + ResourceManagerDiffEngine._resource_attr(existing_res, "resource_id", "resourceId"), + ResourceManagerDiffEngine._resource_attr(existing_res, "resource_value", "resourceValue"), + ) + matched_existing_keys.add(key) + existing_value = ResourceManagerDiffEngine._resource_attr(existing_res, "resource_value", "resourceValue") + + if ResourceManagerDiffEngine._compare_resource_values(existing_value, resource_value, log=log): + log.debug( + "Resource (entity=%s, pool=%s, scope=%s, switch=%s) is idempotent (value=%s)", + entity_name, + pool_name, + scope_type, + sw, + existing_value, + ) + changes["idempotent"].append((cfg, sw, existing_res)) + else: + log.info( + "Resource (entity=%s, pool=%s, scope=%s, switch=%s) value differs (existing=%s, desired=%s) — marking to_update", + entity_name, + pool_name, + scope_type, + sw, + existing_value, + resource_value, + ) + changes["to_update"].append((cfg, sw, existing_res)) + + log.debug( + "Proposed scan complete — matched_existing_keys=%s, total existing_index keys=%s", + len(matched_existing_keys), + len(existing_index), + ) + + # Resources in existing but not matched by any proposed entry → to_delete + for key, res in existing_index.items(): + if key not in matched_existing_keys: + log.info( + "Existing resource (entity=%s, pool=%s) not in proposed — marking to_delete", + ResourceManagerDiffEngine._resource_attr(res, "entity_name", "entityName"), + ResourceManagerDiffEngine._resource_attr(res, "pool_name", "poolName"), + ) + changes["to_delete"].append(res) + else: + log.debug( + "Existing resource (entity=%s, pool=%s, key=%s) was matched by a proposed entry — skipping to_delete", + ResourceManagerDiffEngine._resource_attr(res, "entity_name", "entityName"), + ResourceManagerDiffEngine._resource_attr(res, "pool_name", "poolName"), + key, + ) + + log.info( + "Compute changes summary: to_add=%s, to_update=%s, to_delete=%s, idempotent=%s, debugs=%s", + len(changes["to_add"]), + len(changes["to_update"]), + len(changes["to_delete"]), + len(changes["idempotent"]), + len(changes["debugs"]), + ) + log.debug("EXIT: compute_changes()") + return changes + + @staticmethod + def validate_resource_api_fields( + nd: NDModule, + resource_cfg: ResourceManagerConfigModel, + api_resource: ResourceManagerResponse, + context: str, + log: logging.Logger, + ) -> None: + """Validate user-supplied resource fields against the ND API response. + + Only fields that are non-None in ``resource_cfg`` are validated. + Fields omitted by the user are silently accepted from the API response. + Uses ``ResourceManagerResponse`` model attributes directly for + field access (``entity_name``, ``pool_name``, ``resource_value``, + ``scope_details``). + + Args: + nd: ND module wrapper used for failure handling. + resource_cfg: Validated resource config from the playbook. + api_resource: Matching ``ResourceManagerResponse`` from the ND API. + log: Logger instance. + context: Label used in error messages (e.g. ``"Resource"``). + + Returns: + None. + + Raises: + ValueError: When any provided field does not match the API response. + """ + mismatches: list[str] = [] + + # entity_name: tilde-order-insensitive comparison + if resource_cfg.entity_name is not None: + cfg_norm = ResourceManagerDiffEngine._normalize_entity_key(resource_cfg.entity_name, log=log) + api_norm = ResourceManagerDiffEngine._normalize_entity_key(api_resource.entity_name, log=log) if api_resource.entity_name else None + + log.debug( + "validate_resource_api_fields: checking entity_name — cfg_norm='%s', api_norm='%s'", + cfg_norm, + api_norm, + ) + if cfg_norm != api_norm: + log.debug( + "validate_resource_api_fields: entity_name MISMATCH — provided='%s', API='%s'", + resource_cfg.entity_name, + api_resource.entity_name, + ) + mismatches.append(f"entity_name: provided '{resource_cfg.entity_name}', API reports '{api_resource.entity_name}'") + else: + log.debug( + "validate_resource_api_fields: entity_name OK — '%s' matches API", + resource_cfg.entity_name, + ) + else: + log.debug( + "validate_resource_api_fields: entity_name not provided in cfg — skipping check (api_entity_name='%s')", + api_resource.entity_name, + ) + + # pool_name: exact match + if resource_cfg.pool_name is not None: + cfg_pool_norm = ResourceManagerDiffEngine._normalize_pool_name(resource_cfg.pool_name, log=log) + api_pool_norm = ResourceManagerDiffEngine._normalize_pool_name(api_resource.pool_name, log=log) + log.debug( + "validate_resource_api_fields: checking pool_name — cfg='%s' (norm='%s'), api='%s' (norm='%s')", + resource_cfg.pool_name, + cfg_pool_norm, + api_resource.pool_name, + api_pool_norm, + ) + if cfg_pool_norm != api_pool_norm: + log.debug( + "validate_resource_api_fields: pool_name MISMATCH — provided='%s', API='%s'", + resource_cfg.pool_name, + api_resource.pool_name, + ) + mismatches.append(f"pool_name: provided '{resource_cfg.pool_name}', API reports '{api_resource.pool_name}'") + else: + log.debug( + "validate_resource_api_fields: pool_name OK — '%s' matches API", + resource_cfg.pool_name, + ) + else: + log.debug( + "validate_resource_api_fields: pool_name not provided in cfg — skipping check (api_pool_name='%s')", + api_resource.pool_name, + ) + + # resource vs resource_value: IPv4/v6-aware comparison + if resource_cfg.resource is not None: + log.debug( + "validate_resource_api_fields: checking resource value — cfg='%s', api='%s'", + resource_cfg.resource, + api_resource.resource_value, + ) + if not ResourceManagerDiffEngine._compare_resource_values(api_resource.resource_value, resource_cfg.resource, log=log): + log.debug( + "validate_resource_api_fields: resource value MISMATCH — provided='%s', API='%s'", + resource_cfg.resource, + api_resource.resource_value, + ) + mismatches.append(f"resource: provided '{resource_cfg.resource}', API reports '{api_resource.resource_value}'") + else: + log.debug( + "validate_resource_api_fields: resource value OK — '%s' matches API '%s'", + resource_cfg.resource, + api_resource.resource_value, + ) + else: + log.debug( + "validate_resource_api_fields: resource not provided in cfg — skipping check (api_resource_value='%s')", + api_resource.resource_value, + ) + + if mismatches: + raise ValueError( + f"{context} field mismatch for entity '{resource_cfg.entity_name}'. " + f"The following provided values do not match the API data:\n\n".join(f" - {m}" for m in mismatches) + ) + + log.debug( + "validate_resource_api_fields: all provided fields match API for entity='%s', pool='%s'", + resource_cfg.entity_name, + resource_cfg.pool_name, + ) diff --git a/plugins/module_utils/manage_resource_manager/resource_manager_helpers.py b/plugins/module_utils/manage_resource_manager/resource_manager_helpers.py new file mode 100644 index 000000000..4820e7c2d --- /dev/null +++ b/plugins/module_utils/manage_resource_manager/resource_manager_helpers.py @@ -0,0 +1,557 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +import ipaddress + +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_config_model import ( + ResourceManagerConfigModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_request_model import ( + ResourceManagerRequest, + FabricScope, + DeviceScope, + DeviceInterfaceScope, + DevicePairScope, + LinkScope, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_resource_manager.resource_manager_diff import ResourceManagerDiffEngine + + +class ResourceManagerResourceHelpersMixin: + """Shared resource access, payload, translation, and gathered-filter helpers.""" + + # ------------------------------------------------------------------ + # Resource attribute accessors (handle both ResourceManagerResponse and raw dict) + # ------------------------------------------------------------------ + + def _attr(self, resource, model_attr, dict_key): + """Return a field value from a resource that may be a model instance or a raw dict. + + Delegates to ``ResourceManagerDiffEngine._resource_attr`` so diffing, + gathered output, and filtering share the same model/dict access behavior. + + Args: + resource: A ``ResourceManagerResponse`` model instance or a plain dict. + model_attr: Attribute name to access on a model instance (snake_case). + dict_key: Key to access on a raw dict (camelCase, e.g. ``'entityName'``). + + Returns: + The field value, or None if neither path resolves. + """ + value = ResourceManagerDiffEngine._resource_attr(resource, model_attr, dict_key) + self.log.debug("_attr: resolved '%s'/'%s' from %s: %s", model_attr, dict_key, type(resource).__name__, value) + return value + + def _get_entity_name(self, resource): + """Return the entity_name field from a resource model or raw dict.""" + return self._attr(resource, "entity_name", "entityName") + + def _get_pool_name(self, resource): + """Return the pool_name field from a resource model or raw dict.""" + return self._attr(resource, "pool_name", "poolName") + + def _get_resource_id(self, resource): + """Return the resource_id field from a resource model or raw dict.""" + return self._attr(resource, "resource_id", "resourceId") + + def _get_resource_value(self, resource): + """Return the resource_value field from a resource model or raw dict.""" + return self._attr(resource, "resource_value", "resourceValue") + + def _get_scope_type(self, resource): + """Return the playbook-style scope_type string for a resource. + + Reads the raw ND API ``scopeType`` value from either the model's + ``scope_details.scope_type`` attribute or the ``scopeDetails.scopeType`` key of a + raw dict, then maps it from the API camelCase format (e.g. ``'deviceInterface'``) + to the playbook format (e.g. ``'device_interface'``) using + ``API_SCOPE_TYPE_TO_PLAYBOOK``. + + Args: + resource: A ``ResourceManagerResponse`` model instance or a plain dict. + + Returns: + Playbook-style scope_type string, or None if the resource type is unrecognised. + """ + scope_details = ResourceManagerDiffEngine._scope_details(resource) + if scope_details is None: + self.log.debug("_get_scope_type: unrecognised resource type %s, returning None", type(resource)) + return None + mapped = ResourceManagerDiffEngine._extract_scope_type(scope_details, log=self.log) + self.log.debug("_get_scope_type: mapped scope to playbook scope '%s'", mapped) + return mapped + + def _get_switch_ip(self, resource): + """Return the primary switch IP/ID from scopeDetails (src switch for device_pair/link). + + Delegates to ResourceManagerDiffEngine._extract_scope_switch_key_val for model + instances so that all scope types are handled uniformly: + - fabric → None + - device / device_interface → switch_ip + - device_pair / link → src_switch_ip + """ + scope_details = ResourceManagerDiffEngine._scope_details(resource) + if scope_details is not None: + value = ResourceManagerDiffEngine._extract_scope_switch_key_val( + scope_details, switch_key="switch_ip", src_switch_key="src_switch_ip", log=self.log + ) + self.log.debug("_get_switch_ip: from scope_details, switch_ip=%s", value) + return value + self.log.debug("_get_switch_ip: unrecognised resource type %s, returning None", type(resource)) + return None + + # ------------------------------------------------------------------ + # Matching helpers + # ------------------------------------------------------------------ + + def _entity_names_match(self, e1, e2): + """Compare two entity names in a tilde-order-insensitive way. + + Splits each name on ``'~'``, sorts the resulting parts alphabetically, and + compares the sorted lists. This ensures that a device_pair entity such as + ``'SER1~SER2~label'`` matches ``'SER2~SER1~label'`` regardless of the order + in which the serial numbers appear in the playbook vs the ND API response. + + Args: + e1: First entity name string. + e2: Second entity name string. + + Returns: + True if both names are non-None and their sorted tilde-parts are equal, + False otherwise. + """ + if e1 is None or e2 is None: + self.log.debug( + "_entity_names_match: one or both entity names are None (e1=%s, e2=%s), returning False", + e1, + e2, + ) + return False + result = sorted(e1.split("~")) == sorted(e2.split("~")) + self.log.debug( + "_entity_names_match: e1='%s', e2='%s', sorted_e1=%s, sorted_e2=%s, match=%s", + e1, + e2, + sorted(e1.split("~")), + sorted(e2.split("~")), + result, + ) + return result + + # ------------------------------------------------------------------ + # API payload builders + # ------------------------------------------------------------------ + + def _build_fabric_scope(self, _switch_id=None, _entity_name=None): + """Build fabric-level scope details.""" + return FabricScope(fabric_name=self.fabric) + + def _build_device_scope(self, switch_id=None, _entity_name=None): + """Build device-level scope details.""" + return DeviceScope(switch_id=switch_id) + + def _build_device_interface_scope(self, switch_id=None, entity_name=None): + """Build device-interface scope details from ``entity_name``.""" + parts = (entity_name or "").split("~", 1) + interface_name = parts[1] if len(parts) > 1 else None + if not interface_name: + self.log.warning( + "_build_device_interface_scope: could not parse interfaceName from entity_name='%s'", + entity_name, + ) + return DeviceInterfaceScope(switch_id=switch_id, interface_name=interface_name) + + def _build_device_pair_scope(self, _switch_id=None, entity_name=None): + """Build device-pair scope details from ``entity_name``.""" + parts = (entity_name or "").split("~") + src_switch_id = parts[0] if len(parts) > 0 else None + dst_switch_id = parts[1] if len(parts) > 1 else None + return DevicePairScope(src_switch_id=src_switch_id, dst_switch_id=dst_switch_id) + + def _build_link_scope(self, _switch_id=None, entity_name=None): + """Build link scope details from ``entity_name``.""" + parts = (entity_name or "").split("~") + src_switch_id = parts[0] if len(parts) > 0 else None + src_interface_name = parts[1] if len(parts) > 1 else None + dst_switch_id = parts[2] if len(parts) > 2 else None + dst_interface_name = parts[3] if len(parts) > 3 else None + return LinkScope( + src_switch_id=src_switch_id, + src_interface_name=src_interface_name, + dst_switch_id=dst_switch_id, + dst_interface_name=dst_interface_name, + ) + + def _build_scope_details(self, scope_type, switch_ip=None, entity_name=None): + """Build the scopeDetails model for the ND Manage API. + + ``switch_ip`` is the translated switchId (serial number) of the source switch + from the playbook ``switch`` list. The entity_name encodes the full topology + (src and dst) as tilde-separated fields for multi-switch scopes. + + - fabric: FabricScope(fabricName) + - device: DeviceScope(switchId) + - device_interface: DeviceInterfaceScope(switchId, interfaceName) + - device_pair: DevicePairScope(srcSwitchId, dstSwitchId) + - link: LinkScope(src/dst switch and interface details) + """ + self.log.debug( + "_build_scope_details: scope_type=%s, switch_ip=%s, entity_name=%s, fabric=%s", + scope_type, + switch_ip, + entity_name, + self.fabric, + ) + + scope_builders = { + "fabric": self._build_fabric_scope, + "device": self._build_device_scope, + "device_interface": self._build_device_interface_scope, + "device_pair": self._build_device_pair_scope, + "link": self._build_link_scope, + } + builder = scope_builders.get(scope_type) + if builder is None: + raise ValueError("Unsupported scope_type '{0}' while building scope details".format(scope_type)) + + result = builder(switch_ip, entity_name) + self.log.debug("_build_scope_details: result=%s", result) + return result + + def _build_create_payload(self, cfg, switch_ip=None): + """Build the POST body for a single resource creation request. + + Accepts either a typed ``ResourceManagerConfigModel`` instance or a legacy dict + (backward-compatible path). Delegates scope construction to + ``_build_scope_details`` and serialises the complete request via + ``ResourceManagerRequest.to_payload()``. + + Args: + cfg: A ``ResourceManagerConfigModel`` instance or a dict with keys + ``scope_type``, ``entity_name``, ``pool_name``, ``pool_type``, + and optionally ``resource``. + switch_ip: The resolved switchId (serial number) for the primary switch, + or None for fabric-scoped resources. + + Returns: + A plain dict payload ready to be sent to the ND Manage API POST endpoint. + """ + if isinstance(cfg, ResourceManagerConfigModel): + scope_type = cfg.scope_type + entity_name = cfg.entity_name + pool_name = cfg.pool_name + pool_type = cfg.pool_type + resource_value = cfg.resource + else: + # Legacy dict path (kept for backward-compat with any callers not yet refactored) + scope_type = cfg["scope_type"] + entity_name = cfg["entity_name"] + pool_name = cfg["pool_name"] + pool_type = cfg.get("pool_type") + resource_value = cfg.get("resource") + + self.log.debug( + "_build_create_payload: pool_name=%s, pool_type=%s, entity_name=%s, scope_type=%s, switch_ip=%s, resource=%s", + pool_name, + pool_type, + entity_name, + scope_type, + switch_ip, + resource_value, + ) + + scope = self._build_scope_details(scope_type, switch_ip, entity_name=entity_name) + + request = ResourceManagerRequest( + pool_name=pool_name, + pool_type=pool_type, + entity_name=entity_name, + scope_details=scope, + is_pre_allocated=True, + resource_value=str(resource_value) if resource_value is not None else None, + ) + + if resource_value is not None: + self.log.debug( + "_build_create_payload: adding resourceValue='%s' to payload", + resource_value, + ) + else: + self.log.debug("_build_create_payload: no resource value provided, omitting resourceValue field") + + payload = request.to_payload() + self.log.debug("_build_create_payload: final payload=%s", payload) + return payload + + # ------------------------------------------------------------------ + # Gathered results translation + # ------------------------------------------------------------------ + + def _determine_pool_type(self, resource_value): + """Infer the pool_type from a resource value string. + + Attempts to parse the value as an IP network (returns ``'SUBNET'``), then as an + IP address (returns ``'IP'``), and falls back to ``'ID'`` for plain integer or + string identifiers. Used when translating raw API responses back into the playbook + config format during gathered-state output. + + Args: + resource_value: The raw resource value string from the ND API response, + e.g. ``'101'``, ``'10.1.1.1'``, or ``'10.1.1.0/24'``. May be None. + + Returns: + One of ``'ID'``, ``'IP'``, or ``'SUBNET'``. + """ + self.log.debug( + "_determine_pool_type: evaluating resource_value='%s'", + resource_value, + ) + if not resource_value: + self.log.debug("_determine_pool_type: resource_value is None/empty — returning 'ID'") + return "ID" + val = str(resource_value).strip() + if "/" in val: + self.log.debug( + "_determine_pool_type: value='%s' contains '/' — attempting ip_network parse", + val, + ) + try: + ipaddress.ip_network(val, strict=False) + self.log.debug( + "_determine_pool_type: '%s' is a valid IP network — returning 'SUBNET'", + val, + ) + return "SUBNET" + except ValueError: + self.log.debug( + "_determine_pool_type: '%s' failed ip_network parse — falling through to ip_address check", + val, + ) + else: + self.log.debug( + "_determine_pool_type: value='%s' has no '/' — skipping ip_network check", + val, + ) + try: + ipaddress.ip_address(val) + self.log.debug( + "_determine_pool_type: '%s' is a valid IP address — returning 'IP'", + val, + ) + return "IP" + except ValueError: + self.log.debug( + "_determine_pool_type: '%s' is not an IP address — returning 'ID'", + val, + ) + return "ID" + + def translate_gathered_results(self, resources): + """Translate raw API resource items to the merged-state config format. + + Converts each resource from the ND API response shape + (camelCase keys, nested scopeDetails) into the playbook ``config`` + format used by ``state: merged``: + entity_name, pool_type, pool_name, scope_type, resource[, switches]. + """ + translated = [] + self.log.debug( + "translate_gathered_results: translating %s resource(s) to playbook config format", + len(resources), + ) + for res_idx, res in enumerate(resources): + entity_name = self._get_entity_name(res) + pool_name = self._get_pool_name(res) + resource_value = self._get_resource_value(res) + scope_type = self._get_scope_type(res) + switch_ip = self._get_switch_ip(res) + pool_type = self._determine_pool_type(resource_value) + self.log.debug( + "translate_gathered_results: [%s] resolved fields — " + "entity_name='%s', pool_name='%s', scope_type='%s', " + "pool_type='%s', resource_value='%s', switch_ip='%s'", + res_idx, + entity_name, + pool_name, + scope_type, + pool_type, + resource_value, + switch_ip, + ) + + item = { + "entity_name": entity_name, + "pool_type": pool_type, + "pool_name": pool_name, + "scope_type": scope_type, + "resource": resource_value, + } + if scope_type != "fabric" and switch_ip: + item["switches"] = [switch_ip] + self.log.debug( + "translate_gathered_results: [%s] entity='%s' — non-fabric scope ('%s'), adding switches=['%s'] to item", + res_idx, + entity_name, + scope_type, + switch_ip, + ) + else: + self.log.debug( + "translate_gathered_results: [%s] entity='%s' — scope_type='%s', switch_ip='%s' — no switch field added", + res_idx, + entity_name, + scope_type, + switch_ip, + ) + + translated.append(item) + self.log.debug( + "translate_gathered_results: [%s] appended item=%s", + res_idx, + item, + ) + self.log.debug( + "translate_gathered_results: completed — %s item(s) translated (before switch merge)", + len(translated), + ) + + # Merge entries that share the same (entity_name, pool_name, pool_type, + # scope_type, resource) key — only their switch IPs differ. Fabric-scoped + # resources (no 'switches' key) are passed through unchanged. + merged: dict = {} + for item in translated: + key = ( + item.get("entity_name"), + item.get("pool_name"), + item.get("pool_type"), + item.get("scope_type"), + item.get("resource"), + ) + if key in merged: + # Accumulate switch IPs for matching entries (deduplicate, preserve order) + sw_list = item.get("switches") or [] + for sw in sw_list: + if sw not in merged[key].get("switches", []): + merged[key]["switches"].append(sw) + self.log.debug( + "translate_gathered_results: merged switch ip='%s' into existing entry for key=%s", + sw, + key, + ) + else: + merged[key] = item + + translated = list(merged.values()) + self.log.debug( + "translate_gathered_results: after switch merge — %s item(s) returned", + len(translated), + ) + return translated + + def _get_switch_id(self, resource): + """Return the primary switch ID from scopeDetails for gathered filtering.""" + scope_details = ResourceManagerDiffEngine._scope_details(resource) + if scope_details is None: + return None + return ResourceManagerDiffEngine._extract_scope_switch_key_val( + scope_details, + switch_key="switch_id", + src_switch_key="src_switch_id", + log=self.log, + ) + + @staticmethod + def _filter_has_active_criteria(filter_item): + """Return True when a gathered filter item has at least one criterion.""" + return bool(filter_item.get("entity_name") or filter_item.get("pool_name") or filter_item.get("switches")) + + def _resource_matches_filter(self, resource, filter_item): + """Return True when a resource matches one gathered filter item.""" + resource_id = self._get_resource_id(resource) + resource_entity = self._get_entity_name(resource) + resource_pool = self._get_pool_name(resource) + resource_switch_id = self._get_switch_id(resource) + + filter_entity = filter_item.get("entity_name") + filter_pool = filter_item.get("pool_name") + filter_switches = filter_item.get("switches") or [] + + if filter_entity and not self._entity_names_match(resource_entity, filter_entity): + self.log.debug( + "manage_gathered: skipping resource id='%s', entity_name mismatch: resource='%s' vs filter='%s'", + resource_id, + resource_entity, + filter_entity, + ) + return False + + if filter_pool and resource_pool != filter_pool: + self.log.debug( + "manage_gathered: skipping resource id='%s', pool_name mismatch: resource='%s' vs filter='%s'", + resource_id, + resource_pool, + filter_pool, + ) + return False + + if filter_switches and resource_switch_id not in filter_switches: + self.log.debug( + "manage_gathered: skipping resource id='%s', switchId not in filter: resource_switch='%s', filter_switches=%s", + resource_id, + resource_switch_id, + filter_switches, + ) + return False + + self.log.debug( + "manage_gathered: resource id='%s' matched filter (entity_name='%s', pool_name='%s', switch_id='%s')", + resource_id, + resource_entity, + resource_pool, + resource_switch_id, + ) + return True + + def _apply_gathered_filters(self): + """Apply gathered config filters to cached resources and return translated results.""" + seen_ids = set() + results = [] + + for filter_item in self.config: + if not self._filter_has_active_criteria(filter_item): + self.log.debug( + "manage_gathered: skipping empty filter item (entity_name=%s, pool_name=%s, switches=%s)", + filter_item.get("entity_name"), + filter_item.get("pool_name"), + filter_item.get("switches") or [], + ) + continue + + self.log.debug( + "manage_gathered: applying filter: entity_name=%s, pool_name=%s, switches=%s", + filter_item.get("entity_name"), + filter_item.get("pool_name"), + filter_item.get("switches") or [], + ) + + for resource in self._all_resources: + resource_id = self._get_resource_id(resource) + if resource_id is not None and resource_id in seen_ids: + self.log.debug( + "manage_gathered: skipping resource id='%s' already included by an earlier filter", + resource_id, + ) + continue + + if not self._resource_matches_filter(resource, filter_item): + continue + + translated = self.translate_gathered_results([resource]) + if translated: + results.append(translated[0]) + if resource_id is not None: + seen_ids.add(resource_id) + + return results diff --git a/plugins/module_utils/models/manage_resource_manager/__init__.py b/plugins/module_utils/models/manage_resource_manager/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/models/manage_resource_manager/constants.py b/plugins/module_utils/models/manage_resource_manager/constants.py new file mode 100644 index 000000000..25221e01e --- /dev/null +++ b/plugins/module_utils/models/manage_resource_manager/constants.py @@ -0,0 +1,136 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Shared constants and Ansible/DCNM-style enums for Resource Management models. + +Imported by all nd_manage_resource_manager_updated_models_*.py files. +""" + +from __future__ import annotations + +from enum import Enum + +# ============================================================================= +# POOL_SCOPE_MAP - Derived from dcnm_rm_check_resource_params() +# Maps known pool names to the scope types they are valid for. +# The resource-manager config model uses this map to keep ID pool validation strict. +# ============================================================================= + +POOL_SCOPE_MAP: dict[str, list[str]] = { + "L3_VNI": ["fabric"], + "L2_VNI": ["fabric"], + "BGP_ASN_ID": ["fabric"], + "VPC_DOMAIN_ID": ["fabric"], + "VPC_ID": ["device_pair"], + "VPC_PEER_LINK_VLAN": ["device_pair"], + "FEX_ID": ["device"], + "LOOPBACK_ID": ["device"], + "PORT_CHANNEL_ID": ["device"], + "TUNNEL_ID_IOS_XE": ["device"], + "OBJECT_TRACKING_NUMBER_POOL": ["device"], + "INSTANCE_ID": ["device"], + "PORT_CHANNEL_ID_IOS_XE": ["device"], + "ROUTE_MAP_SEQUENCE_NUMBER_POOL": ["device"], + "SERVICE_NETWORK_VLAN": ["device"], + "TOP_DOWN_VRF_VLAN": ["device"], + "TOP_DOWN_NETWORK_VLAN": ["device"], + "TOP_DOWN_L3_DOT1Q": ["device_interface"], + "IP_POOL": ["fabric", "device_interface"], + "SUBNET": ["link"], +} + +# ============================================================================= +# SCOPE_TYPE_TO_API / API_SCOPE_TYPE_TO_PLAYBOOK +# Maps between playbook scope_type values (underscore) and ND API scopeType +# values (camelCase). +# ============================================================================= + +SCOPE_TYPE_TO_API: dict[str, str] = { + "fabric": "fabric", + "device": "device", + "device_interface": "deviceInterface", + "device_pair": "devicePair", + "link": "link", +} + +API_SCOPE_TYPE_TO_PLAYBOOK: dict[str, str] = {v: k for k, v in SCOPE_TYPE_TO_API.items()} + +# ============================================================================= +# ENUMS - Ansible/DCNM-style values +# ============================================================================= + + +class PoolType(str, Enum): + """ + Pool type enumeration using Ansible/DCNM-style values. + + User-facing values (as used in dcnm_resource_manager.py DOCUMENTATION): + ID → integer ID pool (ND API: idPool) + IP → IP address pool (ND API: ipPool) + SUBNET → subnet/CIDR pool (ND API: subnetPool) + """ + + ID = "ID" + IP = "IP" + SUBNET = "SUBNET" + + @classmethod + def choices(cls) -> list[str]: + """Return list of valid choices.""" + return [e.value for e in cls] + + +class ScopeType(str, Enum): + """ + Scope type enumeration using values (underscores). + + User-facing values (as used in dcnm_resource_manager.py DOCUMENTATION): + fabric (ND API: fabric) + device (ND API: device) + device_interface (ND API: deviceInterface) + device_pair (ND API: devicePair) + link (ND API: link) + """ + + FABRIC = "fabric" + DEVICE = "device" + DEVICE_INTERFACE = "device_interface" + DEVICE_PAIR = "device_pair" + LINK = "link" + + @classmethod + def choices(cls) -> list[str]: + """Return list of valid choices.""" + return [e.value for e in cls] + + +class VlanType(str, Enum): + """ + VLAN type enumeration for the proposeVlan and unusedVlans endpoints. + + Valid values: + networkVlan - Network VLAN + vrfVlan - VRF VLAN + serviceNetworkVlan - Service network VLAN + vpcPeerLinkVlan - VPC peer-link VLAN + """ + + NETWORK_VLAN = "networkVlan" + VRF_VLAN = "vrfVlan" + SERVICE_NETWORK_VLAN = "serviceNetworkVlan" + VPC_PEER_LINK_VLAN = "vpcPeerLinkVlan" + + @classmethod + def choices(cls): + """Return list of valid string values.""" + return [e.value for e in cls] + + +__all__ = [ + "POOL_SCOPE_MAP", + "PoolType", + "ScopeType", + "VlanType", +] diff --git a/plugins/module_utils/models/manage_resource_manager/remove_resource_by_id_request_model.py b/plugins/module_utils/models/manage_resource_manager/remove_resource_by_id_request_model.py new file mode 100644 index 000000000..dfca79a32 --- /dev/null +++ b/plugins/module_utils/models/manage_resource_manager/remove_resource_by_id_request_model.py @@ -0,0 +1,38 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +RemoveResourcesByIdsRequest - Request model for remove-by-IDs action. + +Standalone model (no composite model fields). Contains only list[int]. + +Endpoint: POST /fabrics/{fabricName}/resources/actions/remove +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + + +class RemoveResourcesByIdsRequest(NDBaseModel): + """ + Request body for POST /fabrics/{fabricName}/resources/actions/remove. + + At least one resource ID must be provided. + """ + + identifiers: ClassVar[list[str]] = [] + + resource_ids: list[int] = Field( + alias="resourceIds", + min_length=1, + description="Array of resource IDs to remove. Must contain at least one ID.", + ) + + def to_payload(self) -> dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) diff --git a/plugins/module_utils/models/manage_resource_manager/remove_resource_by_id_response_model.py b/plugins/module_utils/models/manage_resource_manager/remove_resource_by_id_response_model.py new file mode 100644 index 000000000..fa578172b --- /dev/null +++ b/plugins/module_utils/models/manage_resource_manager/remove_resource_by_id_response_model.py @@ -0,0 +1,66 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +RemoveByIdResponse - Individual item in remove-by-IDs response. + +Standalone model (no composite model fields). All fields are primitives. + +Endpoint: POST /fabrics/{fabricName}/resources/actions/remove (response item) +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + + +class RemoveResourcesByIdResponse(NDBaseModel): + """ + Individual resource removal response item for POST .../actions/remove. + """ + + identifiers: ClassVar[list[str]] = [] + + resource_value: str | None = Field( + default=None, + alias="resourceValue", + description="Unique value of the removed resource", + ) + status: str | None = Field( + default=None, + description="Status of the resource delete request", + ) + message: str | None = Field( + default=None, + description="Additional details describing a resource delete failure", + ) + + +class RemoveResourcesByIdsResponse(NDBaseModel): + """ + Response body for POST - /api/v1/manage/fabrics/{fabricName}/resources/actions/remove + + Composite: contains list[RemoveResourcesByIdResponse]. + """ + + identifiers: ClassVar[list[str]] = [] + + resources: list[RemoveResourcesByIdResponse] = Field(default_factory=list, description="list of resource data") + + @classmethod + def from_response(cls, response: Any) -> "RemoveResourcesByIdsResponse": + """Create instance from a raw API response dict. + + Accepts the raw dict returned by nd.request() for the batch POST + endpoint. If the response already has a ``resources`` key it is + validated directly; a bare list is wrapped automatically. + """ + if isinstance(response, list): + return cls.model_validate({"resources": response}) + if isinstance(response, dict): + return cls.model_validate(response) + return cls(resources=[]) diff --git a/plugins/module_utils/models/manage_resource_manager/resource_manager_config_model.py b/plugins/module_utils/models/manage_resource_manager/resource_manager_config_model.py new file mode 100644 index 000000000..a7585f0de --- /dev/null +++ b/plugins/module_utils/models/manage_resource_manager/resource_manager_config_model.py @@ -0,0 +1,416 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +ResourceManagerConfigModel - Ansible playbook input validation model. + +Validates a single config entry for the nd_manage_resource_manager module. + +Fields map directly to the module's config suboptions: + entity_name → entityName (unique name identifying the resource allocation) + pool_type → poolType (ID | IP | SUBNET) + pool_name → poolName (name of the resource pool) + scope_type → scopeType (fabric | device | device_interface | device_pair | link) + resource → resourceValue (value to allocate; integer/IP/CIDR based on pool_type) + is_pre_allocated → isPreAllocated (True to reserve a specific value; False for auto-assignment) + vrf_name → vrfName (VRF name; use 'default' for the global VRF) + switch → switch (list of switch IPs/serials; required for non-fabric scopes) + +State-aware validation is supported when model_validate() is called with +context={"state": "merged|deleted|query|gathered"}. +""" + +from __future__ import annotations + +import re +from ipaddress import ip_address, ip_network +from typing import Any, ClassVar + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.constants import ( + POOL_SCOPE_MAP, + PoolType, + ScopeType, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_validator, + model_validator, +) + + +class ResourceManagerConfigModel(NDBaseModel): + """ + Input validation model for a single nd_manage_resource_manager config entry. + + Provides full per-field and cross-field validation for resource allocation + configuration. Supports state-aware validation when model_validate() is + called with context={"state": "merged|deleted|query|gathered"}. + + Field requirements by state: + merged: entity_name, pool_type, pool_name, scope_type required; + switch required for non-fabric scopes. + deleted: entity_name, pool_type, pool_name, scope_type required; + switch required for non-fabric scopes. + query/gathered: all fields optional (used as filters). + + Note: The nd_manage_resource_manager module performs its own mandatory field + checks before calling from_config(). This model adds per-field normalization + and cross-field validation on top of those checks. + """ + + identifiers: ClassVar[list[str]] = [] + + # Fields excluded from diff — operational input flags not present in gathered state. + # entity_name, pool_type, pool_name, scope_type, resource and vrf_name + # are all meaningful for comparison; is_pre_allocated is a request-time allocation + # control flag (analogous to preserve_config for switches) and is omitted from diffs. + exclude_from_diff: ClassVar[list[str]] = ["is_pre_allocated", "switches"] + + entity_name: str | None = Field( + default=None, + description=( + "Unique name identifying the entity to which the resource is allocated. " + "Format depends on scope_type: " + "fabric/device → free-form string; " + "device_pair → 1 or 2 tildes (~), e.g. 'SER1~SER2' or 'SER1~SER2~label'; " + "device_interface → exactly 1 tilde (~), e.g. 'SER~Ethernet1/13'; " + "link → exactly 3 tildes (~), e.g. 'SER1~Eth1/3~SER2~Eth1/3'." + ), + ) + pool_type: PoolType | None = Field( + default=None, + description=( + "Type of resource pool. One of: ID (integer ID), IP (IP address), SUBNET (CIDR block). Determines the expected format for the 'resource' field." + ), + ) + pool_name: str | None = Field( + default=None, + description=( + "Name of the resource pool to use (e.g. 'L3_VNI', 'LOOPBACK_ID', 'SUBNET'). " + "For known pool names the scope_type must match the allowed scopes in POOL_SCOPE_MAP. " + "ID pool names must be present in POOL_SCOPE_MAP for merged/deleted states." + ), + ) + scope_type: ScopeType | None = Field( + default=None, + description=("Scope level for the resource allocation. One of: fabric, device, device_interface, device_pair, link."), + ) + resource: str | None = Field( + default=None, + description=( + "Value of the resource being allocated. " + "Integer string for ID pools (e.g. '101'); " + "IPv4 or IPv6 address for IP pools (e.g. '110.1.1.1' or 'fe80::1'); " + "CIDR notation for SUBNET pools (e.g. '10.1.1.0/24'). " + "Required when is_pre_allocated is True." + ), + ) + is_pre_allocated: bool | None = Field( + default=None, + description=( + "Whether the resource value is explicitly pre-allocated. " + "Set to True to reserve a specific resource value; " + "False to let the system auto-assign. " + "When True, the 'resource' field must also be provided." + ), + ) + # TODO(Jeet): We need to import this fields from common shared_fields file. + vrf_name: str | None = Field( + default=None, + description=("VRF name associated with the resource allocation. Use 'default' for the global default VRF. When omitted, the default VRF is assumed."), + ) + switches: list[str] | None = Field( + default=None, + description=("list of switch management IP addresses or serial numbers to which the resource is assigned. Required when scope_type is not 'fabric'."), + ) + + # ------------------------------------------------------------------------- + # Per-field validators + # ------------------------------------------------------------------------- + + @field_validator("entity_name", mode="before") + @classmethod + def validate_entity_name(cls, v: Any) -> str | None: + """Validate entity_name is a non-empty string when provided.""" + if v is None: + return None + if not isinstance(v, str) or not str(v).strip(): + raise ValueError("entity_name must be a non-empty string") + return str(v).strip() + + @field_validator("pool_type", mode="before") + @classmethod + def normalize_pool_type(cls, v: Any) -> str | None: + """Normalize pool_type to uppercase and validate against PoolType enum.""" + if v is None: + return None + if isinstance(v, str): + normalized = v.strip().upper() + valid_values = [pt.value for pt in PoolType] + if normalized not in valid_values: + raise ValueError("pool_type '{0}' is invalid. Valid choices: {1}".format(v, valid_values)) + return normalized + return v + + @field_validator("pool_name", mode="before") + @classmethod + def validate_pool_name(cls, v: Any) -> str | None: + """Validate pool_name is a non-empty string when provided.""" + if v is None: + return None + if not isinstance(v, str) or not str(v).strip(): + raise ValueError("pool_name must be a non-empty string") + return str(v).strip() + + @field_validator("scope_type", mode="before") + @classmethod + def normalize_scope_type(cls, v: Any) -> str | None: + """Normalize scope_type to lowercase and validate against ScopeType enum.""" + if v is None: + return None + if isinstance(v, str): + normalized = v.strip().lower() + valid_values = [st.value for st in ScopeType] + if normalized not in valid_values: + raise ValueError("scope_type '{0}' is invalid. Valid choices: {1}".format(v, valid_values)) + return normalized + return v + + @field_validator("resource", mode="before") + @classmethod + def validate_resource_not_empty(cls, v: Any) -> str | None: + """Validate resource is a non-empty string when provided.""" + if v is None: + return None + if not isinstance(v, str) or not str(v).strip(): + raise ValueError("resource must be a non-empty string when provided") + return str(v).strip() + + @field_validator("is_pre_allocated", mode="before") + @classmethod + def coerce_is_pre_allocated(cls, v: Any) -> bool | None: + """Coerce string and integer representations to bool when provided.""" + if v is None: + return None + if isinstance(v, bool): + return v + if isinstance(v, str): + lower = v.strip().lower() + if lower in ("true", "yes", "1"): + return True + if lower in ("false", "no", "0"): + return False + if isinstance(v, int) and v in (0, 1): + return bool(v) + raise ValueError("is_pre_allocated must be a boolean (true/false), got: {0!r}".format(v)) + + @field_validator("vrf_name", mode="before") + @classmethod + def validate_vrf_name(cls, v: Any) -> str | None: + """Validate vrf_name is a non-empty string when provided.""" + if v is None: + return None + if not isinstance(v, str) or not str(v).strip(): + raise ValueError("vrf_name must be a non-empty string when provided") + return str(v).strip() + + @field_validator("switches", mode="before") + @classmethod + def validate_switch_entries(cls, v: Any) -> list[str] | None: + """Validate each switches entry is a non-empty string. + + Accepts IPv4, IPv6, or serial number strings. Format validation + (IP-to-serial resolution) is deferred to the module at runtime. + """ + if v is None: + return None + if not isinstance(v, list): + raise ValueError("switches must be a list of IP addresses or serial numbers") + if len(v) == 0: + raise ValueError("switches list must not be empty when provided") + validated = [] + for entry in v: + entry_str = str(entry).strip() + if not entry_str: + raise ValueError("switches list entries must be non-empty strings") + validated.append(entry_str) + return validated + + # ------------------------------------------------------------------------- + # Cross-field validators + # ------------------------------------------------------------------------- + + @model_validator(mode="after") + def validate_resource_format(self) -> "ResourceManagerConfigModel": + """Validate the resource value format matches the pool_type. + + - pool_type=ID: resource must be a non-negative integer string (e.g. '101') + - pool_type=IP: resource must be a valid IPv4 or IPv6 address + - pool_type=SUBNET: resource must be CIDR notation (e.g. '10.1.1.0/24') + """ + if self.resource is None or self.pool_type is None: + return self + resource = self.resource + pool_type = self.pool_type + if pool_type == PoolType.ID: + if not re.match(r"^\d+$", resource): + raise ValueError("resource must be an integer string when pool_type is 'ID', got: '{0}'".format(resource)) + elif pool_type == PoolType.IP: + try: + ip_address(resource) + except ValueError: + raise ValueError("resource must be a valid IPv4 or IPv6 address when pool_type is 'IP', got: '{0}'".format(resource)) + elif pool_type == PoolType.SUBNET: + if "/" not in resource: + raise ValueError("resource must be CIDR notation (IP/prefix) when pool_type is 'SUBNET', got: '{0}'".format(resource)) + try: + ip_network(resource, strict=False) + except ValueError: + raise ValueError("resource '{0}' is not a valid CIDR network".format(resource)) + return self + + @model_validator(mode="after") + def validate_entity_name_format(self) -> "ResourceManagerConfigModel": + """Validate entity_name tilde (~) count matches the required scope_type format. + + Tilde conventions: + device_pair: 1 or 2 tildes e.g. 'SER1~SER2' or 'SER1~SER2~label' + device_interface: exactly 1 tilde e.g. 'SER~Ethernet1/13' + link: exactly 3 tildes e.g. 'SER1~Eth1/3~SER2~Eth1/3' + fabric/device: no tilde constraint + """ + if self.entity_name is None or self.scope_type is None: + return self + entity_name = self.entity_name + scope_type = self.scope_type + tilde_count = entity_name.count("~") + if scope_type == ScopeType.DEVICE_PAIR: + if tilde_count not in (1, 2): + raise ValueError( + "entity_name for scope_type 'device_pair' must contain 1 or 2 tildes (~), " + "e.g. 'SER1~SER2' or 'SER1~SER2~label', got: '{0}' ({1} tilde(s))".format(entity_name, tilde_count) + ) + elif scope_type == ScopeType.DEVICE_INTERFACE: + if tilde_count != 1: + raise ValueError( + "entity_name for scope_type 'device_interface' must contain exactly 1 tilde (~), " + "e.g. 'SER~Ethernet1/13', got: '{0}' ({1} tilde(s))".format(entity_name, tilde_count) + ) + elif scope_type == ScopeType.LINK: + if tilde_count != 3: + raise ValueError( + "entity_name for scope_type 'link' must contain exactly 3 tildes (~), " + "e.g. 'SER1~Eth1/3~SER2~Eth1/3', got: '{0}' ({1} tilde(s))".format(entity_name, tilde_count) + ) + return self + + @model_validator(mode="after") + def validate_pool_name_scope_combination(self, info: Any) -> "ResourceManagerConfigModel": + """Validate pool_name and scope_type are a known-valid combination via POOL_SCOPE_MAP. + + For IP pool types, 'IP_POOL' is used as the POOL_SCOPE_MAP lookup key. + For SUBNET pool types, 'SUBNET' is used as the POOL_SCOPE_MAP lookup key. + For ID pool types, the pool_name itself is used as the lookup key. + Known ID pool names must be present in POOL_SCOPE_MAP; this preserves + the module's historical strict validation for ID pools. + """ + state = (info.context or {}).get("state") if info else None + if self.pool_name is None or self.scope_type is None or self.pool_type is None: + return self + pool_name = self.pool_name + scope_type = self.scope_type + pool_type = self.pool_type + # Determine the POOL_SCOPE_MAP lookup key based on pool_type + if pool_type == PoolType.IP: + check_key = "IP_POOL" + elif pool_type == PoolType.SUBNET: + check_key = "SUBNET" + else: + check_key = pool_name + allowed_scopes = POOL_SCOPE_MAP.get(check_key) + if allowed_scopes is None and pool_type == PoolType.ID and state in ("merged", "deleted"): + raise ValueError("pool_name '{0}' is not valid for pool_type 'ID'".format(pool_name)) + if allowed_scopes is not None and scope_type not in allowed_scopes: + raise ValueError("scope_type '{0}' is not valid for pool_name '{1}'. Allowed scope_types: {2}".format(scope_type, pool_name, allowed_scopes)) + return self + + @model_validator(mode="after") + def validate_pre_allocated_requires_resource(self) -> "ResourceManagerConfigModel": + """Require 'resource' when 'is_pre_allocated' is True.""" + if self.is_pre_allocated is True and self.resource is None: + raise ValueError("'resource' must be provided when 'is_pre_allocated' is True") + return self + + @model_validator(mode="after") + def validate_scope_and_switch(self, info: Any) -> "ResourceManagerConfigModel": + """Require 'switches' for non-fabric merged/deleted config entries. + + For the fabric scope, switch IDs are not applicable. For all other + scopes (device, device_interface, device_pair, link), at least one switch + must be specified for states that modify resources. Gathered-state config + entries are filters and may provide partial criteria. + """ + state = (info.context or {}).get("state") if info else None + if state in ("merged", "deleted") and self.scope_type is not None and self.scope_type != ScopeType.FABRIC: + if not self.switches: + raise ValueError("'switches' is required when scope_type is '{0}' (entity_name: '{1}')".format(self.scope_type, self.entity_name)) + return self + + @model_validator(mode="after") + def apply_state_validation(self, info: Any) -> "ResourceManagerConfigModel": + """Apply state-aware mandatory field enforcement using validation context. + + When model_validate(context={"state": "merged"}) or + model_validate(context={"state": "deleted"}) is passed, the model + enforces that entity_name, pool_type, pool_name, and scope_type are + all provided. For 'query' / 'gathered' state (or no context), all + fields remain optional and serve as filters. + """ + state = (info.context or {}).get("state") if info else None + if state in ("merged", "deleted"): + missing = [] + if self.entity_name is None: + missing.append("entity_name") + if self.pool_type is None: + missing.append("pool_type") + if self.pool_name is None: + missing.append("pool_name") + if self.scope_type is None: + missing.append("scope_type") + if state == "merged" and self.resource is None: + missing.append("resource") + if missing: + raise ValueError( + "Mandatory parameter(s) missing for state='{0}': {1}".format( + state, + ", ".join("'{0}'".format(m) for m in missing), + ) + ) + return self + + # ------------------------------------------------------------------------- + # Serialization helpers + # ------------------------------------------------------------------------- + + def to_gathered_dict(self) -> dict[str, Any]: + """Return a config dict suitable for gathered output. + + Returns all non-None fields serialised with Python field names + (i.e. the same keys used in Ansible playbook config blocks). + No credential masking is required for this model. + """ + return self.to_config() + + @classmethod + def get_argument_spec(cls) -> dict[str, Any]: + """Return the Ansible argument spec for nd_manage_resource_manager.""" + return dict( + fabric=dict(type="str", required=True), + state=dict( + type="str", + default="merged", + choices=["merged", "deleted", "gathered"], + ), + config=dict(type="list", elements="dict"), + ) diff --git a/plugins/module_utils/models/manage_resource_manager/resource_manager_request_model.py b/plugins/module_utils/models/manage_resource_manager/resource_manager_request_model.py new file mode 100644 index 000000000..7447eaf3f --- /dev/null +++ b/plugins/module_utils/models/manage_resource_manager/resource_manager_request_model.py @@ -0,0 +1,620 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +ResourceModel - GET response model for a single resource allocation. + +COMPOSITE model: contains FabricScope | DeviceScope | DeviceInterfaceScope | +LinkScope, DevicePairScope] as the scope_details field. + +Based on OpenAPI schema: resourceDetailsGet (allOf resourceDataBase + createTimestamp) +Discriminator mapping (scopeType): + 'fabric' -> FabricScope + 'device' -> DeviceScope + 'deviceInterface' -> DeviceInterfaceScope + 'link' -> LinkScope + 'devicePair' -> DevicePairScope + +Endpoints: + GET /fabrics/{fabricName}/resources + GET /fabrics/{fabricName}/resources/{resourceId} + DELETE /fabrics/{fabricName}/resources/{resourceId} +""" + +from __future__ import annotations + +from ipaddress import ip_address +from typing import Any, ClassVar, Literal + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field, field_validator +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_validators import ( + ResourceValidators, +) + + +class FabricScope(NDNestedModel): + """ + Scope details for resources under Fabric scope. + + Based on OpenAPI schema: fabricScope + Required: scopeType (enum: 'fabric') + """ + + identifiers: ClassVar[list[str]] = [] + + scope_type: Literal["fabric"] = Field( + default="fabric", + alias="scopeType", + description="Scope level: must be 'fabric'", + ) + fabric_name: str | None = Field( + default=None, + alias="fabricName", + description="Name of the fabric", + ) + + @field_validator("fabric_name", mode="before") + @classmethod + def validate_fabric_name(cls, v: str | None) -> str | None: + if v is None: + return None + v = str(v).strip() + return v if v else None + + +class DeviceScope(NDNestedModel): + """ + Scope details for resources under Device scope. + + Based on OpenAPI schema: deviceScope + Required: scopeType (enum: 'device') + """ + + identifiers: ClassVar[list[str]] = [] + + scope_type: Literal["device"] = Field( + default="device", + alias="scopeType", + description="Scope level: must be 'device'", + ) + switch_name: str | None = Field( + default=None, + alias="switchName", + description="Name of the switch", + ) + switch_id: str | None = Field( + default=None, + alias="switchId", + description="Serial number of the switch", + ) + switch_ip: str | None = Field( + default=None, + alias="switchIp", + description="IP address of the switch", + ) + + @field_validator("switch_name", mode="before") + @classmethod + def validate_switch_name(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + @field_validator("switch_id", mode="before") + @classmethod + def validate_switch_id(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + @field_validator("switch_ip", mode="before") + @classmethod + def validate_switch_ip(cls, v: str | None) -> str | None: + return ResourceValidators.validate_ip_address(v) + + +class DeviceInterfaceScope(NDNestedModel): + """ + Scope details for resources under DeviceInterface scope. + + Based on OpenAPI schema: deviceInterfaceScope + Required: scopeType (enum: 'deviceInterface') + """ + + identifiers: ClassVar[list[str]] = [] + + scope_type: Literal["deviceInterface"] = Field( + default="deviceInterface", + alias="scopeType", + description="Scope level: must be 'deviceInterface'", + ) + switch_name: str | None = Field( + default=None, + alias="switchName", + description="Name of the switch", + ) + switch_id: str | None = Field( + default=None, + alias="switchId", + description="Serial number of the switch", + ) + switch_ip: str | None = Field( + default=None, + alias="switchIp", + description="IP address of the switch", + ) + interface_name: str | None = Field( + default=None, + alias="interfaceName", + description="Interface name", + ) + + @field_validator("switch_name", mode="before") + @classmethod + def validate_switch_name(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + @field_validator("switch_id", mode="before") + @classmethod + def validate_switch_id(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + @field_validator("switch_ip", mode="before") + @classmethod + def validate_switch_ip(cls, v: str | None) -> str | None: + return ResourceValidators.validate_ip_address(v) + + @field_validator("interface_name", mode="before") + @classmethod + def validate_interface_name(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + +class LinkScope(NDNestedModel): + """ + Scope details for resources under Link scope. + + Based on OpenAPI schema: linkScope + Required: scopeType (enum: 'link') + """ + + identifiers: ClassVar[list[str]] = [] + + scope_type: Literal["link"] = Field( + default="link", + alias="scopeType", + description="Scope level: must be 'link'", + ) + src_switch_name: str | None = Field( + default=None, + alias="srcSwitchName", + description="Name of the source switch", + ) + src_switch_id: str | None = Field( + default=None, + alias="srcSwitchId", + description="Serial number of the source switch", + ) + src_switch_ip: str | None = Field( + default=None, + alias="srcSwitchIp", + description="IP address of the source switch", + ) + src_interface_name: str | None = Field( + default=None, + alias="srcInterfaceName", + description="Source interface name", + ) + dst_switch_name: str | None = Field( + default=None, + alias="dstSwitchName", + description="Name of the destination switch", + ) + dst_switch_id: str | None = Field( + default=None, + alias="dstSwitchId", + description="Serial number of the destination switch", + ) + dst_switch_ip: str | None = Field( + default=None, + alias="dstSwitchIp", + description="IP address of the destination switch", + ) + dst_interface_name: str | None = Field( + default=None, + alias="dstInterfaceName", + description="Destination interface name", + ) + + @field_validator("src_switch_name", "dst_switch_name", mode="before") + @classmethod + def validate_switch_names(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + @field_validator("src_switch_id", "dst_switch_id", mode="before") + @classmethod + def validate_switch_ids(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + @field_validator("src_switch_ip", "dst_switch_ip", mode="before") + @classmethod + def validate_switch_ips(cls, v: str | None) -> str | None: + return ResourceValidators.validate_ip_address(v) + + @field_validator("src_interface_name", "dst_interface_name", mode="before") + @classmethod + def validate_interface_names(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + +class DevicePairScope(NDNestedModel): + """ + Scope details for resources under DevicePair scope. + + Based on OpenAPI schema: devicePairScope + Required: scopeType (enum: 'devicePair') + """ + + identifiers: ClassVar[list[str]] = [] + + scope_type: Literal["devicePair"] = Field( + default="devicePair", + alias="scopeType", + description="Scope level: must be 'devicePair'", + ) + src_switch_name: str | None = Field( + default=None, + alias="srcSwitchName", + description="Name of the source switch", + ) + src_switch_id: str | None = Field( + default=None, + alias="srcSwitchId", + description="Serial number of the source switch", + ) + src_switch_ip: str | None = Field( + default=None, + alias="srcSwitchIp", + description="IP address of the source switch", + ) + dst_switch_name: str | None = Field( + default=None, + alias="dstSwitchName", + description="Name of the destination switch", + ) + dst_switch_id: str | None = Field( + default=None, + alias="dstSwitchId", + description="Serial number of the destination switch", + ) + dst_switch_ip: str | None = Field( + default=None, + alias="dstSwitchIp", + description="IP address of the destination switch", + ) + peer_resource_id: int | None = Field( + default=None, + alias="peerResourceId", + description="Resource ID on the destination switch", + ge=0, + ) + + @field_validator("src_switch_name", "dst_switch_name", mode="before") + @classmethod + def validate_switch_names(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + @field_validator("src_switch_id", "dst_switch_id", mode="before") + @classmethod + def validate_switch_ids(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + @field_validator("src_switch_ip", "dst_switch_ip", mode="before") + @classmethod + def validate_switch_ips(cls, v: str | None) -> str | None: + return ResourceValidators.validate_ip_address(v) + + @field_validator("peer_resource_id", mode="before") + @classmethod + def validate_peer_resource_id(cls, v: Any | None) -> int | None: + if v is None: + return None + try: + val = int(v) + except (ValueError, TypeError): + raise ValueError(f"peer_resource_id must be an integer, got: {v!r}") + if val < 0: + raise ValueError(f"peer_resource_id must be >= 0, got: {val}") + return val + + +class ResourceManagerRequest(NDNestedModel): + """ + Schema for GET APIs that contain resource allocation details. + + Based on OpenAPI schema: resourceDetailsGet (allOf resourceDataBase + createTimestamp) + Composite: scope_details is discriminated by scopeType. + + Path: GET /fabrics/{fabricName}/resources + Path: GET /fabrics/{fabricName}/resources/{resourceId} + Path: DELETE /fabrics/{fabricName}/resources/{resourceId} + """ + + identifiers: ClassVar[list[str]] = ["resource_id"] + identifier_strategy: ClassVar[Literal["single"]] = "single" + exclude_from_diff: ClassVar[list[str]] = [] + + pool_name: str | None = Field( + default=None, + alias="poolName", + description="Pool under which the resource is allocated", + ) + pool_type: str | None = Field( + default=None, + alias="poolType", + description="Type of pool: ID, IP, or SUBNET", + ) + scope_details: FabricScope | DeviceScope | DeviceInterfaceScope | LinkScope | DevicePairScope | None = Field( + default=None, + alias="scopeDetails", + description="Scope details; discriminated by scopeType", + ) + is_pre_allocated: bool | None = Field( + default=False, + alias="isPreAllocated", + description="true if the resource is pre-allocated (reserved) to an entity", + ) + entity_name: str | None = Field( + default=None, + alias="entityName", + description="Name by which the resource is allocated", + ) + resource_value: str | None = Field( + default=None, + alias="resourceValue", + description="Resource value: an ID, IP address, or subnet/CIDR", + ) + resource_id: int | None = Field( + default=None, + alias="resourceId", + description="Unique identifier of the allocated resource", + ge=0, + ) + vrf_name: str | None = Field( + default="default", + alias="vrfName", + description="VRF name when the pool is VRF-scoped; 'default' otherwise", + ) + create_timestamp: str | None = Field( + default=None, + alias="createTimestamp", + description="Timestamp when the resource was allocated or reserved", + ) + + @field_validator("pool_name", mode="before") + @classmethod + def validate_pool_name(cls, v: str | None) -> str | None: + if v is None: + return None + v = str(v).strip() + return v if v else None + + @field_validator("entity_name", mode="before") + @classmethod + def validate_entity_name(cls, v: str | None) -> str | None: + if v is None: + return None + v = str(v).strip() + return v if v else None + + @field_validator("resource_value", mode="before") + @classmethod + def validate_resource_value(cls, v: str | None) -> str | None: + """Validate resource_value: accepts an integer ID string, IPv4/v6 address, + or CIDR subnet notation. Opaque string values are passed through.""" + if v is None: + return None + v = str(v).strip() + if not v: + return None + # Try integer ID + try: + int(v) + return v + except ValueError: + pass + # Try IP address + try: + ip_address(v) + return v + except ValueError: + pass + # Try CIDR + try: + return ResourceValidators.validate_cidr(v) + except ValueError: + pass + # Fall through: return as-is (opaque string used by some pool types) + return v + + @field_validator("resource_id", mode="before") + @classmethod + def validate_resource_id(cls, v: Any | None) -> int | None: + if v is None: + return None + try: + val = int(v) + except (ValueError, TypeError): + raise ValueError(f"resource_id must be an integer, got: {v!r}") + if val < 0: + raise ValueError(f"resource_id must be >= 0, got: {val}") + return val + + @field_validator("vrf_name", mode="before") + @classmethod + def validate_vrf_name(cls, v: str | None) -> str: + if v is None: + return "default" + v = str(v).strip() + return v if v else "default" + + @field_validator("create_timestamp", mode="before") + @classmethod + def validate_create_timestamp(cls, v: str | None) -> str | None: + if v is None: + return None + return str(v).strip() or None + + @field_validator("is_pre_allocated", mode="before") + @classmethod + def validate_is_pre_allocated(cls, v: Any) -> bool: + if isinstance(v, bool): + return v + if v is None: + return False + if isinstance(v, str): + lower = v.strip().lower() + if lower in ("true", "yes", "1"): + return True + if lower in ("false", "no", "0"): + return False + raise ValueError(f"is_pre_allocated must be a boolean, got: {v!r}") + + @field_validator("scope_details", mode="before") + @classmethod + def route_scope_details(cls, v: Any) -> Any: + """Route scopeDetails dict to the correct nested scope model based on scopeType. + + scopeType discriminator mapping (from OpenAPI spec): + 'fabric' -> FabricScope + 'device' -> DeviceScope + 'deviceInterface' -> DeviceInterfaceScope + 'link' -> LinkScope + 'devicePair' -> DevicePairScope + """ + if v is None or not isinstance(v, dict): + return v + scope_type = v.get("scopeType") + if scope_type is None: + return v + model_map = { + "fabric": FabricScope, + "device": DeviceScope, + "deviceInterface": DeviceInterfaceScope, + "link": LinkScope, + "devicePair": DevicePairScope, + } + target_cls = model_map.get(scope_type) + if target_cls is None: + raise ValueError(f"Unknown scopeType: {scope_type!r}. " f"Allowed values: {list(model_map.keys())}") + return target_cls.model_validate(v) + + 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]) -> "ResourceModel": + """Create model instance from API response. + + Handles the resourceDetailsGet schema which is an allOf of + resourceDataBase and a createTimestamp extension. + + Args: + response: Response dict from the resources API. + + Returns: + ResourceModel instance. + """ + return cls.model_validate(response) + + def to_config_dict(self) -> dict[str, Any]: + """Return a user-facing configuration dictionary for this resource. + + Produces a consistent dict suitable for display and diff output. + Scope-specific fields are flattened into top-level keys so callers + need not inspect scope_details directly. + + Returns: + dict with resource identity and scope-specific details. + """ + scope = self.scope_details + scope_type = scope.scope_type if scope is not None else None + + config: dict[str, Any] = { + "resource_id": self.resource_id, + "pool_name": self.pool_name, + "resource_value": self.resource_value, + "entity_name": self.entity_name, + "vrf_name": self.vrf_name, + "is_pre_allocated": self.is_pre_allocated, + "create_timestamp": self.create_timestamp, + "scope_type": scope_type, + } + + if isinstance(scope, FabricScope): + config["fabric_name"] = scope.fabric_name + elif isinstance(scope, DeviceInterfaceScope): + config["switch_name"] = scope.switch_name + config["switch_id"] = scope.switch_id + config["switch_ip"] = scope.switch_ip + config["interface_name"] = scope.interface_name + elif isinstance(scope, DeviceScope): + config["switch_name"] = scope.switch_name + config["switch_id"] = scope.switch_id + config["switch_ip"] = scope.switch_ip + elif isinstance(scope, LinkScope): + config["src_switch_name"] = scope.src_switch_name + config["src_switch_id"] = scope.src_switch_id + config["src_switch_ip"] = scope.src_switch_ip + config["src_interface_name"] = scope.src_interface_name + config["dst_switch_name"] = scope.dst_switch_name + config["dst_switch_id"] = scope.dst_switch_id + config["dst_switch_ip"] = scope.dst_switch_ip + config["dst_interface_name"] = scope.dst_interface_name + elif isinstance(scope, DevicePairScope): + config["src_switch_name"] = scope.src_switch_name + config["src_switch_id"] = scope.src_switch_id + config["src_switch_ip"] = scope.src_switch_ip + config["dst_switch_name"] = scope.dst_switch_name + config["dst_switch_id"] = scope.dst_switch_id + config["dst_switch_ip"] = scope.dst_switch_ip + config["peer_resource_id"] = scope.peer_resource_id + + return config + + +class ResourceManagerBatchRequest(NDBaseModel): + """ + Request body for POST /fabrics/{fabricName}/resources using Ansible-style config. + + Composite: contains list[ResourceManagerRequest]. + Each item is validated with ResourceManagerRequest before submission. + """ + + identifiers: ClassVar[list[str]] = [] + + resources: list[ResourceManagerRequest] = Field(description="Array of resource configs to allocate") + + def to_payload(self) -> dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) diff --git a/plugins/module_utils/models/manage_resource_manager/resource_manager_response_model.py b/plugins/module_utils/models/manage_resource_manager/resource_manager_response_model.py new file mode 100644 index 000000000..fd967f8a4 --- /dev/null +++ b/plugins/module_utils/models/manage_resource_manager/resource_manager_response_model.py @@ -0,0 +1,114 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +ResourcesResponseModel - Response model for list-all-resources endpoint. + + COMPOSITE model: contains list[ResourceGetUpdatedModel]. + +Endpoint: GET /fabrics/{fabricName}/resources +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import NDNestedModel +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_request_model import ( + FabricScope, + DeviceScope, + DeviceInterfaceScope, + LinkScope, + DevicePairScope, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field + + +class ResourceManagerResponse(NDNestedModel): # noqa: F811 + """ + Individual resource allocation response item for POST /fabrics/{fabricName}/resources. + + Composite: scope_details field is a scope-model union discriminated + by scopeType. + """ + + identifiers: ClassVar[list[str]] = [] + + pool_name: str | None = Field( + default=None, + alias="poolName", + description="Pool under which the resource is allocated", + ) + scope_details: FabricScope | DeviceScope | DeviceInterfaceScope | LinkScope | DevicePairScope | None = Field( + default=None, + alias="scopeDetails", + description="Scope details; discriminated by scopeType", + ) + is_pre_allocated: bool | None = Field( + default=False, + alias="isPreAllocated", + description="true if the resource is pre-allocated", + ) + entity_name: str | None = Field( + default=None, + alias="entityName", + description="Name by which the resource is allocated", + ) + resource_value: str | None = Field( + default=None, + alias="resourceValue", + description="The allocated resource value", + ) + resource_id: int | None = Field( + default=None, + alias="resourceId", + description="Unique identifier of the allocated resource", + ) + # TODO(Jeet): We need to import this fields from common shared_fields file. + vrf_name: str | None = Field( + default="default", + alias="vrfName", + description="VRF name for the resource", + ) + create_timestamp: str | None = Field( + default=None, + alias="createTimestamp", + description="Timestamp when the resource was allocated", + ) + status: str | None = Field( + default=None, + description="Status of the resource create request", + ) + message: str | None = Field( + default=None, + description="Additional details describing a resource create failure", + ) + + +class ResourcesManagerBatchResponse(NDBaseModel): + """ + Response body for POST /fabrics/{fabricName}/resources (batch create). + + Composite: contains list[ResourceManagerResponse]. + """ + + identifiers: ClassVar[list[str]] = [] + + resources: list[ResourceManagerResponse] = Field(default_factory=list, description="Resource data entries") + meta: dict[str, Any] | None = Field(default=None, description="Response metadata") + + @classmethod + def from_response(cls, response: Any) -> "ResourcesManagerBatchResponse": + """Create instance from a raw API response dict. + + Accepts the raw dict returned by nd.request() for the batch POST + endpoint. If the response already has a ``resources`` key it is + validated directly; a bare list is wrapped automatically. + """ + if isinstance(response, list): + return cls.model_validate({"resources": response}) + if isinstance(response, dict): + return cls.model_validate(response) + return cls(resources=[]) diff --git a/plugins/module_utils/models/manage_resource_manager/resource_validators.py b/plugins/module_utils/models/manage_resource_manager/resource_validators.py new file mode 100644 index 000000000..42411d262 --- /dev/null +++ b/plugins/module_utils/models/manage_resource_manager/resource_validators.py @@ -0,0 +1,74 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +ResourceValidators - Common validators for resource-related fields. + +Standalone utility class (no instance required). +""" + +from __future__ import annotations + +from ipaddress import ip_address, ip_network + + +class ResourceValidators: + """ + Common validators for resource-related fields. + """ + + @staticmethod + def validate_ip_address(v: str | None) -> str | None: + """Validate IPv4 or IPv6 address.""" + if v is None: + return None + v = str(v).strip() + if not v: + return None + try: + ip_address(v) + return v + except ValueError: + raise ValueError(f"Invalid IP address format: {v}") + + @staticmethod + def validate_cidr(v: str | None) -> str | None: + """Validate CIDR notation (IP/mask).""" + if v is None: + return None + v = str(v).strip() + if not v: + return None + if "/" not in v: + raise ValueError(f"CIDR notation required (IP/mask format): {v}") + try: + ip_network(v, strict=False) + return v + except ValueError: + raise ValueError(f"Invalid CIDR format: {v}") + + @staticmethod + def validate_pool_range(v: str | None) -> str | None: + """Validate pool range format (e.g., '2300-2600' or '10.1.1.0/24').""" + if v is None: + return None + v = str(v).strip() + if not v: + return None + # Check if it's a CIDR notation + if "/" in v: + return ResourceValidators.validate_cidr(v) + # Check if it's a range (e.g., '2300-2600') + if "-" in v: + parts = v.split("-") + if len(parts) == 2: + try: + start = int(parts[0].strip()) + end = int(parts[1].strip()) + if start >= end: + raise ValueError(f"Invalid range: start ({start}) must be less than end ({end})") + return v + except ValueError as e: + raise ValueError(f"Invalid range format: {v}. Error: {str(e)}") + return v diff --git a/plugins/module_utils/models/manage_switches/__init__.py b/plugins/module_utils/models/manage_switches/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/module_utils/models/manage_switches/enums.py b/plugins/module_utils/models/manage_switches/enums.py new file mode 100644 index 000000000..4cbef45f1 --- /dev/null +++ b/plugins/module_utils/models/manage_switches/enums.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat C S (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Enumerations for Switch and Inventory Operations. + +Extracted from OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from enum import Enum +from typing import List + +# ============================================================================= +# ENUMS - Extracted from OpenAPI Schema components/schemas +# ============================================================================= + + +class SwitchRole(str, Enum): + """ + Switch role enumeration. + + Based on: components/schemas/switchRole + Description: The role of the switch, meta is a read-only switch role + """ + + BORDER = "border" + BORDER_GATEWAY = "borderGateway" + BORDER_GATEWAY_SPINE = "borderGatewaySpine" + BORDER_GATEWAY_SUPER_SPINE = "borderGatewaySuperSpine" + BORDER_SPINE = "borderSpine" + BORDER_SUPER_SPINE = "borderSuperSpine" + LEAF = "leaf" + SPINE = "spine" + SUPER_SPINE = "superSpine" + TIER2_LEAF = "tier2Leaf" + TOR = "tor" + ACCESS = "access" + AGGREGATION = "aggregation" + CORE_ROUTER = "coreRouter" + EDGE_ROUTER = "edgeRouter" + META = "meta" # read-only + NEIGHBOR = "neighbor" + + @classmethod + def choices(cls) -> List[str]: + """Return list of valid choices.""" + return [e.value for e in cls] + + +class SystemMode(str, Enum): + """ + System mode enumeration. + + Based on: components/schemas/systemMode + """ + + NORMAL = "normal" + MAINTENANCE = "maintenance" + MIGRATION = "migration" + INCONSISTENT = "inconsistent" + WAITING = "waiting" + NOT_APPLICABLE = "notApplicable" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + +class PlatformType(str, Enum): + """ + Switch platform type enumeration. + + Used for POST /fabrics/{fabricName}/switches (AddSwitches). + Includes all platform types supported by the add-switches endpoint. + Based on: components/schemas + """ + + NX_OS = "nx-os" + OTHER = "other" + IOS_XE = "ios-xe" + IOS_XR = "ios-xr" + SONIC = "sonic" + APIC = "apic" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + +class SnmpV3AuthProtocol(str, Enum): + """ + SNMPv3 authentication protocols. + + Based on: components/schemas/snmpV3AuthProtocol and schemas-snmpV3AuthProtocol + """ + + MD5 = "md5" + SHA = "sha" + MD5_DES = "md5-des" + MD5_AES = "md5-aes" + SHA_AES = "sha-aes" + SHA_DES = "sha-des" + SHA_AES_256 = "sha-aes-256" + SHA_224 = "sha-224" + SHA_224_AES = "sha-224-aes" + SHA_224_AES_256 = "sha-224-aes-256" + SHA_256 = "sha-256" + SHA_256_AES = "sha-256-aes" + SHA_256_AES_256 = "sha-256-aes-256" + SHA_384 = "sha-384" + SHA_384_AES = "sha-384-aes" + SHA_384_AES_256 = "sha-384-aes-256" + SHA_512 = "sha-512" + SHA_512_AES = "sha-512-aes" + SHA_512_AES_256 = "sha-512-aes-256" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + +class DiscoveryStatus(str, Enum): + """ + Switch discovery status. + + Based on: components/schemas/additionalSwitchData.discoveryStatus + """ + + OK = "ok" + DISCOVERING = "discovering" + REDISCOVERING = "rediscovering" + DEVICE_SHUTTING_DOWN = "deviceShuttingDown" + UNREACHABLE = "unreachable" + IP_ADDRESS_CHANGE = "ipAddressChange" + DISCOVERY_TIMEOUT = "discoveryTimeout" + RETRYING = "retrying" + SSH_SESSION_ERROR = "sshSessionError" + TIMEOUT = "timeout" + UNKNOWN_USER_PASSWORD = "unknownUserPassword" + CONNECTION_ERROR = "connectionError" + NOT_APPLICABLE = "notApplicable" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + +class ConfigSyncStatus(str, Enum): + """ + Configuration sync status. + + Based on: components/schemas/switchConfigSyncStatus + """ + + DEPLOYED = "deployed" + DEPLOYMENT_IN_PROGRESS = "deploymentInProgress" + FAILED = "failed" + IN_PROGRESS = "inProgress" + IN_SYNC = "inSync" + NOT_APPLICABLE = "notApplicable" + OUT_OF_SYNC = "outOfSync" + PENDING = "pending" + PREVIEW_IN_PROGRESS = "previewInProgress" + SUCCESS = "success" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + +class VpcRole(str, Enum): + """ + VPC role enumeration. + + Based on: components/schemas/schemas-vpcRole + """ + + PRIMARY = "primary" + SECONDARY = "secondary" + OPERATIONAL_PRIMARY = "operationalPrimary" + OPERATIONAL_SECONDARY = "operationalSecondary" + NONE_ESTABLISHED = "noneEstablished" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + +class RemoteCredentialStore(str, Enum): + """ + Remote credential store type. + + Based on: components/schemas/remoteCredentialStore + """ + + LOCAL = "local" + CYBERARK = "cyberark" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + +class AnomalyLevel(str, Enum): + """ + Anomaly level classification. + + Based on: components/schemas/anomalyLevel + """ + + CRITICAL = "critical" + MAJOR = "major" + MINOR = "minor" + WARNING = "warning" + HEALTHY = "healthy" + NOT_APPLICABLE = "notApplicable" + UNKNOWN = "unknown" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + +class AdvisoryLevel(str, Enum): + """ + Advisory level classification. + + Based on: components/schemas/advisoryLevel + """ + + CRITICAL = "critical" + MAJOR = "major" + MINOR = "minor" + WARNING = "warning" + HEALTHY = "healthy" + NONE = "none" + NOT_APPLICABLE = "notApplicable" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + +__all__ = [ + "SwitchRole", + "SystemMode", + "PlatformType", + "SnmpV3AuthProtocol", + "DiscoveryStatus", + "ConfigSyncStatus", + "VpcRole", + "RemoteCredentialStore", + "AnomalyLevel", + "AdvisoryLevel", +] diff --git a/plugins/module_utils/models/manage_switches/switch_data_models.py b/plugins/module_utils/models/manage_switches/switch_data_models.py new file mode 100644 index 000000000..d6e8b4f7f --- /dev/null +++ b/plugins/module_utils/models/manage_switches/switch_data_models.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat C S (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Switch inventory data models (API response representations). + +Based on OpenAPI schema for Nexus Dashboard Manage APIs v1.1.332. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from typing import Any, Dict, List, Optional, ClassVar, Literal, Union + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + field_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.nested import ( + NDNestedModel, +) + +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.enums import ( + AdvisoryLevel, + AnomalyLevel, + ConfigSyncStatus, + DiscoveryStatus, + PlatformType, + RemoteCredentialStore, + SwitchRole, + SystemMode, + VpcRole, +) +from .validators import SwitchValidators + + +class TelemetryIpCollection(NDNestedModel): + """ + Inband and out-of-band telemetry IP addresses for a switch. + """ + + identifiers: ClassVar[List[str]] = [] + inband_ipv4_address: Optional[str] = Field(default=None, alias="inbandIpV4Address", description="Inband IPv4 address") + inband_ipv6_address: Optional[str] = Field(default=None, alias="inbandIpV6Address", description="Inband IPv6 address") + out_of_band_ipv4_address: Optional[str] = Field( + default=None, + alias="outOfBandIpV4Address", + description="Out of band IPv4 address", + ) + out_of_band_ipv6_address: Optional[str] = Field( + default=None, + alias="outOfBandIpV6Address", + description="Out of band IPv6 address", + ) + + +class VpcData(NDNestedModel): + """ + vPC pair configuration and operational status for a switch. + """ + + identifiers: ClassVar[List[str]] = [] + vpc_domain: int = Field(alias="vpcDomain", ge=1, le=1000, description="vPC domain ID") + peer_switch_id: str = Field(alias="peerSwitchId", description="vPC peer switch serial number") + consistent_status: Optional[bool] = Field( + default=None, + alias="consistentStatus", + description="Flag to indicate the vPC status is consistent", + ) + intended_peer_name: Optional[str] = Field( + default=None, + alias="intendedPeerName", + description="Intended vPC host name for pre-provisioned peer switch", + ) + keep_alive_status: Optional[str] = Field(default=None, alias="keepAliveStatus", description="vPC peer keep alive status") + peer_link_status: Optional[str] = Field(default=None, alias="peerLinkStatus", description="vPC peer link status") + peer_name: Optional[str] = Field(default=None, alias="peerName", description="vPC peer switch name") + vpc_role: Optional[VpcRole] = Field(default=None, alias="vpcRole", description="The vPC role") + + @field_validator("peer_switch_id", mode="before") + @classmethod + def validate_peer_serial(cls, v: str) -> str: + return SwitchValidators.require_serial_number(v, "peer_switch_id") + + +class SwitchMetadata(NDNestedModel): + """ + Internal database identifiers associated with a switch record. + """ + + identifiers: ClassVar[List[str]] = [] + switch_db_id: Optional[int] = Field(default=None, alias="switchDbId", description="Database Id of the switch") + switch_uuid: Optional[str] = Field(default=None, alias="switchUuid", description="Internal unique Id of the switch") + + +class AdditionalSwitchData(NDNestedModel): + """ + Platform-specific additional data for NX-OS switches. + """ + + identifiers: ClassVar[List[str]] = [] + usage: Optional[str] = Field(default="others", description="The usage of additional data") + config_sync_status: Optional[ConfigSyncStatus] = Field(default=None, alias="configSyncStatus", description="Configuration sync status") + discovery_status: Optional[DiscoveryStatus] = Field(default=None, alias="discoveryStatus", description="Discovery status") + domain_name: Optional[str] = Field(default=None, alias="domainName", description="Domain name") + smart_switch: Optional[bool] = Field( + default=None, + alias="smartSwitch", + description="Flag that indicates if the switch is equipped with DPUs or not", + ) + hypershield_connectivity_status: Optional[str] = Field( + default=None, + alias="hypershieldConnectivityStatus", + description="Smart switch connectivity status to hypershield controller", + ) + hypershield_tenant: Optional[str] = Field(default=None, alias="hypershieldTenant", description="Hypershield tenant name") + hypershield_integration_name: Optional[str] = Field( + default=None, + alias="hypershieldIntegrationName", + description="Hypershield Integration Id", + ) + source_interface_name: Optional[str] = Field( + default=None, + alias="sourceInterfaceName", + description="Source interface for switch discovery", + ) + source_vrf_name: Optional[str] = Field( + default=None, + alias="sourceVrfName", + description="Source VRF for switch discovery", + ) + platform_type: Optional[PlatformType] = Field(default=None, alias="platformType", description="Platform type of the switch") + discovered_system_mode: Optional[SystemMode] = Field(default=None, alias="discoveredSystemMode", description="Discovered system mode") + intended_system_mode: Optional[SystemMode] = Field(default=None, alias="intendedSystemMode", description="Intended system mode") + scalable_unit: Optional[str] = Field(default=None, alias="scalableUnit", description="Name of the scalable unit") + system_mode: Optional[SystemMode] = Field(default=None, alias="systemMode", description="System mode") + vendor: Optional[str] = Field(default=None, description="Vendor of the switch") + username: Optional[str] = Field(default=None, description="Discovery user name") + remote_credential_store: Optional[RemoteCredentialStore] = Field(default=None, alias="remoteCredentialStore") + meta: Optional[SwitchMetadata] = Field(default=None, description="Switch metadata") + + +class AdditionalAciSwitchData(NDNestedModel): + """ + Platform-specific additional data for ACI leaf and spine switches. + """ + + identifiers: ClassVar[List[str]] = [] + usage: Optional[str] = Field(default="aci", description="The usage of additional data") + admin_status: Optional[Literal["inService", "outOfService"]] = Field(default=None, alias="adminStatus", description="Admin status") + health_score: Optional[int] = Field( + default=None, + alias="healthScore", + ge=1, + le=100, + description="Switch health score", + ) + last_reload_time: Optional[str] = Field( + default=None, + alias="lastReloadTime", + description="Timestamp when the system is last reloaded", + ) + last_software_update_time: Optional[str] = Field( + default=None, + alias="lastSoftwareUpdateTime", + description="Timestamp when the software is last updated", + ) + node_id: Optional[int] = Field(default=None, alias="nodeId", ge=1, description="Node ID") + node_status: Optional[Literal["active", "inActive"]] = Field(default=None, alias="nodeStatus", description="Node status") + pod_id: Optional[int] = Field(default=None, alias="podId", ge=1, description="Pod ID") + remote_leaf_group_name: Optional[str] = Field(default=None, alias="remoteLeafGroupName", description="Remote leaf group name") + switch_added: Optional[str] = Field( + default=None, + alias="switchAdded", + description="Timestamp when the switch is added", + ) + tep_pool: Optional[str] = Field(default=None, alias="tepPool", description="TEP IP pool") + + +class Metadata(NDNestedModel): + """ + Pagination and result-count metadata from a list API response. + """ + + identifiers: ClassVar[List[str]] = [] + + counts: Optional[Dict[str, int]] = Field(default=None, description="Count information including total and remaining") + + +class SwitchDataModel(NDBaseModel): + """ + Inventory record for a single switch as returned by the fabric switches API. + + Path: GET /fabrics/{fabricName}/switches + """ + + identifiers: ClassVar[List[str]] = ["switch_id"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + exclude_from_diff: ClassVar[set] = {"system_up_time", "anomaly_level", "advisory_level", "alert_suspend"} + switch_id: str = Field( + alias="switchId", + description="Serial number of Switch or Node Id of ACI switch", + ) + serial_number: Optional[str] = Field( + default=None, + alias="serialNumber", + description="Serial number of switch or APIC controller node", + ) + additional_data: Optional[Union[AdditionalSwitchData, AdditionalAciSwitchData]] = Field( + default=None, alias="additionalData", description="Additional switch data" + ) + advisory_level: Optional[AdvisoryLevel] = Field(default=None, alias="advisoryLevel") + anomaly_level: Optional[AnomalyLevel] = Field(default=None, alias="anomalyLevel") + alert_suspend: Optional[str] = Field(default=None, alias="alertSuspend") + fabric_management_ip: Optional[str] = Field( + default=None, + alias="fabricManagementIp", + description="Switch IPv4/v6 address used for management", + ) + fabric_name: Optional[str] = Field(default=None, alias="fabricName", description="Fabric name", max_length=64) + fabric_type: Optional[str] = Field(default=None, alias="fabricType", description="Fabric type") + hostname: Optional[str] = Field(default=None, description="Switch host name") + model: Optional[str] = Field(default=None, description="Model of switch or APIC controller node") + software_version: Optional[str] = Field( + default=None, + alias="softwareVersion", + description="Software version of switch or APIC controller node", + ) + switch_role: Optional[SwitchRole] = Field(default=None, alias="switchRole") + system_up_time: Optional[str] = Field(default=None, alias="systemUpTime", description="System up time") + vpc_configured: Optional[bool] = Field( + default=None, + alias="vpcConfigured", + description="Flag to indicate switch is part of a vPC domain", + ) + vpc_data: Optional[VpcData] = Field(default=None, alias="vpcData") + telemetry_ip_collection: Optional[TelemetryIpCollection] = Field(default=None, alias="telemetryIpCollection") + + @field_validator("additional_data", mode="before") + @classmethod + def parse_additional_data(cls, v: Any) -> Any: + """Route additionalData to the correct nested model. + + The NDFC API may omit the ``usage`` field for non-ACI switches. + Default to ``"others"`` so Pydantic selects ``AdditionalSwitchData`` + and coerces ``discoveryStatus`` / ``systemMode`` as proper enums. + """ + if v is None or not isinstance(v, dict): + return v + if "usage" not in v: + v = {**v, "usage": "others"} + return v + + @field_validator("switch_id", mode="before") + @classmethod + def validate_switch_id(cls, v: str) -> str: + return SwitchValidators.require_serial_number(v, "switch_id") + + @field_validator("fabric_management_ip", mode="before") + @classmethod + def validate_mgmt_ip(cls, v: Optional[str]) -> Optional[str]: + return SwitchValidators.validate_ip_address(v) + + 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]) -> "SwitchDataModel": + """ + Create model instance from API response. + + Handles two response formats: + 1. Inventory API format: {switchId, fabricManagementIp, switchRole, ...} + 2. Discovery API format: {serialNumber, ip, hostname, model, softwareVersion, status, ...} + + Args: + response: Response dict from either inventory or discovery API + + Returns: + SwitchDataModel instance + """ + # Detect format and transform if needed + if "switchId" in response or "fabricManagementIp" in response: + # Already in inventory format - use as-is + return cls.model_validate(response) + + # Discovery format - transform to inventory format + transformed = { + "switchId": response.get("serialNumber"), + "serialNumber": response.get("serialNumber"), + "fabricManagementIp": response.get("ip"), + "hostname": response.get("hostname"), + "model": response.get("model"), + "softwareVersion": response.get("softwareVersion"), + "mode": response.get("mode", "Normal"), + } + + # Only add switchRole if present in response (avoid overwriting with None) + if "switchRole" in response: + transformed["switchRole"] = response["switchRole"] + elif "role" in response: + transformed["switchRole"] = response["role"] + + return cls.model_validate(transformed) + + def to_config_dict(self) -> Dict[str, Any]: + """Return this inventory record using the 7 standard user-facing fields. + + Produces a consistent dict for previous/current output keys. All 7 + fields are always present (None when not available). Credential fields + are never included. + + Returns: + Dict with keys: seed_ip, serial_number, hostname, model, + role, software_version, mode. + """ + ad = self.additional_data + return { + "seed_ip": self.fabric_management_ip or self.switch_id or "", + "serial_number": self.serial_number, + "hostname": self.hostname, + "model": self.model, + "role": self.switch_role, + "software_version": self.software_version, + "mode": (ad.system_mode if ad and hasattr(ad, "system_mode") else None), + } + + +__all__ = [ + "TelemetryIpCollection", + "VpcData", + "SwitchMetadata", + "AdditionalSwitchData", + "AdditionalAciSwitchData", + "Metadata", + "SwitchDataModel", +] diff --git a/plugins/module_utils/models/manage_switches/validators.py b/plugins/module_utils/models/manage_switches/validators.py new file mode 100644 index 000000000..5c3160420 --- /dev/null +++ b/plugins/module_utils/models/manage_switches/validators.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat C S (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Common validators for switch-related fields.""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import re +from ipaddress import ip_address, ip_network +from typing import Optional + + +class SwitchValidators: + """ + Common validators for switch-related fields. + + The ``validate_*`` static methods are safe to call from Pydantic + ``@field_validator`` bodies. They return ``None`` when the value is + absent and raise ``ValueError`` on bad input. + + The ``require_*`` helpers are convenience wrappers that additionally + raise ``ValueError`` when the result is ``None`` (i.e. the field was + empty after stripping). Use them in place of the repetitive + ``result = …; if result is None: raise …`` pattern. + + ``check_discovery_credentials_pair`` is a shared ``@model_validator`` + helper that enforces the mutual-presence rule for discovery credentials. + """ + + # ------------------------------------------------------------------ + # Low-level nullable validators (return None when absent) + # ------------------------------------------------------------------ + + @staticmethod + def validate_ip_address(v: Optional[str]) -> Optional[str]: + """Validate IPv4 or IPv6 address.""" + if v is None: + return None + v = str(v).strip() + if not v: + return None + try: + ip_address(v) + return v + except ValueError: + raise ValueError(f"Invalid IP address format: {v}") + + @staticmethod + def validate_cidr(v: Optional[str]) -> Optional[str]: + """Validate CIDR notation (IP/mask).""" + if v is None: + return None + v = str(v).strip() + if not v: + return None + if "/" not in v: + raise ValueError(f"CIDR notation required (IP/mask format): {v}") + try: + ip_network(v, strict=False) + return v + except ValueError: + raise ValueError(f"Invalid CIDR format: {v}") + + @staticmethod + def validate_serial_number(v: Optional[str]) -> Optional[str]: + """Validate switch serial number format.""" + if v is None: + return None + v = str(v).strip() + if not v: + return None + # Serial numbers are typically alphanumeric with optional hyphens + if not re.match(r"^[A-Za-z0-9_-]+$", v): + raise ValueError(f"Serial number must be alphanumeric with optional hyphens/underscores: {v}") + return v + + @staticmethod + def validate_hostname(v: Optional[str]) -> Optional[str]: + """Validate hostname format.""" + if v is None: + return None + v = str(v).strip() + if not v: + return None + # RFC 1123 hostname validation + if len(v) > 255: + raise ValueError("Hostname cannot exceed 255 characters") + # Allow alphanumeric, dots, hyphens, underscores + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$", v): + raise ValueError(f"Invalid hostname format. Must start with alphanumeric and " f"contain only alphanumeric, dots, hyphens, underscores: {v}") + if v.startswith(".") or v.endswith(".") or ".." in v: + raise ValueError(f"Invalid hostname format (dots): {v}") + return v + + @staticmethod + def validate_mac_address(v: Optional[str]) -> Optional[str]: + """Validate MAC address format.""" + if v is None: + return None + v = str(v).strip() + if not v: + return None + # Accept colon or hyphen separated MAC addresses + mac_pattern = r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$" + if not re.match(mac_pattern, v): + raise ValueError(f"Invalid MAC address format: {v}") + return v + + @staticmethod + def validate_vpc_domain(v: Optional[int]) -> Optional[int]: + """Validate VPC domain ID (1-1000).""" + if v is None: + return None + if not 1 <= v <= 1000: + raise ValueError(f"VPC domain must be between 1 and 1000: {v}") + return v + + # ------------------------------------------------------------------ + # Required-field helpers (raise ValueError when value is absent) + # ------------------------------------------------------------------ + + @staticmethod + def require_serial_number(v: str, field_name: str = "serial_number") -> str: + """Validate and require a non-empty serial number. + + Delegates to ``validate_serial_number`` and raises ``ValueError`` + when the result is ``None`` (empty after stripping). + + Args: + v: Raw serial number value from Pydantic. + field_name: Field name used in the error message. + + Returns: + Validated serial number string. + + Raises: + ValueError: When the value is empty or contains invalid characters. + """ + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError(f"{field_name} cannot be empty") + return result + + @staticmethod + def require_hostname(v: str) -> str: + """Validate and require a non-empty hostname. + + Args: + v: Raw hostname value from Pydantic. + + Returns: + Validated hostname string. + + Raises: + ValueError: When the value is empty or fails RFC 1123 checks. + """ + result = SwitchValidators.validate_hostname(v) + if result is None: + raise ValueError("hostname cannot be empty") + return result + + @staticmethod + def require_ip_address(v: str) -> str: + """Validate and require a non-empty IP address. + + Args: + v: Raw IP address value from Pydantic. + + Returns: + Validated IP address string. + + Raises: + ValueError: When the value is empty or not a valid IPv4/v6 address. + """ + result = SwitchValidators.validate_ip_address(v) + if result is None: + raise ValueError(f"Invalid IP address: {v}") + return result + + @staticmethod + def validate_cidr_optional(v: Optional[str]) -> Optional[str]: + """Validate an optional CIDR string; pass through ``None`` unchanged. + + Args: + v: Raw CIDR value or ``None``. + + Returns: + Validated CIDR string, or ``None``. + + Raises: + ValueError: When the value is present but not valid CIDR notation. + """ + if v is None: + return None + result = SwitchValidators.validate_cidr(v) + if result is None: + raise ValueError(f"Invalid CIDR notation: {v}") + return result + + @staticmethod + def check_discovery_credentials_pair(username: Optional[str], password: Optional[str]) -> None: + """Enforce mutual-presence of discovery credentials. + + Both ``discovery_username`` and ``discovery_password`` must either be + absent together or present together. Call from a ``@model_validator`` + body to avoid duplicating the same four-line check across every model. + + Args: + username: discovery_username value (may be ``None``). + password: discovery_password value (may be ``None``). + + Raises: + ValueError: When exactly one of the two is provided. + """ + has_user = bool(username) + has_pass = bool(password) + if has_user and not has_pass: + raise ValueError("discovery_password must be set when discovery_username is specified") + if has_pass and not has_user: + raise ValueError("discovery_username must be set when discovery_password is specified") + + +__all__ = [ + "SwitchValidators", +] diff --git a/plugins/module_utils/nd.py b/plugins/module_utils/nd.py index f8f14e5d0..486e182c1 100644 --- a/plugins/module_utils/nd.py +++ b/plugins/module_utils/nd.py @@ -18,6 +18,7 @@ from ansible.module_utils._text import to_native, to_text from ansible.module_utils.connection import Connection from ansible_collections.cisco.nd.plugins.module_utils.constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED +from ansible_collections.cisco.nd.plugins.module_utils.utils import issubset def sanitize_dict(dict_to_sanitize, keys=None, values=None, recursive=True, remove_none_values=True): @@ -67,43 +68,6 @@ def cmp(a, b): return (a > b) - (a < b) -def issubset(subset, superset): - """Recurse through a nested dictionary and check if it is a subset of another.""" - - if type(subset) is not type(superset): - return False - - if not isinstance(subset, dict): - if isinstance(subset, list): - if len(subset) != len(superset): - return False - - remaining = list(superset) - for item in subset: - for index, candidate in enumerate(remaining): - if issubset(item, candidate) and issubset(candidate, item): - del remaining[index] - break - else: - return False - return True - return subset == superset - - for key, value in subset.items(): - if value is None: - continue - - if key not in superset: - return False - - superset_value = superset.get(key) - - if not issubset(value, superset_value): - return False - - return True - - def update_qs(params): """Append key-value pairs to self.filter_string""" accepted_params = dict((k, v) for (k, v) in params.items() if v is not None) diff --git a/plugins/modules/nd_manage_resource_manager.py b/plugins/modules/nd_manage_resource_manager.py new file mode 100644 index 000000000..b4dbc5d4b --- /dev/null +++ b/plugins/modules/nd_manage_resource_manager.py @@ -0,0 +1,597 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import annotations + +DOCUMENTATION = """ +--- +module: nd_manage_resource_manager +short_description: Manage resources in Cisco Nexus Dashboard (ND). +version_added: "1.0.0" +author: Jeet Ram (@jeeram) +description: + - Create, delete, and gather resources in Cisco Nexus Dashboard using smart endpoints and pydantic models. + - Supports all resource pool types (ID, IP, SUBNET) and scope types (fabric, device, device_interface, device_pair, link). + - Provides idempotent merged and deleted states. +options: + fabric: + description: + - Name of the target fabric for resource manager operations. + type: str + required: true + state: + description: + - The required state of the configuration after module completion. + - C(merged) creates resources that do not exist and updates those whose value has changed. + Resources not present in C(config) are left untouched. + - C(deleted) removes resources that are listed in C(config) from the fabric. + - C(gathered) reads the current fabric resources and returns them in C(gathered) key + in config format. No changes are made. + type: str + required: false + choices: + - merged + - deleted + - gathered + default: merged + config: + description: + - A list of dictionaries containing resource configurations. + - For state C(gathered), this may be omitted to return all resources. + type: list + elements: dict + suboptions: + entity_name: + description: + - A unique name which identifies the entity to which the resource is allocated. + - The format depends on scope_type. + - "fabric / device: free-form string, e.g. 'l3_vni_fabric'." + - "device_pair: two tildes required, e.g. 'SER1~SER2~label'." + - "device_interface: one tilde required, e.g. 'SER~Ethernet1/13'." + - "link: three tildes required, e.g. 'SER1~Eth1/3~SER2~Eth1/3'." + type: str + required: true + pool_type: + description: + - Type of resource pool. + type: str + required: true + choices: + - ID + - IP + - SUBNET + pool_name: + description: + - Name of the resource pool from which the resource is allocated. + type: str + required: true + scope_type: + description: + - Scope of resource allocation. + type: str + required: true + choices: + - fabric + - device + - device_interface + - device_pair + - link + resource: + description: + - Value of the resource being allocated. + - The value will be an integer if C(pool_type=ID). + - The value will be an IPv4 or IPv6 address if C(pool_type=IP). + - The value will be an IPv4 or IPv6 address with a net mask if C(pool_type=SUBNET). + - Required when C(state=merged). + type: str + required: false + switches: + description: + - Switch IP addresses or DNS names of the management interface of the switch to which the + allocated resource is assigned. + - Required when C(scope_type) is not C(fabric). + type: list + elements: str + required: false +extends_documentation_fragment: + - cisco.nd.modules +notes: + - Requires Nexus Dashboard 3.x or higher with the ND Manage API (v1). + - Idempotence checking compares the existing resource value to the desired value. + - Entity name matching is order-insensitive for tilde-separated serial numbers. +""" + +EXAMPLES = """ +# Entity name format +# ================== +# +# The format of the entity name depends on the scope_type of the resource being allocated. + +# Scope Type Entity Name +# ===================================== +# Fabric Eg: My_Network_30000 +# Device Eg: loopback0 +# Device Pair Eg: 9H1Q6YOL08G~9B4ZC3JGND5~vPC1 +# Device Interface Eg: 9H1Q6YOL08G~Ethernet1/13 +# Link Eg: 9H1Q6YOL08G~Ethernet1/3~9B4ZC3JGND5~Ethernet1/3 + +# where 9H1Q6YOL08G and 9B4ZC3JGND5 are switch serial numbers + +# This module supports the following states: + +# Merged: +# Resources defined in the playbook will be merged into the target fabric. +# - If the resource does not exist it will be added. +# - If the resource exists but properties managed by the playbook are different +# they will be updated if possible. +# - Resources that are not specified in the playbook will be untouched. +# +# Deleted: +# Resources defined in the playbook will be deleted. +# +# Gathered: +# Returns the current ND state for the resources listed in the playbook. + +# CREATING RESOURCES +# ================== +- name: Create Resources + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: test_fabric + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', 'device_interface', 'device_pair', 'link'] + resource: "101" # The value of the resource being created + + - entity_name: "9H1Q6YOL08G~9B4ZC3JGND5" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', 'device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - 192.168.10.150 + - 192.168.10.151 + resource: "500" # The value of the resource being created + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', 'device_interface', 'device_pair', 'link'] + resource: "110.1.1.1" # The value of the resource being created + + - entity_name: "9H1Q6YOL08G~Ethernet1/10" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', 'device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - 192.168.10.150 + resource: "fe:80::04" # The value of the resource being created + + - entity_name: "9H1Q6YOL08G~Ethernet1/3~9B4ZC3JGND5~Ethernet1/3" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', 'device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - 192.168.10.150 + resource: "fe:80:05::05/64" + +# DELETING RESOURCES +# ================== + +- name: Delete Resources + cisco.nd.nd_manage_resource_manager: + state: deleted # choose form [merged, deleted, gathered] + fabric: test_fabric + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', 'device_interface', 'device_pair', 'link'] + + - entity_name: "9H1Q6YOL08G~9B4ZC3JGND5" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', 'device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - 192.168.10.150 + - 192.168.10.151 + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', 'device_interface', 'device_pair', 'link'] + + - entity_name: "9H1Q6YOL08G~Ethernet1/10" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', 'device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - 192.168.10.150 + + - entity_name: "9H1Q6YOL08G~Ethernet1/3~9B4ZC3JGND5~Ethernet1/3" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', 'device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - 192.168.10.150 + +# GATHERING RESOURCES +# =================== + +- name: Gather all Resources - no filters + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: test_fabric + +- name: Gather Resources - filter by entity name + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: test_fabric + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + - entity_name: "loopback_dev" # A unique name to identify the resource + - entity_name: "9H1Q6YOL08G~9B4ZC3JGND5" # A unique name to identify the resource + - entity_name: "9H1Q6YOL08G~Ethernet1/10" # A unique name to identify the resource + - entity_name: "9H1Q6YOL08G~Ethernet1/3~9B4ZC3JGND5~Ethernet1/3" # A unique name to identify the resource + +- name: Gather Resources - filter by switch + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: test_fabric + config: + - switches: # provide the switch information to which the given resource is attached + - 192.168.10.150 + +- name: Gather Resources - filter by fabric and pool name + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: test_fabric + config: + - pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + - pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + - pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + +- name: Gather Resources - filter by switch and pool name + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + switches: # provide the switch information to which the given resource is attached + - 192.168.10.150 + - pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + switches: # provide the switch information to which the given resource is attached + - 192.168.10.150 + - pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + switches: # provide the switch information to which the given resource is attached + - 192.168.10.151 + +- name: Gather Resources - mixed query + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: test_fabric + config: + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + - switches: # provide the switch information to which the given resource is attached + - 192.168.10.150 + - pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + - pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + switches: # provide the switch information to which the given resource is attached + - 192.168.10.150 +""" + +RETURN = """ +changed: + description: Whether any changes were made. + returned: when state is not gathered + type: bool +diff: + description: Tracking of merged and deleted resources. + returned: when state is not gathered + type: list + elements: dict + sample: [{"merged": [], "deleted": [], "gathered": [], "debugs": []}] +api_paths: + description: API request paths included when Ansible verbosity is C(-vvv) or higher. + returned: when verbosity is C(-vvv) or higher + type: list + elements: str +api_verbs: + description: API request methods included when Ansible verbosity is C(-vvv) or higher. + returned: when verbosity is C(-vvv) or higher + type: list + elements: str +api_response: + description: API responses received during module execution. + returned: when verbosity is C(-vvv) or higher + type: list + elements: dict +api_result: + description: Parsed API operation results. + returned: when verbosity is C(-vvv) or higher + type: list + elements: dict +api_diff: + description: Per-API-call diff data. + returned: when verbosity is C(-vvv) or higher + type: list + elements: dict +api_metadata: + description: Per-API-call metadata. + returned: when verbosity is C(-vvv) or higher + type: list + elements: dict +api_payload: + description: API request payloads. + returned: when verbosity is C(-vvv) or higher + type: list + elements: raw +before: + description: State before module execution (always empty list for this module). + returned: when state is not gathered + type: list +after: + description: State after module execution (always empty list for this module). + returned: when state is not gathered + type: list +gathered: + description: + - The current fabric resource returned. + - Each entry mirrors the resource data from the ND API. + returned: when state is gathered + type: list + elements: dict +""" + +import logging +import traceback + +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.nd_v2 import ( + NDModule, + nd_argument_spec, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_config_model import ( + ResourceManagerConfigModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError +from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput +from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results +from ansible_collections.cisco.nd.plugins.module_utils.manage_resource_manager.nd_manage_resource_manager_resources import NDResourceManagerModule + + +def _module_verbosity(module): + """Return Ansible CLI verbosity for consistent output filtering.""" + return module._verbosity if hasattr(module, "_verbosity") else 0 + + +def _format_module_result(module, output_level, results=None, **kwargs): + """Build generic module output and gate API details behind verbosity.""" + output = NDOutput(output_level=output_level or "normal") + return output.format_with_verbosity(_module_verbosity(module), results, **kwargs) + + +def _record_failed_result(results, message, return_code=-1, data=None): + """Populate Results with a failed API-call shaped entry.""" + results.response_current = { + "RETURN_CODE": return_code, + "MESSAGE": message, + "DATA": data or {}, + } + results.result_current = { + "success": False, + "found": False, + } + results.diff_current = {} + results.register_api_call() + results.build_final_result() + + +def _record_nd_module_error_result(results, nd, error, log): + """Populate Results from RestSend when possible, otherwise use NDModuleError details.""" + if nd is not None: + try: + results.response_current = nd.rest_send.response_current + results.result_current = nd.rest_send.result_current + log.debug( + "main: RestSend response captured — RETURN_CODE=%s", + getattr(nd.rest_send.response_current, "RETURN_CODE", "N/A"), + ) + results.diff_current = {} + results.register_api_call() + results.build_final_result() + return + except (AttributeError, ValueError) as rest_exc: + log.debug( + "main: RestSend not available (%s: %s), building fallback response — RETURN_CODE=%s", + type(rest_exc).__name__, + rest_exc, + error.status if error.status else -1, + ) + + _record_failed_result( + results, + error.msg, + return_code=error.status if error.status else -1, + data=error.response_payload if error.response_payload else {}, + ) + + +def main(): + """Main entry point for the nd_manage_resource_manager module.""" + + # Build argument spec + argument_spec = nd_argument_spec() + argument_spec.update(ResourceManagerConfigModel.get_argument_spec()) + + # Create Ansible module + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ("state", "merged", ["config"]), + ("state", "deleted", ["config"]), + ], + ) + + # Initialize logging. ND_LOGGING_CONFIG remains the supported config override. + setup_logging(module) + log = logging.getLogger("nd.nd_manage_resource_manager") + + log.debug( + "main: logging initialised (logger='%s', effective_level=%s)", + log.name, + logging.getLevelName(log.getEffectiveLevel()), + ) + + # Get parameters + fabric = module.params.get("fabric") + output_level = module.params.get("output_level") + state = module.params.get("state") + config_count = len(module.params.get("config") or []) + log.debug( + "main: resolved module params — fabric='%s', state='%s', output_level='%s', config_count=%s, check_mode=%s", + fabric, + state, + output_level, + config_count, + module.check_mode, + ) + + # Initialize Results - this collects all operation results + results = Results() + results.check_mode = module.check_mode + results.action = "manage_resource_manager" + nd = None + + try: + # Initialize NDModule (uses RestSend infrastructure internally) + nd = NDModule(module) + log.debug( + "main: NDModule initialised — host='%s', username='%s'", + module.params.get("host"), + module.params.get("username"), + ) + + # Create NDResourceManagerModule — switch IP→ID resolution and config translation + # happen automatically inside __init__ via _get_all_switches / _resolve_switch_ids_in_config + rm_module = NDResourceManagerModule(nd=nd, results=results, log=log) + + log.debug( + "main: NDResourceManagerModule created — fabric='%s', state='%s', config_count=%s", + fabric, + state, + len(rm_module.config or []), + ) + + # Manage state for merged, deleted + log.debug("main: dispatching manage_state() for state='%s'", state) + rm_module.manage_state() + + # Exit with results + log.info( + "main: manage_state() completed successfully — state='%s', fabric='%s', changed=%s", + state, + fabric, + results.changed, + ) + rm_module.exit_module() + + except NDModuleError as error: + # NDModule-specific errors (API failures, authentication issues, etc.) + log.error( + "main: NDModuleError caught — error_type=NDModuleError, status=%s, msg='%s', fabric='%s', state='%s'", + getattr(error, "status", None), + error.msg, + fabric, + state, + ) + + _record_nd_module_error_result(results, nd, error, log) + + failure_output = { + "failed": True, + } + + # Add error details if debug output is requested + if output_level == "debug": + log.debug( + "main: output_level='debug' — attaching error_details to failure output (error_type=NDModuleError, msg='%s')", + error.msg, + ) + failure_output["error_details"] = error.to_dict() + else: + log.debug( + "main: output_level='%s' — skipping error_details attachment", + output_level, + ) + + final = _format_module_result(module, output_level, results, **failure_output) + + log.error( + "main: module failing with NDModuleError — msg='%s', final_result_keys=%s", + error.msg, + list(final.keys()), + ) + module.fail_json(msg=error.msg, **final) + + except ValueError as error: + # Validation errors raised by NDResourceManagerModule (e.g. invalid config, + # mandatory field missing, pool/scope mismatch, API field mismatch). + log.error( + "main: ValueError caught — msg='%s', fabric='%s', state='%s'", + str(error), + fabric, + state, + ) + _record_failed_result(results, str(error)) + final = _format_module_result(module, output_level, results, failed=True) + module.fail_json(msg=str(error), **final) + + except Exception as error: + # Unexpected errors + log.error( + "main: unexpected exception caught — error_type='%s', msg='%s', fabric='%s', state='%s'", + type(error).__name__, + str(error), + fabric, + state, + ) + + _record_failed_result(results, f"Unexpected error: {str(error)}") + log.debug( + "main: built fallback failed result — RETURN_CODE=-1, error_type='%s'", + type(error).__name__, + ) + + failure_output = { + "failed": True, + } + + if output_level == "debug": + tb_str = traceback.format_exc() + failure_output["traceback"] = tb_str + log.debug( + "main: output_level='debug' — attaching traceback (%s lines) to failure output", + len(tb_str.splitlines()), + ) + else: + log.debug( + "main: output_level='%s' — skipping traceback attachment", + output_level, + ) + + final = _format_module_result(module, output_level, results, **failure_output) + + log.error( + "main: module failing with unexpected error — error_type='%s', msg='%s'", + type(error).__name__, + str(error), + ) + module.fail_json(msg=str(error), **final) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_resource_manager/defaults/main.yaml b/tests/integration/targets/nd_resource_manager/defaults/main.yaml new file mode 100644 index 000000000..5f709c5aa --- /dev/null +++ b/tests/integration/targets/nd_resource_manager/defaults/main.yaml @@ -0,0 +1,2 @@ +--- +testcase: "*" diff --git a/tests/integration/targets/nd_resource_manager/meta/main.yaml b/tests/integration/targets/nd_resource_manager/meta/main.yaml new file mode 100644 index 000000000..32cf5dda7 --- /dev/null +++ b/tests/integration/targets/nd_resource_manager/meta/main.yaml @@ -0,0 +1 @@ +dependencies: [] diff --git a/tests/integration/targets/nd_resource_manager/tasks/main.yaml b/tests/integration/targets/nd_resource_manager/tasks/main.yaml new file mode 100644 index 000000000..2850799ac --- /dev/null +++ b/tests/integration/targets/nd_resource_manager/tasks/main.yaml @@ -0,0 +1,4 @@ +--- +- name: Include tasks + ansible.builtin.include_tasks: rm_tasks.yaml + tags: ['nd'] diff --git a/tests/integration/targets/nd_resource_manager/tasks/rm_tasks.yaml b/tests/integration/targets/nd_resource_manager/tasks/rm_tasks.yaml new file mode 100644 index 000000000..2f111cc68 --- /dev/null +++ b/tests/integration/targets/nd_resource_manager/tasks/rm_tasks.yaml @@ -0,0 +1,26 @@ +--- +- name: Collect nd test cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/nd" + patterns: "{{ testcase }}.yaml" + connection: local + register: nd_cases + tags: sanity + +- name: Set fact + ansible.builtin.set_fact: + test_cases: + files: "{{ nd_cases.files }}" + tags: sanity + +- name: Set test_items + ansible.builtin.set_fact: + test_items: "{{ test_cases.files | map(attribute='path') | list }}" + tags: sanity + +- name: Run test cases (connection=httpapi) + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run + tags: sanity diff --git a/tests/integration/targets/nd_resource_manager/tests/nd/delete.yaml b/tests/integration/targets/nd_resource_manager/tests/nd/delete.yaml new file mode 100644 index 000000000..ef2ef482c --- /dev/null +++ b/tests/integration/targets/nd_resource_manager/tests/nd/delete.yaml @@ -0,0 +1,262 @@ +############################################## +## SETUP ## +############################################## + +- name: Delete Resources + cisco.nd.nd_manage_resource_manager: &rm_delete + state: deleted # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + register: result + +- name: Assert + ansible.builtin.assert: + that: + - result.api_response | default([]) | rejectattr('RETURN_CODE', 'equalto', 200) | list | length == 0 + +- block: + + ############################################## + ## MERGE ## + ############################################## + + - name: Create Resources + cisco.nd.nd_manage_resource_manager: &rm_merge + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "101" # The value of the resource being created + + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "102" # The value of the resource being created + + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch2 }}" # provide the switch information to which the given resource is to be attached + resource: "200" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch2 }}" # provide the switch information to which the given resource is to be attached + resource: "500" # The value of the resource being created + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "110.1.1.1" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" # provide the switch information to which the given resource is to be attached + resource: "fe:80::04" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:80:0505::05/64" # The value of the resource being created + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'result.changed == true' + - '(result["diff"][0]["merged"] | length) == 8' + - '(result["diff"][0]["deleted"] | length) == 0' + - 'result["api_response"] is not defined or (result["api_response"] | length) == 1' + - 'result["api_result"] is not defined or result["api_result"][0]["success"] == true' + - 'result["api_diff"] is not defined or (result["api_diff"][0]["merged"] | length) == (result["diff"][0]["merged"] | length)' + - 'result["api_payload"] is not defined or (result["api_payload"][0]["resources"] | length) == (result["diff"][0]["merged"] | length)' + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - result.api_response | default([]) | rejectattr('RETURN_CODE', 'equalto', 200) | list | length == 0 + + ############################################## + ## IDEMPOTENCE ## + ############################################## + + - name: Create Resources - Idempotence + cisco.nd.nd_manage_resource_manager: *rm_merge + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + # TODO(Jeet): result.changed should be false and merged should be 0 but due to controller api is not creating a resources, hence accepting 1 for now. This needs to be changed in future. + - 'result.changed == true' + - '(result["diff"][0]["merged"] | length) == 1' + - '(result["diff"][0]["deleted"] | length) == 0' + - 'result["api_response"] is not defined or (result["api_response"] | length) == 1' + - 'result["api_result"] is not defined or result["api_result"][0]["success"] == true' + - 'result["api_diff"] is not defined or (result["api_diff"][0]["merged"] | length) == (result["diff"][0]["merged"] | length)' + - 'result["api_payload"] is not defined or (result["api_payload"][0]["resources"] | length) == (result["diff"][0]["merged"] | length)' + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - result.api_response | default([]) | rejectattr('RETURN_CODE', 'equalto', 200) | list | length == 0 + + ############################################## + ## MISSING PARAMS IN DELETE ## + ############################################## + + - name: Delete Resources - scope_type missing + cisco.nd.nd_manage_resource_manager: + state: deleted # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + register: result + ignore_errors: true + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'result["msg"] == "Mandatory parameter ''scope_type'' missing"' + + ############################################## + ## MISSING PARAMS IN DELETE ## + ############################################## + + - name: Delete Resources - pool_type missing + cisco.nd.nd_manage_resource_manager: + state: deleted # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - scope_type: "fabric" + register: result + ignore_errors: true + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'result["msg"] == "Mandatory parameter ''pool_type'' missing"' + + ############################################## + ## MISSING PARAMS IN DELETE ## + ############################################## + + - name: Delete Resources - entity_name missing + cisco.nd.nd_manage_resource_manager: + state: deleted # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "fabric" + register: result + ignore_errors: true + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'result["msg"] == "Mandatory parameter ''entity_name'' missing"' + + ############################################## + ## MISSING PARAMS IN DELETE ## + ############################################## + + - name: Delete Resources - pool_name missing + cisco.nd.nd_manage_resource_manager: + state: deleted # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + scope_type: "fabric" + register: result + ignore_errors: true + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'result["msg"] == "Mandatory parameter ''pool_name'' missing"' + + ############################################## + ## MISSING PARAMS IN DELETE ## + ############################################## + + - name: Delete Resources - switch info missing + cisco.nd.nd_manage_resource_manager: + state: deleted # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + register: result + ignore_errors: true + + - name: Assert conditions + ansible.builtin.assert: + that: + - '"switches : Required parameter not found" in result["msg"]' diff --git a/tests/integration/targets/nd_resource_manager/tests/nd/gathered.yaml b/tests/integration/targets/nd_resource_manager/tests/nd/gathered.yaml new file mode 100644 index 000000000..9068fd56d --- /dev/null +++ b/tests/integration/targets/nd_resource_manager/tests/nd/gathered.yaml @@ -0,0 +1,352 @@ +############################################## +## SETUP ## +############################################## + +- name: Delete Resources + cisco.nd.nd_manage_resource_manager: &rm_delete + state: deleted # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + register: result + +- name: Assert + ansible.builtin.assert: + that: + - result.api_response | default([]) | rejectattr('RETURN_CODE', 'equalto', 200) | list | length == 0 + +- block: + + ############################################## + ## MERGE ## + ############################################## + + - name: Create Resources + cisco.nd.nd_manage_resource_manager: &rm_merge + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "101" # The value of the resource being created + + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "102" # The value of the resource being created + + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "200" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "500" # The value of the resource being created + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "110.1.1.1" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:80::04" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:80:0505::05/64" # The value of the resource being created + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'result.changed == true' + - '(result["diff"][0]["merged"] | length) == 8' + - '(result["diff"][0]["deleted"] | length) == 0' + - 'result["api_response"] is not defined or (result["api_response"] | length) == 1' + - 'result["api_result"] is not defined or result["api_result"][0]["success"] == true' + - 'result["api_diff"] is not defined or (result["api_diff"][0]["merged"] | length) == (result["diff"][0]["merged"] | length)' + - 'result["api_payload"] is not defined or (result["api_payload"][0]["resources"] | length) == (result["diff"][0]["merged"] | length)' + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: '{{ result.api_response | default([]) }}' + + ############################################## + ## GATHERED ## + ############################################## + + - name: Gathered all Resources - false filters + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - '(result["gathered"] | length) != 0' + + - name: Creating entity_names + ansible.builtin.set_fact: + entity_names: + - "l3_vni_fabric" + - "l2_vni_fabric" + - "loopback_dev" + - "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" + - "{{ ansible_sno_2 }}~{{ ansible_sno_1 }}" + - "{{ ansible_sno_1 }}~{{ intf_1_10 }}" + - "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" + - "{{ ansible_sno_2 }}~{{ intf_1_3 }}~{{ ansible_sno_1 }}~{{ intf_1_3 }}" + - "mmudigon-2" + + - name: Gathered Resources - filter by entity name + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + - entity_name: "loopback_dev" # A unique name to identify the resource + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_2 }}~{{ ansible_sno_2 }}~{{ intf_1_2 }}" # A unique name to identify the resource + - entity_name: "mmudigon-2" # A unique name to identify the resource + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_2 }}" # A unique name to identify the resource + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'item["entity_name"] in {{ entity_names }}' + loop: '{{ result.gathered }}' + + - name: Gathered Resources - filter by switch 1 + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'ansible_switch1 in (item["switches"] | default([]))' + loop: '{{ result.gathered }}' + + - name: Gathered Resources - filter by switch 2 + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch2 }}" + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'ansible_switch2 in (item["switches"] | default([]))' + loop: '{{ result.gathered }}' + + - name: Gathered Resources - filter by fabric and pool name + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - pool_name: "BGP_ASN_ID" # Based on the 'poolType', select appropriate name + - pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + - pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + - pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + - pool_name: "VPC_PEER_LINK_VLAN" # Based on the 'poolType', select appropriate name + - pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + - pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + - pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + - pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'item["pool_name"] in ["BGP_ASN_ID", + "L3_VNI", + "L2_VNI", + "LOOPBACK_ID", + "VPC_PEER_LINK_VLAN", + "VPC_ID", + "LOOPBACK0_IP_POOL", + "LOOPBACK1_IP_POOL", + "SUBNET"]' + loop: '{{ result.gathered }}' + + - name: Gathered Resources - filter by switch and pool name + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - pool_name: "BGP_ASN_ID" # Based on the 'poolType', select appropriate name + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - pool_name: "VPC_PEER_LINK_VLAN" # Based on the 'poolType', select appropriate name + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'ansible_switch1 in (item["switches"] | default([])) or + item["pool_name"] in ["BGP_ASN_ID", + "L3_VNI", + "L2_VNI", + "LOOPBACK_ID", + "VPC_PEER_LINK_VLAN", + "VPC_ID", + "LOOPBACK0_IP_POOL", + "LOOPBACK1_IP_POOL", + "SUBNET"]' + loop: '{{ result.gathered }}' + + - name: Gathered Resources - mixed gathered + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch2 }}" + - pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + - pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + - switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - '(result["gathered"] | length) != 0' + + ############################################## + ## CLEANUP ## + ############################################## + + always: + + - name: Delete Resources + cisco.nd.nd_manage_resource_manager: *rm_delete + register: result + when: it_context is not defined + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'result.changed == true' + - '(result["diff"][0]["merged"] | length) == 0' + - '(result["diff"][0]["deleted"] | length) == 7' + - '(result["diff"][0]["gathered"] | length) == 0' + - 'result["api_response"] is not defined or (result["api_response"] | length) == 1' + - 'result["api_result"] is not defined or result["api_result"][0]["success"] == true' + - 'result["api_diff"] is not defined or (result["api_diff"][0]["deleted"] | length) == (result["diff"][0]["deleted"] | length)' + - 'result["api_payload"] is not defined or (result["api_payload"][0]["resourceIds"] | length) == (result["diff"][0]["deleted"] | length)' + when: it_context is not defined + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: '{{ result.api_response | default([]) }}' + when: it_context is not defined diff --git a/tests/integration/targets/nd_resource_manager/tests/nd/invalid_params.yaml b/tests/integration/targets/nd_resource_manager/tests/nd/invalid_params.yaml new file mode 100644 index 000000000..fec02ab80 --- /dev/null +++ b/tests/integration/targets/nd_resource_manager/tests/nd/invalid_params.yaml @@ -0,0 +1,127 @@ +############################################## +## SETUP ## +############################################## + +- block: + + ############################################## + ## MERGE ## + ############################################## + + - name: Create Resources - Invalid Pool type + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "IDLE" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "101" # The value of the resource being created + ignore_errors: true + + - name: Create Resources - Invalid Pool Name + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "WRONG_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "101" # The value of the resource being created + ignore_errors: true + + - name: Create Resources - L3 VNI wrong scope type + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "102" # The value of the resource being created + ignore_errors: true + + - name: Create Resources - L2VNI - wrong scope type + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "102" # The value of the resource being created + ignore_errors: true + + - name: Create Resources - LOOPBACK_ID wrong scope type + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "200" # The value of the resource being created + ignore_errors: true + + - name: Create Resources - VPC_ID wrong scope type + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "500" # The value of the resource being created + ignore_errors: true + + - name: Create Resources - LOOPBACK0_IP_POOL wrong scope type + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "110.1.1.1" # The value of the resource being created + ignore_errors: true + + - name: Create Resources - LOOPBACK1_IP_POOL wrong scope type + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:80::04" # The value of the resource being created + ignore_errors: true + + - name: Create Resources - SUBNET wrong scope type + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:80:0505::05/64" # The value of the resource being created + ignore_errors: true diff --git a/tests/integration/targets/nd_resource_manager/tests/nd/load.yaml b/tests/integration/targets/nd_resource_manager/tests/nd/load.yaml new file mode 100644 index 000000000..d011c4193 --- /dev/null +++ b/tests/integration/targets/nd_resource_manager/tests/nd/load.yaml @@ -0,0 +1,210 @@ +--- +############################################## +## LOAD TEST - OPT-IN ## +############################################## + +- name: ND Resource Manager load test + when: rm_load_test_enabled | default(false) | bool + block: + + - name: LOAD - Set load test defaults + ansible.builtin.set_fact: + rm_load_expected_resources: "{{ rm_load_expected_resources | default(1000) | int }}" + rm_load_resource_start: "{{ rm_load_resource_start | default(300) | int }}" + rm_load_switches: + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + + - name: LOAD - Validate load test prerequisites + ansible.builtin.assert: + that: + - '(rm_load_expected_resources | int) > 0' + - '(rm_load_switches | length) == 2' + - '((rm_load_expected_resources | int) % (rm_load_switches | length)) == 0' + fail_msg: >- + Resource manager load test requires a positive resource count that is + divisible by exactly two configured switches. + + - name: LOAD - Calculate generated config count + ansible.builtin.set_fact: + rm_load_config_count: "{{ (rm_load_expected_resources | int) // (rm_load_switches | length) }}" + rm_load_config: [] + + - name: LOAD - Build generated resource config + ansible.builtin.set_fact: + rm_load_config: >- + {{ + rm_load_config + [ + { + 'entity_name': 'rm_load_loopback_' ~ item, + 'pool_type': 'ID', + 'pool_name': 'LOOPBACK_ID', + 'scope_type': 'device', + 'switches': rm_load_switches, + 'resource': ((rm_load_resource_start | int) + (item | int) - 1) | string + } + ] + }} + loop: "{{ range(1, (rm_load_config_count | int) + 1) | list }}" + + - name: LOAD - Assert generated config size + ansible.builtin.assert: + that: + - '(rm_load_config | length) == (rm_load_config_count | int)' + + - name: LOAD - Pre-clean generated resources + cisco.nd.nd_manage_resource_manager: + state: deleted + fabric: "{{ ansible_it_fabric }}" + config: "{{ rm_load_config }}" + register: rm_load_pre_cleanup_result + + - block: + + ############################################## + ## BULK CREATE ## + ############################################## + + - name: LOAD - Capture bulk create start time + ansible.builtin.set_fact: + rm_load_create_start_epoch: "{{ lookup('pipe', 'date +%s') | int }}" + + - name: LOAD - Bulk create resources + cisco.nd.nd_manage_resource_manager: &rm_load_merge + state: merged + fabric: "{{ ansible_it_fabric }}" + config: "{{ rm_load_config }}" + register: rm_load_create_result + + - name: LOAD - Capture bulk create elapsed time + ansible.builtin.set_fact: + rm_load_create_elapsed: "{{ (lookup('pipe', 'date +%s') | int) - (rm_load_create_start_epoch | int) }}" + + - name: LOAD - Report bulk create timing + ansible.builtin.debug: + msg: "Resource manager bulk create completed in {{ rm_load_create_elapsed }} second(s)." + + - name: LOAD - Assert bulk create result + ansible.builtin.assert: + that: + - 'rm_load_create_result.changed == true' + - '(rm_load_create_result["diff"][0]["merged"] | length) == (rm_load_expected_resources | int)' + - 'rm_load_create_result["api_response"] is not defined or (rm_load_create_result["api_response"] | length) == 1' + - 'rm_load_create_result["api_result"] is not defined or rm_load_create_result["api_result"][0]["success"] == true' + - 'rm_load_create_result["api_diff"] is not defined or (rm_load_create_result["api_diff"][0]["merged"] | length) == (rm_load_create_result["diff"][0]["merged"] | length)' + - 'rm_load_create_result["api_payload"] is not defined or (rm_load_create_result["api_payload"][0]["resources"] | length) == (rm_load_create_result["diff"][0]["merged"] | length)' + + - name: LOAD - Assert bulk create response codes + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: "{{ rm_load_create_result.api_response | default([]) }}" + + - name: LOAD - Assert optional bulk create performance threshold + ansible.builtin.assert: + that: + - '(rm_load_create_elapsed | int) <= (rm_load_max_create_seconds | int)' + fail_msg: >- + Resource manager bulk create took {{ rm_load_create_elapsed }} + second(s), exceeding rm_load_max_create_seconds={{ rm_load_max_create_seconds }}. + when: rm_load_max_create_seconds is defined + + ############################################## + ## IDEMPOTENCE ## + ############################################## + + - name: LOAD - Bulk create resources - Idempotence + cisco.nd.nd_manage_resource_manager: *rm_load_merge + register: rm_load_idempotent_result + + - name: LOAD - Assert bulk create idempotency + ansible.builtin.assert: + that: + - '(rm_load_idempotent_result["diff"][0]["merged"] | length) <= 1' + - '(rm_load_idempotent_result["diff"][0]["deleted"] | length) == 0' + - 'rm_load_idempotent_result["api_response"] is not defined or (rm_load_idempotent_result["api_response"] | length) == 1' + - 'rm_load_idempotent_result["api_result"] is not defined or rm_load_idempotent_result["api_result"][0]["success"] == true' + - 'rm_load_idempotent_result["api_diff"] is not defined or (rm_load_idempotent_result["api_diff"][0].get("merged", []) | length) == (rm_load_idempotent_result["diff"][0]["merged"] | length)' + + - name: LOAD - Assert bulk create idempotency response codes + ansible.builtin.assert: + that: + - rm_load_idempotent_result.api_response | default([]) | rejectattr('RETURN_CODE', 'equalto', 200) | list | length == 0 + + ############################################## + ## BULK DELETE ## + ############################################## + + - name: LOAD - Capture bulk delete start time + ansible.builtin.set_fact: + rm_load_delete_start_epoch: "{{ lookup('pipe', 'date +%s') | int }}" + + - name: LOAD - Bulk delete resources + cisco.nd.nd_manage_resource_manager: &rm_load_delete + state: deleted + fabric: "{{ ansible_it_fabric }}" + config: "{{ rm_load_config }}" + register: rm_load_delete_result + + - name: LOAD - Capture bulk delete elapsed time + ansible.builtin.set_fact: + rm_load_delete_elapsed: "{{ (lookup('pipe', 'date +%s') | int) - (rm_load_delete_start_epoch | int) }}" + + - name: LOAD - Report bulk delete timing + ansible.builtin.debug: + msg: "Resource manager bulk delete completed in {{ rm_load_delete_elapsed }} second(s)." + + - name: LOAD - Assert bulk delete result + ansible.builtin.assert: + that: + - 'rm_load_delete_result.changed == true' + - '(rm_load_delete_result["diff"][0]["deleted"] | length) == (rm_load_expected_resources | int)' + - 'rm_load_delete_result["api_response"] is not defined or (rm_load_delete_result["api_response"] | length) == 1' + - 'rm_load_delete_result["api_result"] is not defined or rm_load_delete_result["api_result"][0]["success"] == true' + - 'rm_load_delete_result["api_diff"] is not defined or (rm_load_delete_result["api_diff"][0]["deleted"] | length) == (rm_load_delete_result["diff"][0]["deleted"] | length)' + - 'rm_load_delete_result["api_payload"] is not defined or (rm_load_delete_result["api_payload"][0]["resourceIds"] | length) == (rm_load_delete_result["diff"][0]["deleted"] | length)' + + - name: LOAD - Assert bulk delete response codes + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: "{{ rm_load_delete_result.api_response | default([]) }}" + + - name: LOAD - Assert optional bulk delete performance threshold + ansible.builtin.assert: + that: + - '(rm_load_delete_elapsed | int) <= (rm_load_max_delete_seconds | int)' + fail_msg: >- + Resource manager bulk delete took {{ rm_load_delete_elapsed }} + second(s), exceeding rm_load_max_delete_seconds={{ rm_load_max_delete_seconds }}. + when: rm_load_max_delete_seconds is defined + + ############################################## + ## DELETE IDEMPOTENCE ## + ############################################## + + - name: LOAD - Bulk delete resources - Idempotence + cisco.nd.nd_manage_resource_manager: *rm_load_delete + register: rm_load_delete_idempotent_result + + - name: LOAD - Assert bulk delete idempotency + ansible.builtin.assert: + that: + - 'rm_load_delete_idempotent_result.changed == false' + - '(rm_load_delete_idempotent_result["diff"][0]["deleted"] | length) == 0' + + always: + + ############################################## + ## CLEANUP ## + ############################################## + + - name: LOAD - Cleanup generated resources + cisco.nd.nd_manage_resource_manager: + state: deleted + fabric: "{{ ansible_it_fabric }}" + config: "{{ rm_load_config }}" + register: rm_load_cleanup_result + when: + - rm_load_config is defined + - (rm_load_config | length) > 0 diff --git a/tests/integration/targets/nd_resource_manager/tests/nd/merge.yaml b/tests/integration/targets/nd_resource_manager/tests/nd/merge.yaml new file mode 100644 index 000000000..56096d153 --- /dev/null +++ b/tests/integration/targets/nd_resource_manager/tests/nd/merge.yaml @@ -0,0 +1,398 @@ +############################################## +## SETUP ## +############################################## + +- name: Delete Resources + cisco.nd.nd_manage_resource_manager: &rm_delete + state: deleted # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + register: result + +- name: Assert conditions + ansible.builtin.assert: + that: + - result.api_response | default([]) | rejectattr('RETURN_CODE', 'equalto', 200) | list | length == 0 + +- block: + + ############################################## + ## MERGE ## + ############################################## + + - name: Create Resources + cisco.nd.nd_manage_resource_manager: &rm_merge + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "101" # The value of the resource being created + + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "102" # The value of the resource being created + + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "200" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "500" # The value of the resource being created + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "110.1.1.1" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:80::04" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:80:0505::05/64" # The value of the resource being created + register: result + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'result.changed == true' + - '(result["diff"][0]["merged"] | length) == 8' + - '(result["diff"][0]["deleted"] | length) == 0' + - 'result["api_response"] is not defined or (result["api_response"] | length) == 1' + - 'result["api_result"] is not defined or result["api_result"][0]["success"] == true' + - 'result["api_diff"] is not defined or (result["api_diff"][0]["merged"] | length) == (result["diff"][0]["merged"] | length)' + - 'result["api_payload"] is not defined or (result["api_payload"][0]["resources"] | length) == (result["diff"][0]["merged"] | length)' + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: '{{ result.api_response | default([]) }}' + + + ############################################## + ## IDEMPOTENCE ## + ############################################## + + - name: Create Resources - Idempotence + cisco.nd.nd_manage_resource_manager: *rm_merge + register: result + + - name: Assert conditions + ansible.builtin.assert: + that: + # TODO(Jeet): result.changed should be false and merged should be 0 but due to controller api is not creating a resources, hence accepting 1 for now. This needs to be changed in future. + - 'result.changed == true' + - '(result["diff"][0]["merged"] | length) == 1' + - '(result["diff"][0]["deleted"] | length) == 0' + - 'result["api_response"] is not defined or (result["api_response"] | length) == 1' + - 'result["api_result"] is not defined or result["api_result"][0]["success"] == true' + - 'result["api_diff"] is not defined or (result["api_diff"][0]["merged"] | length) == (result["diff"][0]["merged"] | length)' + - 'result["api_payload"] is not defined or (result["api_payload"][0]["resources"] | length) == (result["diff"][0]["merged"] | length)' + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: '{{ result.api_response | default([]) }}' + + ############################################## + ## MODIFY EXISTING RESOURCES ## + ############################################## + + - name: Modify Resources + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "1001" # The value of the resource being modified + + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "1002" # The value of the resource being modified + + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "1003" # The value of the resource being modified + + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "1005" # The value of the resource being modified + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "111.1.1.1" # The value of the resource being modified + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:81::04" # The value of the resource being modified + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:81:0505::05/64" # The value of the resource being modified + register: result + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'result.changed == true' + - '(result["diff"][0]["merged"] | length) == 8' + - '(result["diff"][0]["deleted"] | length) == 0' + - 'result["api_response"] is not defined or (result["api_response"] | length) == 1' + - 'result["api_result"] is not defined or result["api_result"][0]["success"] == true' + - 'result["api_diff"] is not defined or (result["api_diff"][0]["merged"] | length) == (result["diff"][0]["merged"] | length)' + - 'result["api_payload"] is not defined or (result["api_payload"][0]["resources"] | length) == (result["diff"][0]["merged"] | length)' + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: '{{ result.api_response | default([]) }}' + + ############################################## + ## MERGE - MISSING PARAMS ## + ############################################## + + - name: Create Resources - Missing switch info + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "200" # The value of the resource being created + register: result + ignore_errors: true + + - name: Assert conditions + ansible.builtin.assert: + that: + - '"switches : Required parameter not found" in result["msg"]' + + - name: Create Resources - Missing scope_type + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "200" # The value of the resource being created + register: result + ignore_errors: true + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'result["msg"] == "Mandatory parameter ''scope_type'' missing"' + + - name: Create Resources - Missing pool_type + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "loopback_dev" # A unique name to identify the resource + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "200" # The value of the resource being created + register: result + ignore_errors: true + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'result["msg"] == "Mandatory parameter ''pool_type'' missing"' + + - name: Create Resources - Missing pool_name + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "200" # The value of the resource being created + register: result + ignore_errors: true + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'result["msg"] == "Mandatory parameter ''pool_name'' missing"' + + - name: Create Resources - Missing entity_name + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "200" # The value of the resource being created + register: result + ignore_errors: true + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'result["msg"] == "Mandatory parameter ''entity_name'' missing"' + + - name: Create Resources - Missing resource value + cisco.nd.nd_manage_resource_manager: + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + register: result + ignore_errors: true + + - name: Assert conditions + ansible.builtin.assert: + that: + - '"Mandatory parameter(s) missing for state=''merged'': ''resource''" in result["msg"]' + + ############################################## + ## CLEANUP ## + ############################################## + + always: + + - name: Delete Resources + cisco.nd.nd_manage_resource_manager: *rm_delete + register: result + when: IT_CONTEXT is not defined + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'result.changed == true' + - '(result["diff"][0]["merged"] | length) == 0' + - '(result["diff"][0]["deleted"] | length) == 7' + - 'result["api_response"] is not defined or (result["api_response"] | length) == 1' + - 'result["api_result"] is not defined or result["api_result"][0]["success"] == true' + - 'result["api_diff"] is not defined or (result["api_diff"][0]["deleted"] | length) == (result["diff"][0]["deleted"] | length)' + - 'result["api_payload"] is not defined or (result["api_payload"][0]["resourceIds"] | length) == (result["diff"][0]["deleted"] | length)' + when: IT_CONTEXT is not defined + + - name: Assert conditions + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: '{{ result.api_response | default([]) }}' + when: IT_CONTEXT is not defined diff --git a/tests/integration/targets/nd_resource_manager/tests/nd/sanity.yaml b/tests/integration/targets/nd_resource_manager/tests/nd/sanity.yaml new file mode 100644 index 000000000..c1dabd189 --- /dev/null +++ b/tests/integration/targets/nd_resource_manager/tests/nd/sanity.yaml @@ -0,0 +1,224 @@ +############################################## +## SETUP ## +############################################## + +- name: SANITY- Delete Resources + cisco.nd.nd_manage_resource_manager: &rm_delete + state: deleted # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is attached + - "{{ ansible_switch1 }}" + register: result + tags: sanity + +- name: Assert + ansible.builtin.assert: + that: + - result.api_response | default([]) | rejectattr('RETURN_CODE', 'equalto', 200) | list | length == 0 + tags: sanity + +- tags: sanity + block: + + ############################################## + ## MERGE ## + ############################################## + + - name: SANITY- Create Resources + cisco.nd.nd_manage_resource_manager: &rm_merge + state: merged # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L3_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "101" # The value of the resource being created + + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "L2_VNI" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "102" # The value of the resource being created + + - entity_name: "loopback_dev" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK_ID" # Based on the 'poolType', select appropriate name + scope_type: "device" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "200" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + pool_type: "ID" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "VPC_ID" # Based on the 'poolType', select appropriate name + scope_type: "device_pair" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + - "{{ ansible_switch2 }}" + resource: "500" # The value of the resource being created + + - entity_name: "mmudigon-2" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK0_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "fabric" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + resource: "110.1.1.1" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + pool_type: "IP" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "LOOPBACK1_IP_POOL" # Based on the 'poolType', select appropriate name + scope_type: "device_interface" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:80::04" # The value of the resource being created + + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + pool_type: "SUBNET" # choose from ['ID', 'IP', 'SUBNET'] + pool_name: "SUBNET" # Based on the 'poolType', select appropriate name + scope_type: "link" # choose from ['fabric', 'device', device_interface', 'device_pair', 'link'] + switches: # provide the switch information to which the given resource is to be attached + - "{{ ansible_switch1 }}" + resource: "fe:80:0505::05/64" # The value of the resource being created + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'result.changed == true' + - '(result["diff"][0]["merged"] | length) == 8' + - '(result["diff"][0]["deleted"] | length) == 0' + - 'result["api_response"] is not defined or (result["api_response"] | length) == 1' + - 'result["api_result"] is not defined or result["api_result"][0]["success"] == true' + - 'result["api_diff"] is not defined or (result["api_diff"][0]["merged"] | length) == (result["diff"][0]["merged"] | length)' + - 'result["api_payload"] is not defined or (result["api_payload"][0]["resources"] | length) == (result["diff"][0]["merged"] | length)' + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: '{{ result.api_response | default([]) }}' + + ############################################## + ## GATHERED ## + ############################################## + + - name: SANITY- Gathered all Resources - false filters + cisco.nd.nd_manage_resource_manager: + state: gathered + fabric: "{{ ansible_it_fabric }}" + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - '(result["gathered"] | length) != 0' + + - name: SANITY- Creating entity_names + ansible.builtin.set_fact: + entity_names: + - "l3_vni_fabric" + - "l2_vni_fabric" + - "loopback_dev" + - "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" + - "{{ ansible_sno_2 }}~{{ ansible_sno_1 }}" + - "{{ ansible_sno_1 }}~{{ intf_1_10 }}" + - "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" + - "{{ ansible_sno_2 }}~{{ intf_1_3 }}~{{ ansible_sno_1 }}~{{ intf_1_3 }}" + - "mmudigon-2" + + - name: SANITY- Gathered Resources - filter by entity name + cisco.nd.nd_manage_resource_manager: + state: gathered # choose form [merged, deleted, gathered] + fabric: "{{ ansible_it_fabric }}" + config: + - entity_name: "l3_vni_fabric" # A unique name to identify the resource + - entity_name: "l2_vni_fabric" # A unique name to identify the resource + - entity_name: "loopback_dev" # A unique name to identify the resource + - entity_name: "{{ ansible_sno_1 }}~{{ ansible_sno_2 }}" # A unique name to identify the resource + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_10 }}" # A unique name to identify the resource + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_2 }}~{{ ansible_sno_2 }}~{{ intf_1_2 }}" # A unique name to identify the resource + - entity_name: "mmudigon-2" # A unique name to identify the resource + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_2 }}" # A unique name to identify the resource + - entity_name: "{{ ansible_sno_1 }}~{{ intf_1_3 }}~{{ ansible_sno_2 }}~{{ intf_1_3 }}" # A unique name to identify the resource + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'item["entity_name"] in {{ entity_names }}' + loop: '{{ result.gathered }}' + + + ############################################## + ## CLEANUP ## + ############################################## + + always: + + - name: SANITY- Delete Resources + cisco.nd.nd_manage_resource_manager: *rm_delete + register: result + when: it_context is not defined + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'result.changed == true' + - '(result["diff"][0]["merged"] | length) == 0' + - '(result["diff"][0]["deleted"] | length) == 7' + - 'result["api_response"] is not defined or (result["api_response"] | length) == 1' + - 'result["api_result"] is not defined or result["api_result"][0]["success"] == true' + - 'result["api_diff"] is not defined or (result["api_diff"][0]["deleted"] | length) == (result["diff"][0]["deleted"] | length)' + - 'result["api_payload"] is not defined or (result["api_payload"][0]["resourceIds"] | length) == (result["diff"][0]["deleted"] | length)' + when: it_context is not defined + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: '{{ result.api_response | default([]) }}' + when: it_context is not defined diff --git a/tests/unit/module_utils/test_manage_resource_manager.py b/tests/unit/module_utils/test_manage_resource_manager.py new file mode 100644 index 000000000..6234e80cb --- /dev/null +++ b/tests/unit/module_utils/test_manage_resource_manager.py @@ -0,0 +1,314 @@ +# Copyright: (c) 2026, Jeet Ram (@jeeram) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for nd_manage_resource_manager module utilities. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import logging + +import pytest + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_resources import ( + EpManageFabricResourcesGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum, OperationType +from ansible_collections.cisco.nd.plugins.module_utils.manage_resource_manager.nd_manage_resource_manager_resources import ( + NDResourceManagerModule, + ResourceManagerDiffEngine, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_config_model import ( + ResourceManagerConfigModel, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_resource_manager.resource_manager_response_model import ( + ResourceManagerResponse, +) +from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput +from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results + +LOG = logging.getLogger("nd.tests.resource_manager") + + +def _config(**overrides): + """Build a valid merged config model with optional overrides.""" + data = { + "entity_name": "loopback0", + "pool_type": "ID", + "pool_name": "LOOPBACK_ID", + "scope_type": "device", + "switches": ["SER1"], + "resource": "10", + } + data.update(overrides) + return ResourceManagerConfigModel.model_validate(data, context={"state": "merged"}) + + +def _response(**overrides): + """Build a resource response model with optional overrides.""" + data = { + "resourceId": 101, + "entityName": "loopback0", + "poolName": "LOOPBACK_ID", + "resourceValue": "10", + "scopeDetails": { + "scopeType": "device", + "switchId": "SER1", + "switchIp": "192.0.2.10", + }, + } + data.update(overrides) + return ResourceManagerResponse.model_validate(data) + + +def _resource_manager(): + """Create a lightweight NDResourceManagerModule instance for helper tests.""" + module = object.__new__(NDResourceManagerModule) + module.fabric = "fabric-1" + module.log = LOG + return module + + +class _DummyAnsibleModule: + """Small AnsibleModule stand-in that captures exit_json payloads.""" + + def __init__(self, verbosity=0): + self.check_mode = False + self._verbosity = verbosity + self.exit_payload = None + + def exit_json(self, **kwargs): + self.exit_payload = kwargs + + +class _DummyND: + """Small NDModule stand-in for NDResourceManagerModule.exit_module tests.""" + + def __init__(self, ansible_module, output_level="normal"): + self.module = ansible_module + self.params = { + "output_level": output_level, + } + + +def _register_resource_manager_task(results): + """Register one API-shaped result for output normalization tests.""" + results.action = "manage_resource_manager" + results.operation_type = OperationType.CREATE + results.path_current = "/api/v1/manage/fabrics/fabric-1/resources" + results.verb_current = HttpVerbEnum.POST + results.payload_current = {"entityName": "loopback0"} + results.verbosity_level_current = 2 + results.response_current = {"RETURN_CODE": 200, "MESSAGE": "OK"} + results.result_current = {"success": True, "changed": True} + results.diff_current = {"before": {}, "after": {"entity_name": "loopback0"}} + results.register_api_call() + + +def _resource_manager_for_exit(verbosity=0): + """Create a lightweight resource manager with enough state for exit_module.""" + ansible_module = _DummyAnsibleModule(verbosity=verbosity) + module = _resource_manager() + module.nd = _DummyND(ansible_module) + module.results = Results() + module.results.state = "merged" + module.results.check_mode = False + _register_resource_manager_task(module.results) + module.state = "merged" + module.changed_dict = [{"merged": [], "deleted": [], "gathered": [], "debugs": []}] + module.existing = [] + module.previous = [] + module._proposed_list = [] + module.output = NDOutput("normal") + return module, ansible_module + + +def test_resource_manager_config_rejects_unknown_id_pool_name(): + """Unknown ID pool names remain invalid for modifying states.""" + with pytest.raises(Exception, match="pool_name 'WRONG_POOL' is not valid"): + ResourceManagerConfigModel.model_validate( + { + "entity_name": "bad", + "pool_type": "ID", + "pool_name": "WRONG_POOL", + "scope_type": "fabric", + "resource": "10", + }, + context={"state": "merged"}, + ) + + +def test_resource_manager_config_allows_partial_gathered_filter(): + """Gathered filters may provide partial criteria without switches.""" + model = ResourceManagerConfigModel.model_validate({"scope_type": "device"}, context={"state": "gathered"}) + assert model.scope_type == "device" + assert model.switches is None + + +@pytest.mark.parametrize( + ("config", "expected_message"), + [ + ([{"entity_name": "l3_vni_fabric"}], "Mandatory parameter 'scope_type' missing"), + ([{"scope_type": "fabric"}], "Mandatory parameter 'pool_type' missing"), + ([{"entity_name": "l3_vni_fabric", "pool_type": "ID", "scope_type": "fabric"}], "Mandatory parameter 'pool_name' missing"), + ([{"pool_type": "ID", "pool_name": "VPC_ID", "scope_type": "fabric"}], "Mandatory parameter 'entity_name' missing"), + ( + [{"entity_name": "SER1~SER2", "pool_type": "ID", "pool_name": "VPC_ID", "scope_type": "device_pair"}], + "switches : Required parameter not found", + ), + ], +) +def test_resource_manager_validate_input_preserves_legacy_missing_param_messages(config, expected_message): + """Missing-field validation keeps integration-compatible error messages.""" + module = _resource_manager() + module.state = "deleted" + module.config = config + + with pytest.raises(ValueError, match=expected_message): + module._validate_input() # pylint: disable=protected-access + + +def test_resource_manager_validate_configs_rejects_duplicate_entries(): + """Duplicate desired resources are rejected before diffing.""" + data = { + "entity_name": "loopback0", + "pool_type": "ID", + "pool_name": "LOOPBACK_ID", + "scope_type": "device", + "switches": ["SER1"], + "resource": "10", + } + with pytest.raises(ValueError, match="Duplicate config entries"): + ResourceManagerDiffEngine.validate_configs([data, data], "merged", log=LOG) + + +def test_resource_manager_diff_detects_idempotent_resource(): + """Diffing matches existing resources by normalized identity and switch ID.""" + changes = ResourceManagerDiffEngine.compute_changes([_config()], [_response()], log=LOG) + + assert len(changes["idempotent"]) == 1 + assert changes["to_add"] == [] + assert changes["to_update"] == [] + + +def test_resource_manager_diff_accepts_raw_dict_existing_resource(): + """Diffing also handles raw dict resources retained after response parsing failures.""" + raw_resource = { + "resourceId": 101, + "entityName": "loopback0", + "poolName": "LOOPBACK_ID", + "resourceValue": "10", + "scopeDetails": { + "scopeType": "device", + "switchId": "SER1", + }, + } + + changes = ResourceManagerDiffEngine.compute_changes([_config()], [raw_resource], log=LOG) + + assert len(changes["idempotent"]) == 1 + assert changes["to_add"] == [] + + +def test_resource_manager_builds_link_create_payload(): + """Payload building fills all link scopeDetails fields from entity_name.""" + module = _resource_manager() + cfg = _config( + entity_name="SER1~Ethernet1/1~SER2~Ethernet1/2", + pool_type="SUBNET", + pool_name="SUBNET", + scope_type="link", + resource="10.0.0.0/30", + ) + + payload = module._build_create_payload(cfg, switch_ip="SER1") # pylint: disable=protected-access + + assert payload["entityName"] == "SER1~Ethernet1/1~SER2~Ethernet1/2" + assert payload["resourceValue"] == "10.0.0.0/30" + assert payload["scopeDetails"]["scopeType"] == "link" + assert payload["scopeDetails"]["srcSwitchId"] == "SER1" + assert payload["scopeDetails"]["dstSwitchId"] == "SER2" + assert payload["scopeDetails"]["srcInterfaceName"] == "Ethernet1/1" + assert payload["scopeDetails"]["dstInterfaceName"] == "Ethernet1/2" + + +def test_resource_manager_gathered_filter_matches_switch_id_and_translates_switch_ip(): + """Gathered switch filters match switchId while output keeps switchIp.""" + module = _resource_manager() + module.config = [{"pool_name": "LOOPBACK_ID", "switches": ["SER1"]}] + module._all_resources = [ # pylint: disable=protected-access + _response(), + _response( + resourceId=102, + entityName="loopback1", + resourceValue="11", + scopeDetails={ + "scopeType": "device", + "switchId": "SER2", + "switchIp": "192.0.2.11", + }, + ), + ] + + gathered = module._apply_gathered_filters() # pylint: disable=protected-access + + assert gathered == [ + { + "entity_name": "loopback0", + "pool_type": "ID", + "pool_name": "LOOPBACK_ID", + "scope_type": "device", + "resource": "10", + "switches": ["192.0.2.10"], + } + ] + + +def test_manage_fabric_resources_get_endpoint_path_and_class_name(): + """Resources GET endpoint has the correct class name, verb, and query path.""" + endpoint = EpManageFabricResourcesGet(fabric_name="fabric-1") + endpoint.endpoint_params.pool_name = "LOOPBACK_ID" + + assert endpoint.class_name == "EpManageFabricResourcesGet" + assert endpoint.verb == HttpVerbEnum.GET + assert endpoint.path == "/api/v1/manage/fabrics/fabric-1/resources?poolName=LOOPBACK_ID" + + +def test_resource_manager_exit_module_normal_output_omits_raw_results_keys(): + """Normal output does not expose raw Results aggregation keys.""" + module, ansible_module = _resource_manager_for_exit(verbosity=0) + + module.exit_module() + + for key in ("metadata", "path", "payload", "response", "result", "verb", "verbosity_level"): + assert key not in ansible_module.exit_payload + assert "api_paths" not in ansible_module.exit_payload + assert ansible_module.exit_payload["changed"] is False + + +def test_resource_manager_exit_module_verbosity_2_omits_api_keys(): + """-vv output exposes path/verb summary but not full controller detail.""" + module, ansible_module = _resource_manager_for_exit(verbosity=2) + + module.exit_module() + + assert ansible_module.exit_payload["api_paths"] == ["/api/v1/manage/fabrics/fabric-1/resources"] + assert ansible_module.exit_payload["api_verbs"] == ["POST"] + for key in ("api_payload", "api_response", "api_result", "api_diff", "api_metadata"): + assert key not in ansible_module.exit_payload + + +def test_resource_manager_exit_module_verbose_output_uses_api_keys_only(): + """-vvv output exposes API details only through normalized api_* keys.""" + module, ansible_module = _resource_manager_for_exit(verbosity=3) + + module.exit_module() + + for key in ("metadata", "path", "payload", "response", "result", "verb", "verbosity_level"): + assert key not in ansible_module.exit_payload + assert ansible_module.exit_payload["api_paths"] == ["/api/v1/manage/fabrics/fabric-1/resources"] + assert ansible_module.exit_payload["api_verbs"] == ["POST"] + assert ansible_module.exit_payload["api_payload"] == [{"entityName": "loopback0"}]