From b00aebea1eeb99f59aae0c8011e65d0df95f200e Mon Sep 17 00:00:00 2001 From: AKDRG Date: Wed, 11 Mar 2026 21:35:19 +0530 Subject: [PATCH 01/27] Initial Commit : ND Manage Switches ( Smart Endpoints + Pydantic Models + Module ) --- .../nd_manage_switches/manage_credentials.py | 153 + .../manage_fabric_bootstrap.py | 151 + .../manage_fabric_config.py | 303 ++ .../manage_fabric_discovery.py | 90 + .../manage_fabric_switch_actions.py | 754 +++++ .../manage_fabric_switches.py | 290 ++ plugins/module_utils/models/__init__.py | 1 + .../models/nd_manage_switches/__init__.py | 143 + .../nd_manage_switches/bootstrap_models.py | 388 +++ .../nd_manage_switches/config_models.py | 654 +++++ .../nd_manage_switches/discovery_models.py | 268 ++ .../models/nd_manage_switches/enums.py | 320 ++ .../nd_manage_switches/preprovision_models.py | 218 ++ .../models/nd_manage_switches/rma_models.py | 258 ++ .../switch_actions_models.py | 116 + .../nd_manage_switches/switch_data_models.py | 488 +++ .../models/nd_manage_switches/validators.py | 115 + plugins/module_utils/nd_switch_resources.py | 2611 +++++++++++++++++ .../utils/nd_manage_switches/__init__.py | 52 + .../nd_manage_switches/bootstrap_utils.py | 111 + .../utils/nd_manage_switches/exceptions.py | 20 + .../utils/nd_manage_switches/fabric_utils.py | 177 ++ .../utils/nd_manage_switches/payload_utils.py | 90 + .../nd_manage_switches/switch_helpers.py | 138 + .../nd_manage_switches/switch_wait_utils.py | 593 ++++ plugins/modules/nd_manage_switches.py | 622 ++++ 26 files changed, 9124 insertions(+) create mode 100644 plugins/module_utils/endpoints/v1/nd_manage_switches/manage_credentials.py create mode 100644 plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_bootstrap.py create mode 100644 plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_config.py create mode 100644 plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_discovery.py create mode 100644 plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switch_actions.py create mode 100644 plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switches.py create mode 100644 plugins/module_utils/models/__init__.py create mode 100644 plugins/module_utils/models/nd_manage_switches/__init__.py create mode 100644 plugins/module_utils/models/nd_manage_switches/bootstrap_models.py create mode 100644 plugins/module_utils/models/nd_manage_switches/config_models.py create mode 100644 plugins/module_utils/models/nd_manage_switches/discovery_models.py create mode 100644 plugins/module_utils/models/nd_manage_switches/enums.py create mode 100644 plugins/module_utils/models/nd_manage_switches/preprovision_models.py create mode 100644 plugins/module_utils/models/nd_manage_switches/rma_models.py create mode 100644 plugins/module_utils/models/nd_manage_switches/switch_actions_models.py create mode 100644 plugins/module_utils/models/nd_manage_switches/switch_data_models.py create mode 100644 plugins/module_utils/models/nd_manage_switches/validators.py create mode 100644 plugins/module_utils/nd_switch_resources.py create mode 100644 plugins/module_utils/utils/nd_manage_switches/__init__.py create mode 100644 plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py create mode 100644 plugins/module_utils/utils/nd_manage_switches/exceptions.py create mode 100644 plugins/module_utils/utils/nd_manage_switches/fabric_utils.py create mode 100644 plugins/module_utils/utils/nd_manage_switches/payload_utils.py create mode 100644 plugins/module_utils/utils/nd_manage_switches/switch_helpers.py create mode 100644 plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py create mode 100644 plugins/modules/nd_manage_switches.py diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_credentials.py b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_credentials.py new file mode 100644 index 00000000..9007be8d --- /dev/null +++ b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_credentials.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Credentials endpoint models. + +This module contains endpoint definitions for switch credential operations +in the ND Manage API. + +Endpoints covered: +- List switch credentials +- Create switch credentials +- Remove switch credentials +- Validate switch credentials +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +__author__ = "Akshayanat Chengam Saravanan" +# 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.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) + +# Common config for basic validation +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class CredentialsSwitchesEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for credentials switches endpoint. + + ## Parameters + + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + params = CredentialsSwitchesEndpointParams(ticket_id="CHG12345") + query_string = params.to_query_string() + # Returns: "ticketId=CHG12345" + ``` + """ + + ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") + + +class _V1ManageCredentialsSwitchesBase(BaseModel): + """ + Base class for Credentials Switches endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/credentials/switches endpoint. + """ + + model_config = COMMON_CONFIG + + @property + def _base_path(self) -> str: + """Build the base endpoint path.""" + return BasePath.nd_manage("credentials", "switches") + + +class V1ManageCredentialsSwitchesPost(_V1ManageCredentialsSwitchesBase): + """ + # Summary + + Create Switch Credentials Endpoint + + ## Description + + Endpoint to save switch credentials for the user. + + ## Path + + - /api/v1/manage/credentials/switches + - /api/v1/manage/credentials/switches?ticketId=CHG12345 + + ## Verb + + - POST + + ## Query Parameters + + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + # Create credentials without ticket + request = V1ManageCredentialsSwitchesPost() + path = request.path + verb = request.verb + + # Create credentials with change control ticket + request = V1ManageCredentialsSwitchesPost() + request.endpoint_params.ticket_id = "CHG12345" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/credentials/switches?ticketId=CHG12345 + ``` + """ + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageCredentialsSwitchesPost"] = Field( + default="V1ManageCredentialsSwitchesPost", description="Class name for backward compatibility" + ) + endpoint_params: CredentialsSwitchesEndpointParams = Field( + default_factory=CredentialsSwitchesEndpointParams, 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 diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_bootstrap.py b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_bootstrap.py new file mode 100644 index 00000000..48212482 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_bootstrap.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Fabric Bootstrap endpoint models. + +This module contains endpoint definitions for switch bootstrap operations +within fabrics in the ND Manage API. + +Endpoints covered: +- List bootstrap switches (POAP/PnP) +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +__author__ = "Akshayanat Chengam Saravanan" +# 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 ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) + +# Common config for basic validation +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class FabricBootstrapEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for fabric bootstrap endpoint. + + ## Parameters + + - max: Maximum number of results to return (optional) + - offset: Pagination offset (optional) + - filter: Lucene filter expression (optional) + + ## Usage + + ```python + params = FabricBootstrapEndpointParams(max=50, offset=0) + query_string = params.to_query_string() + # Returns: "max=50&offset=0" + ``` + """ + + max: Optional[int] = Field(default=None, ge=1, description="Maximum number of results") + offset: Optional[int] = Field(default=None, ge=0, description="Pagination offset") + filter: Optional[str] = Field(default=None, min_length=1, description="Lucene filter expression") + + +class V1ManageFabricBootstrapGet(FabricNameMixin, BaseModel): + """ + # Summary + + List Bootstrap Switches Endpoint + + ## Description + + Endpoint to list switches currently going through bootstrap loop via POAP (NX-OS) or PnP (IOS-XE). + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/bootstrap + - /api/v1/manage/fabrics/{fabricName}/bootstrap?max=50&offset=0 + + ## Verb + + - GET + + ## Query Parameters + + - max: Maximum number of results (optional) + - offset: Pagination offset (optional) + - filter: Lucene filter expression (optional) + + ## Usage + + ```python + # List all bootstrap switches + request = V1ManageFabricBootstrapGet() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # List with pagination + request = V1ManageFabricBootstrapGet() + request.fabric_name = "MyFabric" + request.endpoint_params.max = 50 + request.endpoint_params.offset = 0 + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/bootstrap?max=50&offset=0 + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricBootstrapGet"] = Field( + default="V1ManageFabricBootstrapGet", description="Class name for backward compatibility" + ) + endpoint_params: FabricBootstrapEndpointParams = Field( + default_factory=FabricBootstrapEndpointParams, 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 + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + base_path = BasePath.nd_manage("fabrics", self.fabric_name, "bootstrap") + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_config.py b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_config.py new file mode 100644 index 00000000..bb037e1e --- /dev/null +++ b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_config.py @@ -0,0 +1,303 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Fabric Config endpoint models. + +This module contains endpoint definitions for fabric configuration operations +in the ND Manage API. + +Endpoints covered: +- Config save (recalculate) +- Config deploy +- Get fabric info +- Inventory discover status +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +__author__ = "Akshayanat Chengam Saravanan" +# 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 ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) + +# Common config for basic validation +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class FabricConfigDeployEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for fabric config deploy endpoint. + + ## Parameters + + - force_show_run: Force show running config before deploy (optional) + - incl_all_msd_switches: Include all MSD fabric switches (optional) + + ## Usage + + ```python + params = FabricConfigDeployEndpointParams(force_show_run=True) + query_string = params.to_query_string() + # Returns: "forceShowRun=true" + ``` + """ + + force_show_run: Optional[bool] = Field(default=None, description="Force show running config before deploy") + incl_all_msd_switches: Optional[bool] = Field(default=None, description="Include all MSD fabric switches") + + +class V1ManageFabricConfigSavePost(FabricNameMixin, BaseModel): + """ + # Summary + + Fabric Config Save Endpoint + + ## Description + + Endpoint to save (recalculate) fabric configuration. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/actions/configSave + + ## Verb + + - POST + + ## Usage + + ```python + request = V1ManageFabricConfigSavePost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricConfigSavePost"] = Field( + default="V1ManageFabricConfigSavePost", description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """Build the endpoint path.""" + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + return BasePath.nd_manage("fabrics", self.fabric_name, "actions", "configSave") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +class V1ManageFabricConfigDeployPost(FabricNameMixin, BaseModel): + """ + # Summary + + Fabric Config Deploy Endpoint + + ## Description + + Endpoint to deploy pending configuration to switches in a fabric. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/actions/configDeploy + - /api/v1/manage/fabrics/{fabricName}/actions/configDeploy?forceShowRun=true + + ## Verb + + - POST + + ## Query Parameters + + - force_show_run: Force show running config before deploy (optional) + - incl_all_msd_switches: Include all MSD fabric switches (optional) + + ## Usage + + ```python + # Deploy with defaults + request = V1ManageFabricConfigDeployPost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # Deploy forcing show run + request = V1ManageFabricConfigDeployPost() + request.fabric_name = "MyFabric" + request.endpoint_params.force_show_run = True + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/actions/configDeploy?forceShowRun=true + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricConfigDeployPost"] = Field( + default="V1ManageFabricConfigDeployPost", description="Class name for backward compatibility" + ) + endpoint_params: FabricConfigDeployEndpointParams = Field( + default_factory=FabricConfigDeployEndpointParams, 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 + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + base_path = BasePath.nd_manage("fabrics", self.fabric_name, "actions", "configDeploy") + 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 + + +class V1ManageFabricGet(FabricNameMixin, BaseModel): + """ + # Summary + + Get Fabric Info Endpoint + + ## Description + + Endpoint to retrieve fabric information. + + ## Path + + - /api/v1/manage/fabrics/{fabricName} + + ## Verb + + - GET + + ## Usage + + ```python + request = V1ManageFabricGet() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricGet"] = Field( + default="V1ManageFabricGet", description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """Build the endpoint path.""" + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + return BasePath.nd_manage("fabrics", self.fabric_name) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class V1ManageFabricInventoryDiscoverGet(FabricNameMixin, BaseModel): + """ + # Summary + + Fabric Inventory Discover Endpoint + + ## Description + + Endpoint to get discovery status for switches in a fabric. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/inventory/discover + + ## Verb + + - GET + + ## Usage + + ```python + request = V1ManageFabricInventoryDiscoverGet() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricInventoryDiscoverGet"] = Field( + default="V1ManageFabricInventoryDiscoverGet", description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """Build the endpoint path.""" + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + return BasePath.nd_manage("fabrics", self.fabric_name, "inventory", "discover") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_discovery.py b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_discovery.py new file mode 100644 index 00000000..d7d2e1f2 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_discovery.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Fabric Discovery endpoint models. + +This module contains endpoint definitions for switch discovery operations +within fabrics in the ND Manage API. + +Endpoints covered: +- Shallow discovery +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +__author__ = "Akshayanat Chengam Saravanan" +# pylint: enable=invalid-name + +from typing import Literal + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) + +# Common config for basic validation +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class V1ManageFabricShallowDiscoveryPost(FabricNameMixin, BaseModel): + """ + # Summary + + Shallow Discovery Endpoint + + ## Description + + Endpoint to shallow discover switches given seed switches with hop count. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/actions/shallowDiscovery + + ## Verb + + - POST + + ## Usage + + ```python + request = V1ManageFabricShallowDiscoveryPost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricShallowDiscoveryPost"] = Field( + default="V1ManageFabricShallowDiscoveryPost", description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """Build the endpoint path.""" + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + return BasePath.nd_manage("fabrics", self.fabric_name, "actions", "shallowDiscovery") + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switch_actions.py b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switch_actions.py new file mode 100644 index 00000000..73aa93ea --- /dev/null +++ b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switch_actions.py @@ -0,0 +1,754 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Fabric Switch Actions endpoint models. + +This module contains endpoint definitions for switch action operations +within fabrics in the ND Manage API. + +Endpoints covered: +- Remove switches (bulk delete) +- Change switch roles (bulk) +- Import bootstrap (POAP) +- Pre-provision switches +- Provision RMA +- Change switch serial number +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +__author__ = "Akshayanat Chengam Saravanan" +# 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 ( + FabricNameMixin, + SwitchSerialNumberMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) + +# Common config for basic validation +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +# ============================================================================ +# Endpoint-specific query parameter classes +# ============================================================================ + + +class SwitchActionsRemoveEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for switch actions remove endpoint. + + ## Parameters + + - force: Force removal even if switches have pending operations (optional) + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + params = SwitchActionsRemoveEndpointParams(force=True, ticket_id="CHG12345") + query_string = params.to_query_string() + # Returns: "force=true&ticketId=CHG12345" + ``` + """ + + force: Optional[bool] = Field(default=None, description="Force removal of switches") + ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") + + +class SwitchActionsTicketEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for switch action endpoints that accept a ticket ID. + + ## Parameters + + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + params = SwitchActionsTicketEndpointParams(ticket_id="CHG12345") + query_string = params.to_query_string() + # Returns: "ticketId=CHG12345" + ``` + """ + + ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") + + +class SwitchActionsImportEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for switch import/provision endpoints. + + ## Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional) + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + params = SwitchActionsImportEndpointParams(cluster_name="cluster1", ticket_id="CHG12345") + query_string = params.to_query_string() + # Returns: "clusterName=cluster1&ticketId=CHG12345" + ``` + """ + + cluster_name: Optional[str] = Field(default=None, min_length=1, description="Target cluster name") + ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") + + +# ============================================================================ +# Switch Actions Endpoints +# ============================================================================ + + +class V1ManageFabricSwitchActionsRemovePost(FabricNameMixin, BaseModel): + """ + # Summary + + Remove Switches Endpoint (Bulk Delete) + + ## Description + + Endpoint to delete multiple switches from a fabric. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switchActions/remove + - /api/v1/manage/fabrics/{fabricName}/switchActions/remove?force=true&ticketId=CHG12345 + + ## Verb + + - POST + + ## Query Parameters + + - force: Force removal even if switches have pending operations (optional) + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + # Remove switches + request = V1ManageFabricSwitchActionsRemovePost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # Remove switches with force and ticket + request = V1ManageFabricSwitchActionsRemovePost() + request.fabric_name = "MyFabric" + request.endpoint_params.force = True + request.endpoint_params.ticket_id = "CHG12345" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switchActions/remove?force=true&ticketId=CHG12345 + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricSwitchActionsRemovePost"] = Field( + default="V1ManageFabricSwitchActionsRemovePost", description="Class name for backward compatibility" + ) + endpoint_params: SwitchActionsRemoveEndpointParams = Field( + default_factory=SwitchActionsRemoveEndpointParams, 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 + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + base_path = BasePath.nd_manage("fabrics", self.fabric_name, "switchActions", "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 + + +class V1ManageFabricSwitchActionsChangeRolesPost(FabricNameMixin, BaseModel): + """ + # Summary + + Change Switch Roles Endpoint (Bulk) + + ## Description + + Endpoint to change the role of multiple switches in a single request. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switchActions/changeRoles + - /api/v1/manage/fabrics/{fabricName}/switchActions/changeRoles?ticketId=CHG12345 + + ## Verb + + - POST + + ## Query Parameters + + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + # Change roles + request = V1ManageFabricSwitchActionsChangeRolesPost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # Change roles with change control ticket + request = V1ManageFabricSwitchActionsChangeRolesPost() + request.fabric_name = "MyFabric" + request.endpoint_params.ticket_id = "CHG12345" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switchActions/changeRoles?ticketId=CHG12345 + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricSwitchActionsChangeRolesPost"] = Field( + default="V1ManageFabricSwitchActionsChangeRolesPost", + description="Class name for backward compatibility", + ) + endpoint_params: SwitchActionsTicketEndpointParams = Field( + default_factory=SwitchActionsTicketEndpointParams, 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 + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + base_path = BasePath.nd_manage("fabrics", self.fabric_name, "switchActions", "changeRoles") + 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 + + +class V1ManageFabricSwitchActionsImportBootstrapPost(FabricNameMixin, BaseModel): + """ + # Summary + + Import Bootstrap Switches Endpoint + + ## Description + + Endpoint to import and bootstrap preprovision or bootstrap switches to a fabric. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switchActions/importBootstrap + - /api/v1/manage/fabrics/{fabricName}/switchActions/importBootstrap?clusterName=cluster1&ticketId=CHG12345 + + ## Verb + + - POST + + ## Query Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional) + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + # Import bootstrap switches + request = V1ManageFabricSwitchActionsImportBootstrapPost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # Import with cluster and ticket + request = V1ManageFabricSwitchActionsImportBootstrapPost() + request.fabric_name = "MyFabric" + request.endpoint_params.cluster_name = "cluster1" + request.endpoint_params.ticket_id = "CHG12345" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switchActions/importBootstrap?clusterName=cluster1&ticketId=CHG12345 + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricSwitchActionsImportBootstrapPost"] = Field( + default="V1ManageFabricSwitchActionsImportBootstrapPost", description="Class name for backward compatibility" + ) + endpoint_params: SwitchActionsImportEndpointParams = Field( + default_factory=SwitchActionsImportEndpointParams, 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 + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + base_path = BasePath.nd_manage("fabrics", self.fabric_name, "switchActions", "importBootstrap") + 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 + + +# ============================================================================ +# Pre-Provision Endpoints +# ============================================================================ + + +class V1ManageFabricSwitchActionsPreProvisionPost(FabricNameMixin, BaseModel): + """ + # Summary + + Pre-Provision Switches Endpoint + + ## Description + + Endpoint to pre-provision switches in a fabric. Pre-provisioning allows + you to define switch parameters (serial, IP, model, etc.) ahead of time + so that when the physical device boots it is automatically absorbed into + the fabric. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switchActions/preProvision + - /api/v1/manage/fabrics/{fabricName}/switchActions/preProvision?clusterName=cluster1&ticketId=CHG12345 + + ## Verb + + - POST + + ## Query Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional) + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + # Pre-provision switches + request = V1ManageFabricSwitchActionsPreProvisionPost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # Pre-provision with cluster and ticket + request = V1ManageFabricSwitchActionsPreProvisionPost() + request.fabric_name = "MyFabric" + request.endpoint_params.cluster_name = "cluster1" + request.endpoint_params.ticket_id = "CHG12345" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switchActions/preProvision?clusterName=cluster1&ticketId=CHG12345 + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricSwitchActionsPreProvisionPost"] = Field( + default="V1ManageFabricSwitchActionsPreProvisionPost", + description="Class name for backward compatibility", + ) + endpoint_params: SwitchActionsImportEndpointParams = Field( + default_factory=SwitchActionsImportEndpointParams, 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 + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + base_path = BasePath.nd_manage("fabrics", self.fabric_name, "switchActions", "preProvision") + 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 + + +# ============================================================================ +# RMA (Return Material Authorization) Endpoints +# ============================================================================ + + +class V1ManageFabricSwitchProvisionRMAPost(FabricNameMixin, SwitchSerialNumberMixin, BaseModel): + """ + # Summary + + Provision RMA for Switch Endpoint + + ## Description + + Endpoint to RMA (Return Material Authorization) an existing switch with a new bootstrapped switch. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/provisionRMA + - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/provisionRMA?ticketId=CHG12345 + + ## Verb + + - POST + + ## Query Parameters + + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + # Provision RMA + request = V1ManageFabricSwitchProvisionRMAPost() + request.fabric_name = "MyFabric" + request.switch_sn = "SAL1948TRTT" + path = request.path + verb = request.verb + + # Provision RMA with change control ticket + request = V1ManageFabricSwitchProvisionRMAPost() + request.fabric_name = "MyFabric" + request.switch_sn = "SAL1948TRTT" + request.endpoint_params.ticket_id = "CHG12345" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switches/SAL1948TRTT/actions/provisionRMA?ticketId=CHG12345 + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricSwitchProvisionRMAPost"] = Field( + default="V1ManageFabricSwitchProvisionRMAPost", description="Class name for backward compatibility" + ) + endpoint_params: SwitchActionsTicketEndpointParams = Field( + default_factory=SwitchActionsTicketEndpointParams, 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 + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + if self.switch_sn is None: + raise ValueError("switch_sn must be set before accessing path") + base_path = BasePath.nd_manage( + "fabrics", self.fabric_name, "switches", self.switch_sn, "actions", "provisionRMA" + ) + 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 + + +# ============================================================================ +# Change Switch Serial Number Endpoints +# ============================================================================ + + +class SwitchActionsClusterEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for switch action endpoints that accept only a cluster name. + + ## Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional) + + ## Usage + + ```python + params = SwitchActionsClusterEndpointParams(cluster_name="cluster1") + query_string = params.to_query_string() + # Returns: "clusterName=cluster1" + ``` + """ + + cluster_name: Optional[str] = Field(default=None, min_length=1, description="Target cluster name") + + +class V1ManageFabricSwitchChangeSerialNumberPost(FabricNameMixin, SwitchSerialNumberMixin, BaseModel): + """ + # Summary + + Change Switch Serial Number Endpoint + + ## Description + + Endpoint to change the serial number for a pre-provisioned switch. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/changeSwitchSerialNumber + - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/changeSwitchSerialNumber?clusterName=cluster1 + + ## Verb + + - POST + + ## Query Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional) + + ## Usage + + ```python + # Change serial number + request = V1ManageFabricSwitchChangeSerialNumberPost() + request.fabric_name = "MyFabric" + request.switch_sn = "SAL1948TRTT" + path = request.path + verb = request.verb + + # Change serial number with cluster name + request = V1ManageFabricSwitchChangeSerialNumberPost() + request.fabric_name = "MyFabric" + request.switch_sn = "SAL1948TRTT" + request.endpoint_params.cluster_name = "cluster1" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switches/SAL1948TRTT/actions/changeSwitchSerialNumber?clusterName=cluster1 + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricSwitchChangeSerialNumberPost"] = Field( + default="V1ManageFabricSwitchChangeSerialNumberPost", description="Class name for backward compatibility" + ) + endpoint_params: SwitchActionsClusterEndpointParams = Field( + default_factory=SwitchActionsClusterEndpointParams, 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 + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + if self.switch_sn is None: + raise ValueError("switch_sn must be set before accessing path") + base_path = BasePath.nd_manage( + "fabrics", self.fabric_name, "switches", self.switch_sn, "actions", "changeSwitchSerialNumber" + ) + 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 + + +# ============================================================================ +# Rediscover Endpoints +# ============================================================================ + + +class V1ManageFabricSwitchActionsRediscoverPost(FabricNameMixin, BaseModel): + """ + # Summary + + Rediscover Switches Endpoint + + ## Description + + Endpoint to trigger rediscovery for one or more switches in a fabric. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switchActions/rediscover + - /api/v1/manage/fabrics/{fabricName}/switchActions/rediscover?ticketId=CHG12345 + + ## Verb + + - POST + + ## Query Parameters + + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + # Rediscover switches + request = V1ManageFabricSwitchActionsRediscoverPost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # Rediscover switches with change control ticket + request = V1ManageFabricSwitchActionsRediscoverPost() + request.fabric_name = "MyFabric" + request.endpoint_params.ticket_id = "CHG12345" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switchActions/rediscover?ticketId=CHG12345 + ``` + """ + + model_config = COMMON_CONFIG + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricSwitchActionsRediscoverPost"] = Field( + default="V1ManageFabricSwitchActionsRediscoverPost", + description="Class name for backward compatibility", + ) + endpoint_params: SwitchActionsTicketEndpointParams = Field( + default_factory=SwitchActionsTicketEndpointParams, 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 + """ + if self.fabric_name is None: + raise ValueError("fabric_name must be set before accessing path") + base_path = BasePath.nd_manage("fabrics", self.fabric_name, "switchActions", "rediscover") + 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/nd_manage_switches/manage_fabric_switches.py b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switches.py new file mode 100644 index 00000000..b771fb1d --- /dev/null +++ b/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switches.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@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 Chengam Saravanan" +# 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 ( + FabricNameMixin, + SwitchSerialNumberMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( + EndpointQueryParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( + BasePath, +) +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, +) + +# Common config for basic validation +COMMON_CONFIG = ConfigDict(validate_assignment=True) + + +class FabricSwitchesGetEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for list fabric switches endpoint. + + ## Parameters + + - hostname: Filter by switch hostname (optional) + - max: Maximum number of results (optional) + - offset: Pagination offset (optional) + - filter: Lucene filter expression (optional) + + ## 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") + max: Optional[int] = Field(default=None, ge=1, description="Maximum number of results") + offset: Optional[int] = Field(default=None, ge=0, description="Pagination offset") + filter: Optional[str] = Field(default=None, min_length=1, description="Lucene filter expression") + + +class FabricSwitchesAddEndpointParams(EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for add switches to fabric endpoint. + + ## Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional) + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + params = FabricSwitchesAddEndpointParams(cluster_name="cluster1", ticket_id="CHG12345") + query_string = params.to_query_string() + # Returns: "clusterName=cluster1&ticketId=CHG12345" + ``` + """ + + cluster_name: Optional[str] = Field(default=None, min_length=1, description="Target cluster name") + ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") + + +class _V1ManageFabricSwitchesBase(FabricNameMixin, BaseModel): + """ + Base class for Fabric Switches endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabricName}/switches endpoint. + """ + + model_config = COMMON_CONFIG + + @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.nd_manage("fabrics", self.fabric_name, "switches") + + +class V1ManageFabricSwitchesGet(_V1ManageFabricSwitchesBase): + """ + # 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 = V1ManageFabricSwitchesGet() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # List with filtering + request = V1ManageFabricSwitchesGet() + 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 + ``` + """ + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricSwitchesGet"] = Field( + default="V1ManageFabricSwitchesGet", 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 + + +class V1ManageFabricSwitchesPost(_V1ManageFabricSwitchesBase): + """ + # Summary + + Add Switches to Fabric Endpoint + + ## Description + + Endpoint to add switches to a specific fabric. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches + - /api/v1/manage/fabrics/{fabricName}/switches?clusterName=cluster1&ticketId=CHG12345 + + ## Verb + + - POST + + ## Query Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional) + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + # Add switches + request = V1ManageFabricSwitchesPost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # Add switches with cluster and ticket + request = V1ManageFabricSwitchesPost() + request.fabric_name = "MyFabric" + request.endpoint_params.cluster_name = "cluster1" + request.endpoint_params.ticket_id = "CHG12345" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switches?clusterName=cluster1&ticketId=CHG12345 + ``` + """ + + # Version metadata + api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") + min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") + + class_name: Literal["V1ManageFabricSwitchesPost"] = Field( + default="V1ManageFabricSwitchesPost", description="Class name for backward compatibility" + ) + endpoint_params: FabricSwitchesAddEndpointParams = Field( + default_factory=FabricSwitchesAddEndpointParams, 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 + + +class _V1ManageFabricSwitchBase(FabricNameMixin, SwitchSerialNumberMixin, BaseModel): + """ + Base class for single switch endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabricName}/switches/{switchSn} endpoint. + """ + + model_config = COMMON_CONFIG + + @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") + if self.switch_sn is None: + raise ValueError("switch_sn must be set before accessing path") + return BasePath.nd_manage("fabrics", self.fabric_name, "switches", self.switch_sn) diff --git a/plugins/module_utils/models/__init__.py b/plugins/module_utils/models/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/plugins/module_utils/models/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/plugins/module_utils/models/nd_manage_switches/__init__.py b/plugins/module_utils/models/nd_manage_switches/__init__.py new file mode 100644 index 00000000..6ddd6cd8 --- /dev/null +++ b/plugins/module_utils/models/nd_manage_switches/__init__.py @@ -0,0 +1,143 @@ +# -*- 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_switches models package. + +Re-exports all model classes, enums, and validators from their individual +modules so that consumers can import directly from the package: + + from .models.nd_manage_switches import SwitchConfigModel, SwitchRole, ... +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +# --- Enums --- +from .enums import ( # noqa: F401 + AdvisoryLevel, + AnomalyLevel, + ConfigSyncStatus, + DiscoveryStatus, + PlatformType, + RemoteCredentialStore, + SnmpV3AuthProtocol, + SwitchRole, + SystemMode, + VpcRole, +) + +# --- Validators --- +from .validators import SwitchValidators # noqa: F401 + +# --- Nested / shared models --- +from .switch_data_models import ( # noqa: F401 + AdditionalAciSwitchData, + AdditionalSwitchData, + Metadata, + SwitchMetadata, + TelemetryIpCollection, + VpcData, +) + +# --- Discovery models --- +from .discovery_models import ( # noqa: F401 + AddSwitchesRequestModel, + ShallowDiscoveryRequestModel, + SwitchDiscoveryModel, +) + +# --- Switch data models --- +from .switch_data_models import ( # noqa: F401 + SwitchDataModel, +) + +# --- Bootstrap models --- +from .bootstrap_models import ( # noqa: F401 + BootstrapBaseData, + BootstrapBaseModel, + BootstrapCredentialModel, + BootstrapImportSpecificModel, + BootstrapImportSwitchModel, + ImportBootstrapSwitchesRequestModel, +) + +# --- Preprovision models --- +from .preprovision_models import ( # noqa: F401 + PreProvisionSwitchesRequestModel, + PreProvisionSwitchModel, +) + +# --- RMA models --- +from .rma_models import ( # noqa: F401 + RMASpecificModel, + RMASwitchModel, +) + +# --- Switch actions models --- +from .switch_actions_models import ( # noqa: F401 + ChangeSwitchSerialNumberRequestModel, + SwitchCredentialsRequestModel, +) + +# --- Config / playbook models --- +from .config_models import ( # noqa: F401 + ConfigDataModel, + POAPConfigModel, + RMAConfigModel, + SwitchConfigModel, +) + + +__all__ = [ + # Enums + "AdvisoryLevel", + "AnomalyLevel", + "ConfigSyncStatus", + "DiscoveryStatus", + "PlatformType", + "RemoteCredentialStore", + "SnmpV3AuthProtocol", + "SwitchRole", + "SystemMode", + "VpcRole", + # Validators + "SwitchValidators", + # Nested models + "AdditionalAciSwitchData", + "AdditionalSwitchData", + "Metadata", + "SwitchMetadata", + "TelemetryIpCollection", + "VpcData", + # Discovery models + "AddSwitchesRequestModel", + "ShallowDiscoveryRequestModel", + "SwitchDiscoveryModel", + # Switch data models + "SwitchDataModel", + # Bootstrap models + "BootstrapBaseData", + "BootstrapBaseModel", + "BootstrapCredentialModel", + "BootstrapImportSpecificModel", + "BootstrapImportSwitchModel", + "ImportBootstrapSwitchesRequestModel", + # Preprovision models + "PreProvisionSwitchesRequestModel", + "PreProvisionSwitchModel", + # RMA models + "RMASpecificModel", + "RMASwitchModel", + # Switch actions models + "ChangeSwitchSerialNumberRequestModel", + "SwitchCredentialsRequestModel", + # Config models + "ConfigDataModel", + "POAPConfigModel", + "RMAConfigModel", + "SwitchConfigModel", +] diff --git a/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py b/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py new file mode 100644 index 00000000..864a8e25 --- /dev/null +++ b/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py @@ -0,0 +1,388 @@ +# -*- 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) + +"""Bootstrap (POAP) switch models for import operations. + +Based on OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from pydantic import Field, computed_field, field_validator, model_validator +from typing import Any, Dict, List, Optional, ClassVar, Literal +from typing_extensions import Self + +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 .enums import ( + RemoteCredentialStore, + SnmpV3AuthProtocol, + SwitchRole, +) +from .validators import SwitchValidators + + +class BootstrapBaseData(NDNestedModel): + """ + Device-reported data embedded in a bootstrap API entry. + """ + identifiers: ClassVar[List[str]] = [] + gateway_ip_mask: Optional[str] = Field( + default=None, + alias="gatewayIpMask", + description="Gateway IP address with mask" + ) + models: Optional[List[str]] = Field( + default=None, + description="Supported models for switch" + ) + + @field_validator('gateway_ip_mask', mode='before') + @classmethod + def validate_gateway(cls, v: Optional[str]) -> Optional[str]: + return SwitchValidators.validate_cidr(v) + + +class BootstrapBaseModel(NDBaseModel): + """ + Common hardware and policy properties shared across bootstrap, pre-provision, and RMA operations. + """ + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" + gateway_ip_mask: str = Field( + ..., + alias="gatewayIpMask", + description="Gateway IP address with mask" + ) + model: str = Field( + ..., + description="Model of the bootstrap switch" + ) + software_version: str = Field( + ..., + alias="softwareVersion", + description="Software version of the bootstrap switch" + ) + image_policy: Optional[str] = Field( + default=None, + alias="imagePolicy", + description="Image policy associated with the switch during bootstrap" + ) + switch_role: Optional[SwitchRole] = Field( + default=None, + alias="switchRole" + ) + data: Optional[BootstrapBaseData] = Field( + default=None, + description="Additional bootstrap data" + ) + + @field_validator('gateway_ip_mask', mode='before') + @classmethod + def validate_gateway(cls, v: str) -> str: + result = SwitchValidators.validate_cidr(v) + if result is None: + raise ValueError("gateway_ip_mask cannot be empty") + return result + + +class BootstrapCredentialModel(NDBaseModel): + """ + Credential properties for a switch bootstrap or pre-provision operation. + + When useNewCredentials is true, separate discovery credentials are used for + post-bootstrap switch discovery instead of the admin password. + """ + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" + exclude_from_diff: ClassVar[List[str]] = ["password", "discovery_password"] + password: str = Field( + ..., + description="Switch password to be set during bootstrap for admin user" + ) + discovery_auth_protocol: SnmpV3AuthProtocol = Field( + ..., + alias="discoveryAuthProtocol" + ) + use_new_credentials: bool = Field( + default=False, + alias="useNewCredentials", + description="If True, use discoveryUsername and discoveryPassword" + ) + discovery_username: Optional[str] = Field( + default=None, + alias="discoveryUsername", + description="Username to be used for switch discovery post bootstrap" + ) + discovery_password: Optional[str] = Field( + default=None, + alias="discoveryPassword", + description="Password associated with the corresponding switch discovery user" + ) + remote_credential_store: RemoteCredentialStore = Field( + default=RemoteCredentialStore.LOCAL, + alias="remoteCredentialStore", + description="Type of credential store for discovery credentials" + ) + remote_credential_store_key: Optional[str] = Field( + default=None, + alias="remoteCredentialStoreKey", + description="Remote credential store key for discovery credentials" + ) + + @model_validator(mode='after') + def validate_credentials(self) -> Self: + """Validate credential configuration logic.""" + if self.use_new_credentials: + if self.remote_credential_store == RemoteCredentialStore.CYBERARK: + if not self.remote_credential_store_key: + raise ValueError( + "remote_credential_store_key is required when " + "remote_credential_store is 'cyberark'" + ) + elif self.remote_credential_store == RemoteCredentialStore.LOCAL: + if not self.discovery_username or not self.discovery_password: + raise ValueError( + "discovery_username and discovery_password are required when " + "remote_credential_store is 'local' and use_new_credentials is True" + ) + return self + + +class BootstrapImportSpecificModel(NDBaseModel): + """ + Switch-identifying fields returned by the bootstrap GET API prior to import. + """ + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" + hostname: str = Field( + ..., + description="Hostname of the bootstrap switch" + ) + ip: str = Field( + ..., + description="IP address of the bootstrap switch" + ) + serial_number: str = Field( + ..., + alias="serialNumber", + description="Serial number of the bootstrap switch" + ) + in_inventory: bool = Field( + ..., + alias="inInventory", + description="True if the bootstrap switch is in inventory" + ) + public_key: str = Field( + ..., + alias="publicKey", + description="Public Key" + ) + finger_print: str = Field( + ..., + alias="fingerPrint", + description="Fingerprint" + ) + dhcp_bootstrap_ip: Optional[str] = Field( + default=None, + alias="dhcpBootstrapIp", + description="This is used for device day-0 bring-up when using inband reachability" + ) + seed_switch: bool = Field( + default=False, + alias="seedSwitch", + description="Use as seed switch" + ) + + @field_validator('hostname', mode='before') + @classmethod + def validate_host(cls, v: str) -> str: + result = SwitchValidators.validate_hostname(v) + if result is None: + raise ValueError("hostname cannot be empty") + return result + + @field_validator('ip', 'dhcp_bootstrap_ip', mode='before') + @classmethod + def validate_ip(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + return SwitchValidators.validate_ip_address(v) + + @field_validator('serial_number', mode='before') + @classmethod + def validate_serial(cls, v: str) -> str: + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("serial_number cannot be empty") + return result + + +class BootstrapImportSwitchModel(NDBaseModel): + """ + Request payload for importing a single POAP bootstrap switch into the fabric. + + Path: POST /fabrics/{fabricName}/switchActions/importBootstrap + """ + identifiers: ClassVar[List[str]] = ["serial_number"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + exclude_from_diff: ClassVar[List[str]] = ["password", "discovery_password"] + + serial_number: str = Field( + ..., + alias="serialNumber", + description="Serial number of the bootstrap switch" + ) + model: str = Field( + ..., + description="Model of the bootstrap switch" + ) + version: str = Field( + ..., + description="Software version of the bootstrap switch" + ) + hostname: str = Field( + ..., + description="Hostname of the bootstrap switch" + ) + ip_address: str = Field( + ..., + alias="ipAddress", + description="IP address of the bootstrap switch" + ) + password: str = Field( + ..., + description="Switch password to be set during bootstrap for admin user" + ) + discovery_auth_protocol: SnmpV3AuthProtocol = Field( + ..., + alias="discoveryAuthProtocol" + ) + discovery_username: Optional[str] = Field( + default=None, + alias="discoveryUsername" + ) + discovery_password: Optional[str] = Field( + default=None, + alias="discoveryPassword" + ) + data: Optional[Dict[str, Any]] = Field( + default=None, + description="Bootstrap configuration data block (gatewayIpMask, models)" + ) + fingerprint: str = Field( + default="", + description="SSH fingerprint from bootstrap GET API" + ) + public_key: str = Field( + default="", + alias="publicKey", + description="SSH public key from bootstrap GET API" + ) + re_add: bool = Field( + default=False, + alias="reAdd", + description="Re-add flag from bootstrap GET API" + ) + in_inventory: bool = Field( + default=False, + alias="inInventory" + ) + image_policy: Optional[str] = Field( + default=None, + alias="imagePolicy", + description="Image policy associated with the switch during bootstrap" + ) + switch_role: Optional[SwitchRole] = Field( + default=None, + alias="switchRole" + ) + ip: Optional[str] = Field( + default=None, + description="IP address (duplicate of ipAddress for API compatibility)" + ) + software_version: Optional[str] = Field( + default=None, + alias="softwareVersion", + description="Software version (duplicate of version for API compatibility)" + ) + gateway_ip_mask: Optional[str] = Field( + default=None, + alias="gatewayIpMask", + description="Gateway IP address with mask" + ) + + @field_validator('ip_address', mode='before') + @classmethod + def validate_ip_address(cls, v: str) -> str: + result = SwitchValidators.validate_ip_address(v) + if result is None: + raise ValueError(f"Invalid IP address: {v}") + return result + + @field_validator('hostname', mode='before') + @classmethod + def validate_host(cls, v: str) -> str: + result = SwitchValidators.validate_hostname(v) + if result is None: + raise ValueError("hostname cannot be empty") + return result + + @field_validator('serial_number', mode='before') + @classmethod + def validate_serial(cls, v: str) -> str: + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("serial_number cannot be empty") + return result + + @computed_field(alias="useNewCredentials") + @property + def use_new_credentials(self) -> bool: + """Derive useNewCredentials from discoveryUsername and discoveryPassword.""" + return bool(self.discovery_username and self.discovery_password) + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format matching importBootstrap spec.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create model instance from API response.""" + return cls.model_validate(response) + + +class ImportBootstrapSwitchesRequestModel(NDBaseModel): + """ + Request body wrapping a list of bootstrap switch payloads for bulk POAP import. + + Path: POST /fabrics/{fabricName}/switchActions/importBootstrap + """ + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" + switches: List[BootstrapImportSwitchModel] = Field( + ..., + description="PowerOn Auto Provisioning switches" + ) + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return { + "switches": [s.to_payload() for s in self.switches] + } + + +__all__ = [ + "BootstrapBaseData", + "BootstrapBaseModel", + "BootstrapCredentialModel", + "BootstrapImportSpecificModel", + "BootstrapImportSwitchModel", + "ImportBootstrapSwitchesRequestModel", +] diff --git a/plugins/module_utils/models/nd_manage_switches/config_models.py b/plugins/module_utils/models/nd_manage_switches/config_models.py new file mode 100644 index 00000000..1ed4aa72 --- /dev/null +++ b/plugins/module_utils/models/nd_manage_switches/config_models.py @@ -0,0 +1,654 @@ +# -*- 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) + +"""Ansible playbook configuration models. + +These models represent the user-facing configuration schema used in Ansible +playbooks for normal switch addition, POAP, and RMA operations. + +Based on: dcnm_inventory.py config suboptions +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ipaddress import ip_address, ip_interface +from pydantic import Field, ValidationInfo, computed_field, field_validator, model_validator +from typing import Any, Dict, List, Optional, ClassVar, Literal, Union +from typing_extensions import Self + +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 .enums import ( + PlatformType, + SnmpV3AuthProtocol, + SwitchRole, +) +from .validators import SwitchValidators + + +class ConfigDataModel(NDNestedModel): + """ + Hardware and gateway network data required for POAP and RMA operations. + + Maps to config.poap.config_data and config.rma.config_data in the playbook. + """ + identifiers: ClassVar[List[str]] = [] + + models: List[str] = Field( + ..., + alias="models", + min_length=1, + description="List of model of modules in switch to Bootstrap/Pre-provision/RMA" + ) + gateway: str = Field( + ..., + description="Gateway IP with mask for the switch (e.g., 192.168.0.1/24)" + ) + + @field_validator('gateway', mode='before') + @classmethod + def validate_gateway(cls, v: str) -> str: + """Validate gateway is a valid CIDR.""" + if not v or not v.strip(): + raise ValueError("gateway cannot be empty") + try: + ip_interface(v.strip()) + except ValueError as e: + raise ValueError(f"Invalid gateway IP address with mask: {v}") from e + return v.strip() + + +class POAPConfigModel(NDNestedModel): + """ + POAP configuration entry for a single switch in the playbook config list. + + Supports Bootstrap (serial_number only), Pre-provision (preprovision_serial only), + and Swap (both serial fields) operation modes. + """ + identifiers: ClassVar[List[str]] = [] + + # Discovery credentials + discovery_username: Optional[str] = Field( + default=None, + alias="discoveryUsername", + description="Username for device discovery during POAP" + ) + discovery_password: Optional[str] = Field( + default=None, + alias="discoveryPassword", + description="Password for device discovery during POAP" + ) + + # Bootstrap operation - requires actual switch serial number + serial_number: Optional[str] = Field( + default=None, + alias="serialNumber", + min_length=1, + description="Serial number of switch to Bootstrap" + ) + + # Pre-provision operation - requires pre-provision serial number + preprovision_serial: Optional[str] = Field( + default=None, + alias="preprovisionSerial", + min_length=1, + description="Serial number of switch to Pre-provision" + ) + + # Common fields for both operations + model: Optional[str] = Field( + default=None, + description="Model of switch to Bootstrap/Pre-provision" + ) + version: Optional[str] = Field( + default=None, + description="Software version of switch to Bootstrap/Pre-provision" + ) + hostname: Optional[str] = Field( + default=None, + description="Hostname of switch to Bootstrap/Pre-provision" + ) + image_policy: Optional[str] = Field( + default=None, + alias="imagePolicy", + description="Name of the image policy to be applied on switch" + ) + config_data: Optional[ConfigDataModel] = Field( + default=None, + alias="configData", + description=( + "Basic config data of switch to Bootstrap/Pre-provision. " + "'models' (list of module models) and 'gateway' (IP with mask) are mandatory." + ), + ) + + @model_validator(mode='after') + def validate_operation_type(self) -> Self: + """Validate serial_number / preprovision_serial combinations. + + Allowed combinations: + - serial_number only → Bootstrap + - preprovision_serial only → Pre-provision + - both serial_number AND preprovision_serial → Swap (change serial + number of an existing pre-provisioned switch) + - neither → error + """ + has_serial = bool(self.serial_number) + has_preprov = bool(self.preprovision_serial) + + if not has_serial and not has_preprov: + raise ValueError( + "Either 'serial_number' (for Bootstrap / Swap) or 'preprovision_serial' " + "(for Pre-provision / Swap) must be provided." + ) + + return self + + @model_validator(mode='after') + def validate_required_fields_for_non_swap(self) -> Self: + """Validate model/version/hostname/config_data are all provided for non-swap POAP. + + For Bootstrap (serial_number only) or Pre-provision (preprovision_serial only) + all four descriptor fields are mandatory. This mirrors the + dcnm_inventory.py check: + if only one serial provided → model, version, hostname, config_data required. + + When both serials are present (swap mode), these fields are not + required because the swap API only needs the new serial number. + """ + has_serial = bool(self.serial_number) + has_preprov = bool(self.preprovision_serial) + + # XOR: exactly one serial → non-swap case + if has_serial != has_preprov: + missing = [] + if not self.model: + missing.append("model") + if not self.version: + missing.append("version") + if not self.hostname: + missing.append("hostname") + if not self.config_data: + missing.append("config_data") + if missing: + op = "Bootstrap" if has_serial else "Pre-provisioning" + raise ValueError( + f"model, version, hostname and config_data are required for " + f"{op} a switch. Missing: {', '.join(missing)}" + ) + return self + + @model_validator(mode='after') + def validate_discovery_credentials_pair(self) -> Self: + """Validate that discovery_username and discovery_password are both set or both absent. + + Mirrors the dcnm_inventory.py bidirectional check: + - discovery_username set → discovery_password required + - discovery_password set → discovery_username required + """ + has_user = bool(self.discovery_username) + has_pass = bool(self.discovery_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" + ) + return self + + @field_validator('serial_number', 'preprovision_serial', mode='before') + @classmethod + def validate_serial_numbers(cls, v: Optional[str]) -> Optional[str]: + """Validate serial numbers are not empty strings.""" + if v is not None and not v.strip(): + raise ValueError("Serial number cannot be empty") + return v + + +class RMAConfigModel(NDNestedModel): + """ + RMA configuration entry for replacing a single switch via bootstrap. + + The switch being replaced must be in maintenance mode and either shut down + or disconnected from the network before initiating the RMA operation. + """ + identifiers: ClassVar[List[str]] = [] + + # Discovery credentials + discovery_username: Optional[str] = Field( + default=None, + alias="discoveryUsername", + description="Username for device discovery during POAP and RMA discovery" + ) + discovery_password: Optional[str] = Field( + default=None, + alias="discoveryPassword", + description="Password for device discovery during POAP and RMA discovery" + ) + + # Required fields for RMA + serial_number: str = Field( + ..., + alias="serialNumber", + min_length=1, + description="Serial number of switch to Bootstrap for RMA" + ) + old_serial: str = Field( + ..., + alias="oldSerial", + min_length=1, + description="Serial number of switch to be replaced by RMA" + ) + model: str = Field( + ..., + min_length=1, + description="Model of switch to Bootstrap for RMA" + ) + version: str = Field( + ..., + min_length=1, + description="Software version of switch to Bootstrap for RMA" + ) + + # Optional fields + image_policy: Optional[str] = Field( + default=None, + alias="imagePolicy", + description="Name of the image policy to be applied on switch during Bootstrap for RMA" + ) + + # Required config data for RMA (models list + gateway) + config_data: ConfigDataModel = Field( + ..., + alias="configData", + description=( + "Basic config data of switch to Bootstrap for RMA. " + "'models' (list of module models) and 'gateway' (IP with mask) are mandatory." + ), + ) + + @field_validator('serial_number', 'old_serial', mode='before') + @classmethod + def validate_serial_numbers(cls, v: str) -> str: + """Validate serial numbers are not empty.""" + if not v or not v.strip(): + raise ValueError("Serial number cannot be empty") + return v.strip() + + @model_validator(mode='after') + def validate_discovery_credentials_pair(self) -> Self: + """Validate that discovery_username and discovery_password are both set or both absent. + + Mirrors the dcnm_inventory.py bidirectional check: + - discovery_username set → discovery_password required + - discovery_password set → discovery_username required + """ + has_user = bool(self.discovery_username) + has_pass = bool(self.discovery_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" + ) + return self + + +class SwitchConfigModel(NDBaseModel): + """ + Per-switch configuration entry in the Ansible playbook config list. + + Supports normal switch addition, POAP (Bootstrap and Pre-provision), and RMA + operations. The operation type is derived from the presence of poap or rma fields. + """ + identifiers: ClassVar[List[str]] = ["seed_ip"] + + # Fields excluded from diff — only seed_ip + role are compared + exclude_from_diff: ClassVar[List[str]] = [ + "user_name", "password", "auth_proto", "max_hops", + "preserve_config", "platform_type", "poap", "rma", + "operation_type", + "switch_id", "serial_number", "mode", "hostname", + "model", "software_version", + ] + + # Required fields + seed_ip: str = Field( + ..., + alias="seedIp", + min_length=1, + description="Seed IP address or DNS name of the switch" + ) + + # Optional fields — required for merged/overridden, optional for query/deleted + user_name: Optional[str] = Field( + default=None, + alias="userName", + description="Login username to the switch (required for merged/overridden states)" + ) + password: Optional[str] = Field( + default=None, + description="Login password to the switch (required for merged/overridden states)" + ) + + # Optional fields with defaults + auth_proto: SnmpV3AuthProtocol = Field( + default=SnmpV3AuthProtocol.MD5, + alias="authProto", + description="Authentication protocol to use" + ) + max_hops: int = Field( + default=0, + alias="maxHops", + ge=0, + le=7, + description="Maximum hops to reach the switch (deprecated, defaults to 0)" + ) + role: Optional[SwitchRole] = Field( + default=None, + description="Role to assign to the switch. None means not specified (uses controller default)." + ) + preserve_config: bool = Field( + default=False, + alias="preserveConfig", + description="Set to false for greenfield, true for brownfield deployment" + ) + platform_type: PlatformType = Field( + default=PlatformType.NX_OS, + alias="platformType", + description="Platform type of the switch (nx-os, ios-xe, etc.)" + ) + + # POAP and RMA configurations + poap: Optional[List[POAPConfigModel]] = Field( + default=None, + description="POAP (PowerOn Auto Provisioning) configurations for Bootstrap/Pre-provision" + ) + rma: Optional[List[RMAConfigModel]] = Field( + default=None, + description="RMA (Return Material Authorization) configurations for switch replacement" + ) + + # Computed fields + + @computed_field + @property + def operation_type(self) -> Literal["normal", "poap", "rma"]: + """Determine the operation type from this config. + + Returns: + ``'poap'`` if POAP configs are present, + ``'rma'`` if RMA configs are present, + ``'normal'`` otherwise. + """ + if self.poap: + return "poap" + if self.rma: + return "rma" + return "normal" + + # API-derived fields (populated by from_response, never set by users) + switch_id: Optional[str] = Field( + default=None, + alias="switchId", + description="Serial number / switch ID from inventory API" + ) + serial_number: Optional[str] = Field( + default=None, + alias="serialNumber", + description="Serial number from inventory API" + ) + mode: Optional[str] = Field( + default=None, + description="Switch mode from inventory API (Normal, Migration, etc.)" + ) + hostname: Optional[str] = Field( + default=None, + description="Switch hostname from inventory API" + ) + model: Optional[str] = Field( + default=None, + description="Switch model from inventory API" + ) + software_version: Optional[str] = Field( + default=None, + alias="softwareVersion", + description="Software version from inventory API" + ) + + @model_validator(mode='before') + @classmethod + def reject_auth_proto_for_poap_rma(cls, data: Any) -> Any: + """Reject non-MD5 auth_proto when POAP or RMA is configured. + + POAP, Pre-provision, and RMA operations always use MD5 internally. + If the user explicitly supplies a non-MD5 ``auth_proto`` (or + ``authProto``) alongside ``poap`` or ``rma``, raise an error so + they know the field is not user-configurable for these operation + types. + + Note: Ansible argspec injects the default ``"MD5"`` even when the + user omits ``auth_proto``, so we must allow MD5 through. + """ + if not isinstance(data, dict): + return data + + has_poap = bool(data.get("poap")) + has_rma = bool(data.get("rma")) + + if has_poap or has_rma: + # Check both snake_case (Ansible playbook) and camelCase (API) keys + auth_val = data.get("auth_proto") or data.get("authProto") + if auth_val is not None: + # Normalize to lowercase for comparison + normalized = str(auth_val).strip().lower() + if normalized not in ("md5", ""): + op = "POAP" if has_poap else "RMA" + raise ValueError( + f"'auth_proto' must not be specified for {op} operations. " + f"The authentication protocol is always MD5 and is set " + f"automatically. Received: '{auth_val}'" + ) + + return data + + @model_validator(mode='after') + def validate_poap_rma_mutual_exclusion(self) -> Self: + """Validate that POAP and RMA are mutually exclusive.""" + if self.poap and self.rma: + raise ValueError("Cannot specify both 'poap' and 'rma' configurations for the same switch") + + return self + + @model_validator(mode='after') + def validate_poap_rma_credentials(self) -> Self: + """Validate credentials for POAP and RMA operations.""" + if self.poap or self.rma: + # POAP/RMA require credentials + if not self.user_name or not self.password: + raise ValueError( + "For POAP and RMA operations, user_name and password are required" + ) + # For POAP and RMA, username should be 'admin' + if self.user_name != "admin": + raise ValueError("For POAP and RMA operations, user_name should be 'admin'") + + return self + + @model_validator(mode='after') + def apply_state_defaults(self, info: ValidationInfo) -> Self: + """Apply state-aware defaults and enforcement using validation context. + + When ``context={"state": "merged"}`` (or ``"overridden"``) is passed + to ``model_validate()``, the model: + - Defaults ``role`` to ``SwitchRole.LEAF`` when not specified. + - Enforces that ``user_name`` and ``password`` are provided. + + For ``query`` / ``deleted`` (or no context), fields remain as-is. + """ + state = (info.context or {}).get("state") if info else None + + # POAP only allowed with merged or query + if self.poap and state not in (None, "merged", "query"): + raise ValueError( + f"POAP operations require 'merged' or 'query' state, " + f"got '{state}' (switch: {self.seed_ip})" + ) + + # RMA only allowed with merged + if self.rma and state not in (None, "merged"): + raise ValueError( + f"RMA operations require 'merged' state, " + f"got '{state}' (switch: {self.seed_ip})" + ) + + if state in ("merged", "overridden"): + if self.role is None: + self.role = SwitchRole.LEAF + if not self.user_name or not self.password: + raise ValueError( + f"user_name and password are required " + f"for '{state}' state " + f"(switch: {self.seed_ip})" + ) + return self + + @field_validator('seed_ip', mode='before') + @classmethod + def validate_seed_ip(cls, v: str) -> str: + """Validate seed IP is valid IP address or DNS name.""" + if not v or not v.strip(): + raise ValueError("seed_ip cannot be empty") + + v = v.strip() + + # Try to validate as IP address first + try: + ip_address(v) + return v + except ValueError: + pass + + # If not an IP, assume it's a DNS name - basic validation + if not v.replace('-', '').replace('.', '').replace('_', '').isalnum(): + raise ValueError(f"Invalid seed_ip: {v}. Must be a valid IP address or DNS name") + + return v + + @field_validator('poap', 'rma', mode='before') + @classmethod + def validate_lists_not_empty(cls, v: Optional[List]) -> Optional[List]: + """Validate that if POAP or RMA lists are provided, they are not empty.""" + if v is not None and len(v) == 0: + raise ValueError("POAP/RMA list cannot be empty if provided") + return v + + @field_validator('auth_proto', mode='before') + @classmethod + def normalize_auth_proto(cls, v: Union[str, SnmpV3AuthProtocol, None]) -> SnmpV3AuthProtocol: + """Normalize auth_proto to handle case-insensitive input (MD5, md5, etc.).""" + return SnmpV3AuthProtocol.normalize(v) + + @field_validator('role', mode='before') + @classmethod + def normalize_role(cls, v: Union[str, SwitchRole, None]) -> Optional[SwitchRole]: + """Normalize role for case-insensitive and underscore-to-camelCase matching. + Returns None when not specified (distinguishes from explicit 'leaf').""" + if v is None: + return None + return SwitchRole.normalize(v) + + @field_validator('platform_type', mode='before') + @classmethod + def normalize_platform_type(cls, v: Union[str, PlatformType, None]) -> PlatformType: + """Normalize platform_type for case-insensitive matching (NX_OS, nx-os, etc.).""" + return PlatformType.normalize(v) + + @classmethod + def validate_no_mixed_operations( + cls, configs: List["SwitchConfigModel"] + ) -> None: + """Validate that a list of configs does not mix operation types. + + POAP, RMA, and normal switch operations cannot be combined + in the same Ansible task. Call this after validating all + individual configs. + + Args: + configs: List of validated SwitchConfigModel instances. + + Raises: + ValueError: If more than one operation type is present. + """ + op_types = {cfg.operation_type for cfg in configs} + if len(op_types) > 1: + raise ValueError( + "Mixed operation types detected: " + f"{', '.join(sorted(op_types))}. " + "POAP, RMA, and normal switch operations " + "cannot be mixed in the same task. " + "Please separate them into different tasks." + ) + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format. + + Excludes API-derived fields that are not part of the user config. + """ + return self.model_dump( + by_alias=True, + exclude_none=True, + exclude={ + "switch_id", "serial_number", "mode", + "hostname", "model", "software_version", + }, + ) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create model instance from inventory or discovery API response. + + Handles two formats: + 1. Inventory API: {switchId, fabricManagementIp, switchRole, ...} + 2. Discovery API: {serialNumber, ip, hostname, ...} + """ + mapped: Dict[str, Any] = {} + + # seed_ip from fabricManagementIp (inventory) or ip (discovery) + ip = response.get("fabricManagementIp") or response.get("ip") + if ip: + mapped["seedIp"] = ip + + # role from switchRole + role = response.get("switchRole") + if role: + mapped["role"] = role + + # Direct API fields + direct_fields = ( + "switchId", "serialNumber", "softwareVersion", + "mode", "hostname", "model", + ) + for key in direct_fields: + if key in response and response[key] is not None: + mapped[key] = response[key] + + return cls.model_validate(mapped) + + +__all__ = [ + "ConfigDataModel", + "POAPConfigModel", + "RMAConfigModel", + "SwitchConfigModel", +] diff --git a/plugins/module_utils/models/nd_manage_switches/discovery_models.py b/plugins/module_utils/models/nd_manage_switches/discovery_models.py new file mode 100644 index 00000000..4e6fb667 --- /dev/null +++ b/plugins/module_utils/models/nd_manage_switches/discovery_models.py @@ -0,0 +1,268 @@ +# -*- 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 discovery models for shallow discovery and fabric add operations. + +Based on OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from pydantic import Field, field_validator +from typing import Any, Dict, List, Optional, ClassVar, Literal, Union +from typing_extensions import Self + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel + +from .enums import ( + PlatformType, + RemoteCredentialStore, + SnmpV3AuthProtocol, + SwitchRole, +) +from .validators import SwitchValidators + + +class ShallowDiscoveryRequestModel(NDBaseModel): + """ + Initiates a shallow CDP/LLDP-based discovery from one or more seed IP addresses. + + Path: POST /fabrics/{fabricName}/actions/shallowDiscovery + """ + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" + exclude_from_diff: ClassVar[List[str]] = ["password"] + seed_ip_collection: List[str] = Field( + ..., + alias="seedIpCollection", + min_length=1, + description="Seed switch IP collection" + ) + max_hop: int = Field( + default=2, + alias="maxHop", + ge=0, + le=7, + description="Max hop" + ) + platform_type: PlatformType = Field( + default=PlatformType.NX_OS, + alias="platformType", + description="Switch platform type" + ) + snmp_v3_auth_protocol: SnmpV3AuthProtocol = Field( + default=SnmpV3AuthProtocol.MD5, + alias="snmpV3AuthProtocol", + description="SNMPv3 authentication protocols" + ) + username: Optional[str] = Field( + default=None, + description="User name for switch login" + ) + password: Optional[str] = Field( + default=None, + description="User password for switch login" + ) + remote_credential_store: Optional[RemoteCredentialStore] = Field( + default=None, + alias="remoteCredentialStore", + description="Type of credential store" + ) + remote_credential_store_key: Optional[str] = Field( + default=None, + alias="remoteCredentialStoreKey", + description="Remote credential store key" + ) + + @field_validator('seed_ip_collection', mode='before') + @classmethod + def validate_seed_ips(cls, v: List[str]) -> List[str]: + """Validate all seed IPs.""" + if not v: + raise ValueError("At least one seed IP is required") + validated = [] + for ip in v: + result = SwitchValidators.validate_ip_address(ip) + if result: + validated.append(result) + if not validated: + raise ValueError("No valid seed IPs provided") + return validated + + @field_validator('snmp_v3_auth_protocol', mode='before') + @classmethod + def normalize_snmp_auth(cls, v: Union[str, SnmpV3AuthProtocol, None]) -> SnmpV3AuthProtocol: + """Normalize SNMP auth protocol (case-insensitive).""" + return SnmpV3AuthProtocol.normalize(v) + + @field_validator('platform_type', mode='before') + @classmethod + def normalize_platform(cls, v: Union[str, PlatformType, None]) -> PlatformType: + """Normalize platform type (case-insensitive).""" + return PlatformType.normalize(v) + + +class SwitchDiscoveryModel(NDBaseModel): + """ + Discovery data for a single switch returned by the shallow discovery API. + + For N7K user VDC deployments, the serial number format is serialNumber:vDCName. + """ + identifiers: ClassVar[List[str]] = ["serial_number"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + hostname: str = Field( + ..., + description="Switch host name" + ) + ip: str = Field( + ..., + description="Switch IPv4/v6 address" + ) + serial_number: str = Field( + ..., + alias="serialNumber", + description="Switch serial number" + ) + model: str = Field( + ..., + description="Switch model" + ) + software_version: Optional[str] = Field( + default=None, + alias="softwareVersion", + description="Switch software version" + ) + vdc_id: Optional[int] = Field( + default=None, + alias="vdcId", + ge=0, + description="N7K VDC ID. Mandatory for N7K switch discovery" + ) + vdc_mac: Optional[str] = Field( + default=None, + alias="vdcMac", + description="N7K VDC Mac address. Mandatory for N7K switch discovery" + ) + switch_role: Optional[SwitchRole] = Field( + default=None, + alias="switchRole", + description="Switch role" + ) + + @field_validator('hostname', mode='before') + @classmethod + def validate_host(cls, v: str) -> str: + result = SwitchValidators.validate_hostname(v) + if result is None: + raise ValueError("hostname cannot be empty") + return result + + @field_validator('ip', mode='before') + @classmethod + def validate_ip(cls, v: str) -> str: + result = SwitchValidators.validate_ip_address(v) + if result is None: + raise ValueError("ip cannot be empty") + return result + + @field_validator('serial_number', mode='before') + @classmethod + def validate_serial(cls, v: str) -> str: + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("serial_number cannot be empty") + return result + + @field_validator('vdc_mac', mode='before') + @classmethod + def validate_mac(cls, v: Optional[str]) -> Optional[str]: + return SwitchValidators.validate_mac_address(v) + + +class AddSwitchesRequestModel(NDBaseModel): + """ + Imports one or more previously discovered switches into a fabric. + + Path: POST /fabrics/{fabricName}/switches + """ + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" + exclude_from_diff: ClassVar[List[str]] = ["password"] + switches: List[SwitchDiscoveryModel] = Field( + ..., + min_length=1, + description="The list of switches to be imported" + ) + platform_type: PlatformType = Field( + default=PlatformType.NX_OS, + alias="platformType", + description="Switch platform type" + ) + preserve_config: bool = Field( + default=True, + alias="preserveConfig", + description="Flag to preserve the switch configuration after import" + ) + snmp_v3_auth_protocol: SnmpV3AuthProtocol = Field( + default=SnmpV3AuthProtocol.MD5, + alias="snmpV3AuthProtocol", + description="SNMPv3 authentication protocols" + ) + use_credential_for_write: Optional[bool] = Field( + default=None, + alias="useCredentialForWrite", + description="Flag to use the discovery credential as LAN credential" + ) + username: Optional[str] = Field( + default=None, + description="User name for switch login" + ) + password: Optional[str] = Field( + default=None, + description="User password for switch login" + ) + remote_credential_store: Optional[RemoteCredentialStore] = Field( + default=None, + alias="remoteCredentialStore", + description="Type of credential store" + ) + remote_credential_store_key: Optional[str] = Field( + default=None, + alias="remoteCredentialStoreKey", + description="Remote credential store key" + ) + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + payload = self.model_dump(by_alias=True, exclude_none=True) + # Convert nested switches to payload format + if 'switches' in payload: + payload['switches'] = [ + s.to_payload() if hasattr(s, 'to_payload') else s + for s in self.switches + ] + return payload + + @field_validator('snmp_v3_auth_protocol', mode='before') + @classmethod + def normalize_snmp_auth(cls, v: Union[str, SnmpV3AuthProtocol, None]) -> SnmpV3AuthProtocol: + """Normalize SNMP auth protocol (case-insensitive: MD5, md5, etc.).""" + return SnmpV3AuthProtocol.normalize(v) + + @field_validator('platform_type', mode='before') + @classmethod + def normalize_platform_type(cls, v: Union[str, PlatformType, None]) -> PlatformType: + """Normalize platform type (case-insensitive: NX_OS, nx-os, etc.).""" + return PlatformType.normalize(v) + + +__all__ = [ + "ShallowDiscoveryRequestModel", + "SwitchDiscoveryModel", + "AddSwitchesRequestModel", +] diff --git a/plugins/module_utils/models/nd_manage_switches/enums.py b/plugins/module_utils/models/nd_manage_switches/enums.py new file mode 100644 index 00000000..93f93083 --- /dev/null +++ b/plugins/module_utils/models/nd_manage_switches/enums.py @@ -0,0 +1,320 @@ +# -*- 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, Union + + +# ============================================================================= +# 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] + + @classmethod + def from_user_input(cls, value: str) -> "SwitchRole": + """ + Convert user-friendly input to enum value. + Accepts underscore-separated values like 'border_gateway' -> 'borderGateway' + """ + if not value: + return cls.LEAF + # Try direct match first + try: + return cls(value) + except ValueError: + pass + # Try converting underscore to camelCase + parts = value.lower().split('_') + camel_case = parts[0] + ''.join(word.capitalize() for word in parts[1:]) + try: + return cls(camel_case) + except ValueError: + raise ValueError(f"Invalid switch role: {value}. Valid options: {cls.choices()}") + + @classmethod + def normalize(cls, value: Union[str, "SwitchRole", None]) -> "SwitchRole": + """ + Normalize input to enum value (case-insensitive). + Accepts: LEAF, leaf, border_gateway, borderGateway, etc. + """ + if value is None: + return cls.LEAF + if isinstance(value, cls): + return value + if isinstance(value, str): + v_lower = value.lower() + # Try direct match with lowercase + for role in cls: + if role.value.lower() == v_lower: + return role + # Try converting underscore to camelCase + parts = v_lower.split('_') + if len(parts) > 1: + camel_case = parts[0] + ''.join(word.capitalize() for word in parts[1:]) + for role in cls: + if role.value == camel_case: + return role + raise ValueError(f"Invalid SwitchRole: {value}. Valid: {cls.choices()}") + + +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. + + Based on: components/schemas (multiple references) + """ + 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] + + @classmethod + def normalize(cls, value: Union[str, "PlatformType", None]) -> "PlatformType": + """ + Normalize input to enum value (case-insensitive). + Accepts: NX_OS, nx-os, NX-OS, ios_xe, ios-xe, etc. + """ + if value is None: + return cls.NX_OS + if isinstance(value, cls): + return value + if isinstance(value, str): + v_normalized = value.lower().replace('_', '-') + for pt in cls: + if pt.value == v_normalized: + return pt + raise ValueError(f"Invalid PlatformType: {value}. Valid: {cls.choices()}") + + +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] + + @classmethod + def normalize(cls, value: Union[str, "SnmpV3AuthProtocol", None]) -> "SnmpV3AuthProtocol": + """ + Normalize input to enum value (case-insensitive). + Accepts: MD5, md5, MD5_DES, md5-des, etc. + """ + if value is None: + return cls.MD5 + if isinstance(value, cls): + return value + if isinstance(value, str): + v_normalized = value.lower().replace('_', '-') + for proto in cls: + if proto.value == v_normalized: + return proto + raise ValueError(f"Invalid SnmpV3AuthProtocol: {value}. Valid: {cls.choices()}") + + +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. + """ + CRITICAL = "critical" + MAJOR = "major" + MINOR = "minor" + WARNING = "warning" + INFO = "info" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + +class AdvisoryLevel(str, Enum): + """ + Advisory level classification. + """ + CRITICAL = "critical" + MAJOR = "major" + MINOR = "minor" + NONE = "none" + + @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/nd_manage_switches/preprovision_models.py b/plugins/module_utils/models/nd_manage_switches/preprovision_models.py new file mode 100644 index 00000000..1cd8b8a0 --- /dev/null +++ b/plugins/module_utils/models/nd_manage_switches/preprovision_models.py @@ -0,0 +1,218 @@ +# -*- 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) + +"""Pre-provision switch models. + +Based on OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ipaddress import ip_network +from pydantic import Field, field_validator, model_validator +from typing import Any, Dict, List, Optional, ClassVar, Literal +from typing_extensions import Self + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel + +from .enums import ( + RemoteCredentialStore, + SnmpV3AuthProtocol, + SwitchRole, +) +from .validators import SwitchValidators + + +class PreProvisionSwitchModel(NDBaseModel): + """ + Request payload for pre-provisioning a single switch in the fabric. + + Path: POST /fabrics/{fabricName}/switchActions/preProvision + """ + + identifiers: ClassVar[List[str]] = ["serial_number"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + exclude_from_diff: ClassVar[List[str]] = ["password", "discovery_password"] + + # --- preProvisionSpecific fields (required) --- + serial_number: str = Field( + ..., + alias="serialNumber", + description="Serial number of the switch to pre-provision", + ) + hostname: str = Field( + ..., + description="Hostname of the switch to pre-provision", + ) + ip: str = Field( + ..., + description="IP address of the switch to pre-provision", + ) + + # --- preProvisionSpecific fields (optional) --- + dhcp_bootstrap_ip: Optional[str] = Field( + default=None, + alias="dhcpBootstrapIp", + description="Used for device day-0 bring-up when using inband reachability", + ) + seed_switch: bool = Field( + default=False, + alias="seedSwitch", + description="Use as seed switch", + ) + + # --- bootstrapBase fields (required) --- + model: str = Field( + ..., + description="Model of the switch to pre-provision", + ) + software_version: str = Field( + ..., + alias="softwareVersion", + description="Software version of the switch to pre-provision", + ) + gateway_ip_mask: str = Field( + ..., + alias="gatewayIpMask", + description="Gateway IP address with mask (e.g., 10.23.244.1/24)", + ) + + # --- bootstrapBase fields (optional) --- + image_policy: Optional[str] = Field( + default=None, + alias="imagePolicy", + description="Image policy associated with the switch during pre-provision", + ) + switch_role: Optional[SwitchRole] = Field( + default=None, + alias="switchRole", + description="Role to assign to the switch", + ) + data: Optional[Dict[str, Any]] = Field( + default=None, + description="Pre-provision configuration data block (gatewayIpMask, models)", + ) + + # --- bootstrapCredential fields (required) --- + password: str = Field( + ..., + description="Switch password to be set during pre-provision for admin user", + ) + discovery_auth_protocol: SnmpV3AuthProtocol = Field( + ..., + alias="discoveryAuthProtocol", + description="SNMP authentication protocol for discovery", + ) + + # --- bootstrapCredential fields (optional) --- + use_new_credentials: bool = Field( + default=False, + alias="useNewCredentials", + description=( + "If True, use discoveryUsername and discoveryPassword for local " + "remoteCredentialStore or use remoteCredentialStoreKey for CyberArk" + ), + ) + discovery_username: Optional[str] = Field( + default=None, + alias="discoveryUsername", + description="Username for switch discovery post pre-provision", + ) + discovery_password: Optional[str] = Field( + default=None, + alias="discoveryPassword", + description="Password for switch discovery post pre-provision", + ) + remote_credential_store: Optional[RemoteCredentialStore] = Field( + default=None, + alias="remoteCredentialStore", + description="Type of credential store for discovery credentials", + ) + + # --- Validators --- + + @field_validator("ip", "dhcp_bootstrap_ip", mode="before") + @classmethod + def validate_ip(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + result = SwitchValidators.validate_ip_address(v) + if result is None: + raise ValueError(f"Invalid IP address: {v}") + return result + + @field_validator("hostname", mode="before") + @classmethod + def validate_host(cls, v: str) -> str: + result = SwitchValidators.validate_hostname(v) + if result is None: + raise ValueError("hostname cannot be empty") + return result + + @field_validator("serial_number", mode="before") + @classmethod + def validate_serial(cls, v: str) -> str: + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("serial_number cannot be empty") + return result + + @field_validator("gateway_ip_mask", mode="before") + @classmethod + def validate_gateway(cls, v: str) -> str: + if not v or "/" not in v: + raise ValueError( + "gatewayIpMask must include subnet mask (e.g., 10.23.244.1/24)" + ) + try: + ip_network(v, strict=False) + except Exception as exc: + raise ValueError(f"Invalid gatewayIpMask: {v}") from exc + return v + + @model_validator(mode='after') + def derive_use_new_credentials(self) -> Self: + """Auto-set useNewCredentials when both discoveryUsername and discoveryPassword are provided.""" + self.use_new_credentials = bool(self.discovery_username and self.discovery_password) + return self + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format matching preProvision spec.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create model instance from API response.""" + return cls.model_validate(response) + + +class PreProvisionSwitchesRequestModel(NDBaseModel): + """ + Request body wrapping a list of pre-provision payloads for bulk switch pre-provisioning. + + Path: POST /fabrics/{fabricName}/switchActions/preProvision + """ + + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" + switches: List[PreProvisionSwitchModel] = Field( + ..., + description="PowerOn Auto Provisioning switches", + ) + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return { + "switches": [s.to_payload() for s in self.switches] + } + + +__all__ = [ + "PreProvisionSwitchModel", + "PreProvisionSwitchesRequestModel", +] diff --git a/plugins/module_utils/models/nd_manage_switches/rma_models.py b/plugins/module_utils/models/nd_manage_switches/rma_models.py new file mode 100644 index 00000000..1f5be8b5 --- /dev/null +++ b/plugins/module_utils/models/nd_manage_switches/rma_models.py @@ -0,0 +1,258 @@ +# -*- 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) + +"""RMA (Return Material Authorization) switch models. + +Based on OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from pydantic import Field, computed_field, field_validator, model_validator +from typing import Any, Dict, List, Optional, ClassVar, Literal +from typing_extensions import Self + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel + +from .enums import ( + RemoteCredentialStore, + SnmpV3AuthProtocol, + SwitchRole, +) +from .validators import SwitchValidators + + +class RMASpecificModel(NDBaseModel): + """ + Replacement-switch-specific fields used in an RMA bootstrap operation. + """ + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" + hostname: str = Field( + ..., + description="Hostname of the switch" + ) + ip: str = Field( + ..., + description="IP address of the switch" + ) + new_switch_id: str = Field( + ..., + alias="newSwitchId", + description="SwitchId (serial number) of the switch" + ) + public_key: str = Field( + ..., + alias="publicKey", + description="Public Key" + ) + finger_print: str = Field( + ..., + alias="fingerPrint", + description="Fingerprint" + ) + dhcp_bootstrap_ip: Optional[str] = Field( + default=None, + alias="dhcpBootstrapIp", + description="This is used for device day-0 bring-up when using inband reachability" + ) + seed_switch: bool = Field( + default=False, + alias="seedSwitch", + description="Use as seed switch" + ) + + @field_validator('hostname', mode='before') + @classmethod + def validate_host(cls, v: str) -> str: + result = SwitchValidators.validate_hostname(v) + if result is None: + raise ValueError("hostname cannot be empty") + return result + + @field_validator('ip', 'dhcp_bootstrap_ip', mode='before') + @classmethod + def validate_ip(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + return SwitchValidators.validate_ip_address(v) + + @field_validator('new_switch_id', mode='before') + @classmethod + def validate_serial(cls, v: str) -> str: + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("new_switch_id cannot be empty") + return result + + +class RMASwitchModel(NDBaseModel): + """ + Request payload for provisioning a replacement (RMA) switch via bootstrap. + + Path: POST /fabrics/{fabricName}/switches/{switchId}/actions/provisionRMA + """ + identifiers: ClassVar[List[str]] = ["new_switch_id"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + exclude_from_diff: ClassVar[List[str]] = ["password", "discovery_password"] + # From bootstrapBase + gateway_ip_mask: str = Field( + ..., + alias="gatewayIpMask", + description="Gateway IP address with mask" + ) + model: str = Field( + ..., + description="Model of the bootstrap switch" + ) + software_version: str = Field( + ..., + alias="softwareVersion", + description="Software version of the bootstrap switch" + ) + image_policy: Optional[str] = Field( + default=None, + alias="imagePolicy", + description="Image policy associated with the switch during bootstrap" + ) + switch_role: Optional[SwitchRole] = Field( + default=None, + alias="switchRole" + ) + + # From bootstrapCredential + password: str = Field( + ..., + description="Switch password to be set during bootstrap for admin user" + ) + discovery_auth_protocol: SnmpV3AuthProtocol = Field( + ..., + alias="discoveryAuthProtocol" + ) + discovery_username: Optional[str] = Field( + default=None, + alias="discoveryUsername" + ) + discovery_password: Optional[str] = Field( + default=None, + alias="discoveryPassword" + ) + remote_credential_store: RemoteCredentialStore = Field( + default=RemoteCredentialStore.LOCAL, + alias="remoteCredentialStore" + ) + remote_credential_store_key: Optional[str] = Field( + default=None, + alias="remoteCredentialStoreKey" + ) + + # From RMASpecific + hostname: str = Field( + ..., + description="Hostname of the switch" + ) + ip: str = Field( + ..., + description="IP address of the switch" + ) + new_switch_id: str = Field( + ..., + alias="newSwitchId", + description="SwitchId (serial number) of the switch" + ) + public_key: str = Field( + ..., + alias="publicKey", + description="Public Key" + ) + finger_print: str = Field( + ..., + alias="fingerPrint", + description="Fingerprint" + ) + dhcp_bootstrap_ip: Optional[str] = Field( + default=None, + alias="dhcpBootstrapIp" + ) + seed_switch: bool = Field( + default=False, + alias="seedSwitch" + ) + + @field_validator('gateway_ip_mask', mode='before') + @classmethod + def validate_gateway(cls, v: str) -> str: + result = SwitchValidators.validate_cidr(v) + if result is None: + raise ValueError("gateway_ip_mask cannot be empty") + return result + + @field_validator('hostname', mode='before') + @classmethod + def validate_host(cls, v: str) -> str: + result = SwitchValidators.validate_hostname(v) + if result is None: + raise ValueError("hostname cannot be empty") + return result + + @field_validator('ip', 'dhcp_bootstrap_ip', mode='before') + @classmethod + def validate_ip(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + result = SwitchValidators.validate_ip_address(v) + if v is not None and result is None: + raise ValueError(f"Invalid IP address: {v}") + return result + + @field_validator('new_switch_id', mode='before') + @classmethod + def validate_serial(cls, v: str) -> str: + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("new_switch_id cannot be empty") + return result + + @computed_field(alias="useNewCredentials") + @property + def use_new_credentials(self) -> bool: + """Derive useNewCredentials from discoveryUsername and discoveryPassword.""" + return bool(self.discovery_username and self.discovery_password) + + @model_validator(mode='after') + def validate_rma_credentials(self) -> Self: + """Validate RMA credential configuration logic.""" + if self.use_new_credentials: + if self.remote_credential_store == RemoteCredentialStore.CYBERARK: + if not self.remote_credential_store_key: + raise ValueError( + "remote_credential_store_key is required when " + "remote_credential_store is 'cyberark'" + ) + elif self.remote_credential_store == RemoteCredentialStore.LOCAL: + if not self.discovery_username or not self.discovery_password: + raise ValueError( + "discovery_username and discovery_password are required when " + "remote_credential_store is 'local' and use_new_credentials is True" + ) + return self + + def to_payload(self) -> Dict[str, Any]: + """Convert to API payload format.""" + return self.model_dump(by_alias=True, exclude_none=True) + + @classmethod + def from_response(cls, response: Dict[str, Any]) -> Self: + """Create model instance from API response.""" + return cls.model_validate(response) + + +__all__ = [ + "RMASpecificModel", + "RMASwitchModel", +] diff --git a/plugins/module_utils/models/nd_manage_switches/switch_actions_models.py b/plugins/module_utils/models/nd_manage_switches/switch_actions_models.py new file mode 100644 index 00000000..76b207da --- /dev/null +++ b/plugins/module_utils/models/nd_manage_switches/switch_actions_models.py @@ -0,0 +1,116 @@ +# -*- 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 action models (serial number change, IDs list, credentials). + +Based on OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from pydantic import Field, field_validator, model_validator +from typing import Any, Dict, List, Literal, Optional, ClassVar +from typing_extensions import Self + +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel + +from .validators import SwitchValidators + + +class SwitchCredentialsRequestModel(NDBaseModel): + """ + Request body to save LAN credentials for one or more fabric switches. + + Supports local credentials or remote credential store (such as CyberArk). + Path: POST /api/v1/manage/credentials/switches + """ + identifiers: ClassVar[List[str]] = [] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" + + switch_ids: List[str] = Field( + ..., + alias="switchIds", + min_length=1, + description="List of switch serial numbers" + ) + switch_username: Optional[str] = Field( + default=None, + alias="switchUsername", + description="Switch username" + ) + switch_password: Optional[str] = Field( + default=None, + alias="switchPassword", + description="Switch password" + ) + remote_credential_store_key: Optional[str] = Field( + default=None, + alias="remoteCredentialStoreKey", + description="Remote credential store key (e.g. CyberArk path)" + ) + remote_credential_store_type: Optional[str] = Field( + default=None, + alias="remoteCredentialStoreType", + description="Remote credential store type (e.g. 'cyberark')" + ) + + @field_validator('switch_ids', mode='before') + @classmethod + def validate_switch_ids(cls, v: List[str]) -> List[str]: + """Validate all switch IDs.""" + if not v: + raise ValueError("At least one switch ID is required") + validated = [] + for serial in v: + result = SwitchValidators.validate_serial_number(serial) + if result: + validated.append(result) + if not validated: + raise ValueError("No valid switch IDs provided") + return validated + + @model_validator(mode='after') + def validate_credentials(self) -> Self: + """Ensure either local or remote credentials are provided.""" + has_local = self.switch_username is not None and self.switch_password is not None + has_remote = self.remote_credential_store_key is not None and self.remote_credential_store_type is not None + if not has_local and not has_remote: + raise ValueError( + "Either local credentials (switchUsername + switchPassword) " + "or remote credentials (remoteCredentialStoreKey + remoteCredentialStoreType) must be provided" + ) + return self + + +class ChangeSwitchSerialNumberRequestModel(NDBaseModel): + """ + Request body to update the serial number of an existing fabric switch. + + Path: POST /fabrics/{fabricName}/switches/{switchId}/actions/changeSwitchSerialNumber + """ + identifiers: ClassVar[List[str]] = ["new_switch_id"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + new_switch_id: str = Field( + ..., + alias="newSwitchId", + description="New switchId" + ) + + @field_validator('new_switch_id', mode='before') + @classmethod + def validate_serial(cls, v: str) -> str: + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("new_switch_id cannot be empty") + return result + + +__all__ = [ + "SwitchCredentialsRequestModel", + "ChangeSwitchSerialNumberRequestModel", +] diff --git a/plugins/module_utils/models/nd_manage_switches/switch_data_models.py b/plugins/module_utils/models/nd_manage_switches/switch_data_models.py new file mode 100644 index 00000000..5afc6117 --- /dev/null +++ b/plugins/module_utils/models/nd_manage_switches/switch_data_models.py @@ -0,0 +1,488 @@ +# -*- 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 (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from pydantic import Field, field_validator +from typing import Any, Dict, List, Optional, ClassVar, Literal, Union +from typing_extensions import Self + +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 .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" + ) + + @field_validator('inband_ipv4_address', 'out_of_band_ipv4_address', mode='before') + @classmethod + def validate_ipv4(cls, v: Optional[str]) -> Optional[str]: + return SwitchValidators.validate_ip_address(v) + + +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: + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("peer_switch_id cannot be empty") + return result + + +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" + 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" + ) + mode: Optional[str] = Field( + default=None, + description="Switch mode (Normal, Migration, etc.)" + ) + 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: + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("switch_id cannot be empty") + return result + + @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]) -> Self: + """ + 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) + + +__all__ = [ + "TelemetryIpCollection", + "VpcData", + "SwitchMetadata", + "AdditionalSwitchData", + "AdditionalAciSwitchData", + "Metadata", + "SwitchDataModel", +] diff --git a/plugins/module_utils/models/nd_manage_switches/validators.py b/plugins/module_utils/models/nd_manage_switches/validators.py new file mode 100644 index 00000000..b2e3a704 --- /dev/null +++ b/plugins/module_utils/models/nd_manage_switches/validators.py @@ -0,0 +1,115 @@ +# -*- 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. + """ + + @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 + + +__all__ = [ + "SwitchValidators", +] diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py new file mode 100644 index 00000000..3d9a7f69 --- /dev/null +++ b/plugins/module_utils/nd_switch_resources.py @@ -0,0 +1,2611 @@ +# -*- 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) + +"""Manage ND fabric switch lifecycle workflows. + +This module validates desired switch state, performs discovery and fabric +operations, and coordinates POAP and RMA workflows. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple, Union + +from pydantic import ValidationError + +from .nd_v2 import NDModule +from .enums import OperationType +from .rest.results import Results +from .models.nd_manage_switches import ( + SwitchRole, + SnmpV3AuthProtocol, + PlatformType, + DiscoveryStatus, + SystemMode, + SwitchDiscoveryModel, + SwitchDataModel, + AddSwitchesRequestModel, + ShallowDiscoveryRequestModel, + BootstrapImportSwitchModel, + ImportBootstrapSwitchesRequestModel, + PreProvisionSwitchModel, + PreProvisionSwitchesRequestModel, + RMASwitchModel, + SwitchConfigModel, + SwitchCredentialsRequestModel, + ChangeSwitchSerialNumberRequestModel, + POAPConfigModel, + RMAConfigModel, +) +from .utils.nd_manage_switches import ( + FabricUtils, + SwitchWaitUtils, + SwitchOperationError, + mask_password, + get_switch_field, + group_switches_by_credentials, + query_bootstrap_switches, + build_bootstrap_index, + build_poap_data_block, +) +from .endpoints.v1.nd_manage_switches.manage_fabric_switches import ( + V1ManageFabricSwitchesGet, + V1ManageFabricSwitchesPost, +) +from .endpoints.v1.nd_manage_switches.manage_fabric_discovery import V1ManageFabricShallowDiscoveryPost +from .endpoints.v1.nd_manage_switches.manage_fabric_switch_actions import ( + V1ManageFabricSwitchProvisionRMAPost, + V1ManageFabricSwitchActionsImportBootstrapPost, + V1ManageFabricSwitchActionsPreProvisionPost, + V1ManageFabricSwitchActionsRemovePost, + V1ManageFabricSwitchActionsChangeRolesPost, + V1ManageFabricSwitchChangeSerialNumberPost, +) +from .endpoints.v1.nd_manage_switches.manage_credentials import V1ManageCredentialsSwitchesPost + + +# ========================================================================= +# Shared Dependency Container +# ========================================================================= + +@dataclass +class SwitchServiceContext: + """Store shared dependencies used by service classes. + + Attributes: + nd: ND module wrapper for requests and module interactions. + results: Shared results aggregator for task output. + fabric: Target fabric name. + log: Logger instance. + save_config: Whether to run fabric save after changes. + deploy_config: Whether to run fabric deploy after changes. + """ + nd: NDModule + results: Results + fabric: str + log: logging.Logger + save_config: bool = True + deploy_config: bool = True + + +# ========================================================================= +# Validation & Diff +# ========================================================================= + +class SwitchDiffEngine: + """Provide stateless validation and diff computation helpers.""" + + @staticmethod + def validate_configs( + config: Union[Dict[str, Any], List[Dict[str, Any]]], + state: str, + nd: NDModule, + log: logging.Logger, + ) -> List[SwitchConfigModel]: + """Validate raw module config and return typed switch configs. + + Args: + config: Raw config dict or list of dicts from module parameters. + state: Requested module state. + nd: ND module wrapper used for failure handling. + log: Logger instance. + + Returns: + List of validated ``SwitchConfigModel`` objects. + + Raises: + ValidationError: Raised by model validation for invalid input. + """ + log.debug("ENTER: validate_configs()") + + configs_list = config if isinstance(config, list) else [config] + log.debug(f"Normalized to {len(configs_list)} configuration(s)") + + validated_configs: List[SwitchConfigModel] = [] + for idx, cfg in enumerate(configs_list): + try: + validated = SwitchConfigModel.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 " + f"config index {idx}: {error_detail}" + ) + log.error(error_msg) + if hasattr(nd, 'module'): + nd.module.fail_json(msg=error_msg) + else: + raise ValueError(error_msg) from e + except Exception as e: + error_msg = ( + f"Configuration validation failed for " + f"config index {idx}: {str(e)}" + ) + log.error(error_msg) + if hasattr(nd, 'module'): + nd.module.fail_json(msg=error_msg) + else: + raise ValueError(error_msg) from e + + if not validated_configs: + log.warning("No valid configurations found in input") + return validated_configs + + # Cross-config check — model can't do this per-instance + try: + SwitchConfigModel.validate_no_mixed_operations(validated_configs) + except ValueError as e: + error_msg = str(e) + log.error(error_msg) + if hasattr(nd, 'module'): + nd.module.fail_json(msg=error_msg) + else: + raise + + operation_type = validated_configs[0].operation_type + log.info( + f"Successfully validated {len(validated_configs)} " + f"configuration(s) with operation type: {operation_type}" + ) + log.debug( + f"EXIT: validate_configs() -> " + f"{len(validated_configs)} configs, operation_type={operation_type}" + ) + return validated_configs + + @staticmethod + def compute_changes( + proposed: List[SwitchDataModel], + existing: List[SwitchDataModel], + log: logging.Logger, + ) -> Dict[str, List[SwitchDataModel]]: + """Compare proposed and existing switches and categorize changes. + + Args: + proposed: Switch models representing desired state. + existing: Switch models currently present in inventory. + log: Logger instance. + + Returns: + Dict mapping change buckets to switch lists. Buckets are + ``to_add``, ``to_update``, ``to_delete``, ``migration_mode``, + and ``idempotent``. + """ + log.debug("ENTER: compute_changes()") + log.debug( + f"Comparing {len(proposed)} proposed vs {len(existing)} existing switches" + ) + + # Build indexes for O(1) lookups + existing_by_id = {sw.switch_id: sw for sw in existing} + existing_by_ip = {sw.fabric_management_ip: sw for sw in existing} + + log.debug( + f"Indexes built — existing_by_id: {list(existing_by_id.keys())}, " + f"existing_by_ip: {list(existing_by_ip.keys())}" + ) + + # Only user-controllable fields populated by both discovery and + # inventory APIs. Server-managed fields (uptime, alerts, vpc info, + # telemetry, etc.) are ignored. + compare_fields = { + "switch_id", + "serial_number", + "fabric_management_ip", + "hostname", + "model", + "software_version", + "switch_role", + } + + changes: Dict[str, list] = { + "to_add": [], + "to_update": [], + "to_delete": [], + "migration_mode": [], + "idempotent": [], + } + + # Categorise proposed switches + for prop_sw in proposed: + ip = prop_sw.fabric_management_ip + sid = prop_sw.switch_id + + existing_sw = existing_by_id.get(sid) + match_key = "switch_id" if existing_sw else None + + if not existing_sw: + existing_sw = existing_by_ip.get(ip) + if existing_sw: + match_key = "ip" + + if not existing_sw: + log.info( + f"Switch {ip} (id={sid}) not found in existing — marking to_add" + ) + changes["to_add"].append(prop_sw) + continue + + log.debug( + f"Switch {ip} matched existing by {match_key} " + f"(existing_id={existing_sw.switch_id})" + ) + + if existing_sw.mode == "Migration": + log.info( + f"Switch {ip} ({existing_sw.switch_id}) is in Migration mode" + ) + changes["migration_mode"].append(prop_sw) + continue + + prop_dict = prop_sw.model_dump( + by_alias=True, exclude_none=True, include=compare_fields + ) + existing_dict = existing_sw.model_dump( + by_alias=True, exclude_none=True, include=compare_fields + ) + + if prop_dict == existing_dict: + log.debug(f"Switch {ip} is idempotent — no changes needed") + changes["idempotent"].append(prop_sw) + else: + diff_keys = { + k for k in set(prop_dict) | set(existing_dict) + if prop_dict.get(k) != existing_dict.get(k) + } + log.info( + f"Switch {ip} has differences — marking to_update. " + f"Changed fields: {diff_keys}" + ) + log.debug( + f"Switch {ip} diff detail — " + f"proposed: { {k: prop_dict.get(k) for k in diff_keys} }, " + f"existing: { {k: existing_dict.get(k) for k in diff_keys} }" + ) + changes["to_update"].append(prop_sw) + + # Switches in existing but not in proposed (for overridden state) + proposed_ids = {sw.switch_id for sw in proposed} + for existing_sw in existing: + if existing_sw.switch_id not in proposed_ids: + log.info( + f"Existing switch {existing_sw.fabric_management_ip} " + f"({existing_sw.switch_id}) not in proposed — marking to_delete" + ) + changes["to_delete"].append(existing_sw) + + log.info( + f"Compute changes summary: " + f"to_add={len(changes['to_add'])}, " + f"to_update={len(changes['to_update'])}, " + f"to_delete={len(changes['to_delete'])}, " + f"migration_mode={len(changes['migration_mode'])}, " + f"idempotent={len(changes['idempotent'])}" + ) + log.debug("EXIT: compute_changes()") + return changes + + +# ========================================================================= +# Switch Discovery Service +# ========================================================================= + +class SwitchDiscoveryService: + """Handle switch discovery and proposed-model construction.""" + + def __init__(self, ctx: SwitchServiceContext): + """Initialize the discovery service. + + Args: + ctx: Shared service context. + + Returns: + None. + """ + self.ctx = ctx + + def discover( + self, + switch_configs: List[SwitchConfigModel], + ) -> Dict[str, Dict[str, Any]]: + """Discover switches for the provided config list. + + Args: + switch_configs: Validated switch configuration entries. + + Returns: + Dict mapping seed IP to raw discovery data. + """ + log = self.ctx.log + log.debug("Step 1: Grouping switches by credentials") + credential_groups = group_switches_by_credentials(switch_configs, log) + log.debug(f"Created {len(credential_groups)} credential group(s)") + + log.debug("Step 2: Bulk discovering switches") + all_discovered: Dict[str, Dict[str, Any]] = {} + for group_key, switches in credential_groups.items(): + username, _, auth_proto, platform_type, _ = group_key + password = switches[0].password + + log.debug( + f"Discovering group: {len(switches)} switches with username={username}" + ) + try: + discovered_batch = self.bulk_discover( + switches=switches, + username=username, + password=password, + auth_proto=auth_proto, + platform_type=platform_type, + ) + all_discovered.update(discovered_batch) + except Exception as e: + seed_ips = [sw.seed_ip for sw in switches] + msg = ( + f"Discovery failed for credential group " + f"(username={username}, IPs={seed_ips}): {e}" + ) + log.error(msg) + self.ctx.nd.module.fail_json(msg=msg) + + log.debug(f"Total discovered: {len(all_discovered)} switches") + return all_discovered + + def bulk_discover( + self, + switches: List[SwitchConfigModel], + username: str, + password: str, + auth_proto: SnmpV3AuthProtocol, + platform_type: PlatformType, + ) -> Dict[str, Dict[str, Any]]: + """Run one bulk discovery call for switches with shared credentials. + + Args: + switches: Switches to discover. + username: Discovery username. + password: Discovery password. + auth_proto: SNMP v3 authentication protocol. + platform_type: Platform type for discovery. + + Returns: + Dict mapping seed IP to discovered switch data. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + + log.debug("ENTER: bulk_discover()") + log.debug(f"Discovering {len(switches)} switches in bulk") + + endpoint = V1ManageFabricShallowDiscoveryPost() + endpoint.fabric_name = self.ctx.fabric + + seed_ips = [switch.seed_ip for switch in switches] + log.debug(f"Seed IPs: {seed_ips}") + + max_hops = switches[0].max_hops if hasattr(switches[0], 'max_hops') else 0 + + discovery_request = ShallowDiscoveryRequestModel( + seedIpCollection=seed_ips, + maxHop=max_hops, + platformType=platform_type, + snmpV3AuthProtocol=auth_proto, + username=username, + password=password, + ) + + payload = discovery_request.to_payload() + log.info(f"Bulk discovering {len(seed_ips)} switches: {', '.join(seed_ips)}") + log.debug(f"Discovery endpoint: {endpoint.path}") + log.debug(f"Discovery payload (password masked): {mask_password(payload)}") + + try: + nd.request(path=endpoint.path, verb=endpoint.verb, data=payload) + + response = nd.rest_send.response_current + result = nd.rest_send.result_current + + results.action = "discover" + results.response_current = response + results.result_current = result + results.diff_current = payload + results.register_task_result() + + # Extract discovered switches from response + switches_data = [] + if response and isinstance(response, dict): + if "DATA" in response and isinstance(response["DATA"], dict): + switches_data = response["DATA"].get("switches", []) + elif "body" in response and isinstance(response["body"], dict): + switches_data = response["body"].get("switches", []) + elif "switches" in response: + switches_data = response.get("switches", []) + + log.debug( + f"Extracted {len(switches_data)} switches from discovery response" + ) + + discovered_results: Dict[str, Dict[str, Any]] = {} + for discovered in switches_data: + if not isinstance(discovered, dict): + continue + + ip = discovered.get("ip") + status = discovered.get("status", "").lower() + serial_number = discovered.get("serialNumber") + + if not serial_number: + msg = ( + f"Switch {ip} discovery response missing serial number. " + f"Cannot proceed without a valid serial number." + ) + log.error(msg) + nd.module.fail_json(msg=msg) + if not ip: + msg = ( + f"Switch with serial {serial_number} discovery response " + f"missing IP address. Cannot proceed without a valid IP." + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + if status in ("manageable", "ok"): + discovered_results[ip] = discovered + log.info( + f"Switch {ip} ({serial_number}) discovered successfully - status: {status}" + ) + elif status == "alreadymanaged": + log.info(f"Switch {ip} ({serial_number}) is already managed") + discovered_results[ip] = discovered + else: + reason = discovered.get("statusReason", "Unknown") + log.error( + f"Switch {ip} discovery failed - status: {status}, reason: {reason}" + ) + + for seed_ip in seed_ips: + if seed_ip not in discovered_results: + log.warning(f"Switch {seed_ip} not found in discovery response") + + log.info( + f"Bulk discovery completed: " + f"{len(discovered_results)}/{len(seed_ips)} switches successful" + ) + log.debug(f"Discovered switches: {list(discovered_results.keys())}") + log.debug( + f"EXIT: bulk_discover() -> {len(discovered_results)} discovered" + ) + return discovered_results + + except Exception as e: + msg = ( + f"Bulk discovery failed for switches " + f"{', '.join(seed_ips)}: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + def build_proposed( + self, + proposed_config: List[SwitchConfigModel], + discovered_data: Dict[str, Dict[str, Any]], + existing: List[SwitchDataModel], + ) -> List[SwitchDataModel]: + """Build proposed switch models from discovery and inventory data. + + Args: + proposed_config: Validated switch config entries. + discovered_data: Mapping of seed IP to raw discovery data. + existing: Current fabric inventory snapshot. + + Returns: + List of ``SwitchDataModel`` instances for proposed state. + """ + log = self.ctx.log + proposed: List[SwitchDataModel] = [] + + for cfg in proposed_config: + seed_ip = cfg.seed_ip + discovered = discovered_data.get(seed_ip) + + if discovered: + if cfg.role is not None: + discovered["role"] = cfg.role + proposed.append( + SwitchDataModel.from_response(discovered) + ) + log.debug(f"Built proposed model from discovery for {seed_ip}") + continue + + # Fallback: switch may already be in the fabric inventory + existing_match = next( + (sw for sw in existing if sw.fabric_management_ip == seed_ip), + None, + ) + if existing_match: + proposed.append(existing_match) + log.warning( + f"Switch {seed_ip} not discovered but found in existing " + f"inventory — using existing record for comparison" + ) + continue + + msg = ( + f"Switch with seed IP {seed_ip} not discovered " + f"and not found in existing inventory." + ) + log.error(msg) + self.ctx.nd.module.fail_json(msg=msg) + + return proposed + + +# ========================================================================= +# Bulk Fabric Operations +# ========================================================================= + +class SwitchFabricOps: + """Run fabric mutation operations for add, delete, credentials, and roles.""" + + def __init__(self, ctx: SwitchServiceContext, fabric_utils: FabricUtils): + """Initialize the fabric operation service. + + Args: + ctx: Shared service context. + fabric_utils: Utility wrapper for fabric-level operations. + + Returns: + None. + """ + self.ctx = ctx + self.fabric_utils = fabric_utils + + def bulk_add( + self, + switches: List[Tuple[SwitchConfigModel, Dict[str, Any]]], + username: str, + password: str, + auth_proto: SnmpV3AuthProtocol, + platform_type: PlatformType, + preserve_config: bool, + ) -> Dict[str, Any]: + """Add multiple discovered switches to the fabric. + + Args: + switches: List of ``(SwitchConfigModel, discovered_data)`` tuples. + username: Discovery username. + password: Discovery password. + auth_proto: SNMP v3 authentication protocol. + platform_type: Platform type. + preserve_config: Whether to preserve existing switch config. + + Returns: + API response payload. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + + log.debug("ENTER: bulk_add()") + log.debug(f"Adding {len(switches)} switches to fabric") + + endpoint = V1ManageFabricSwitchesPost() + endpoint.fabric_name = self.ctx.fabric + + switch_discoveries = [] + for switch_config, discovered in switches: + required_fields = ["hostname", "ip", "serialNumber", "model"] + missing_fields = [f for f in required_fields if not discovered.get(f)] + + if missing_fields: + msg = ( + f"Switch missing required fields from discovery: " + f"{', '.join(missing_fields)}. Cannot add to fabric." + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + switch_role = switch_config.role if hasattr(switch_config, 'role') else None + + switch_discovery = SwitchDiscoveryModel( + hostname=discovered.get("hostname"), + ip=discovered.get("ip"), + serialNumber=discovered.get("serialNumber"), + model=discovered.get("model"), + softwareVersion=discovered.get("softwareVersion"), + switchRole=switch_role, + ) + switch_discoveries.append(switch_discovery) + log.debug( + f"Prepared switch for add: " + f"{discovered.get('serialNumber')} ({discovered.get('hostname')})" + ) + + if not switch_discoveries: + log.error("No valid switches to add after validation") + raise SwitchOperationError("No valid switches to add - all failed validation") + + add_request = AddSwitchesRequestModel( + switches=switch_discoveries, + platformType=platform_type, + preserveConfig=preserve_config, + snmpV3AuthProtocol=auth_proto, + username=username, + password=password, + ) + + payload = add_request.to_payload() + serial_numbers = [d.get("serialNumber") for _, d in switches] + log.info( + f"Bulk adding {len(switches)} switches to fabric " + f"{self.ctx.fabric}: {', '.join(serial_numbers)}" + ) + log.debug(f"Add endpoint: {endpoint.path}") + log.debug(f"Add payload (password masked): {mask_password(payload)}") + + try: + nd.request(path=endpoint.path, verb=endpoint.verb, data=payload) + except Exception as e: + msg = ( + f"Bulk add switches to fabric '{self.ctx.fabric}' failed " + f"for {', '.join(serial_numbers)}: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + response = nd.rest_send.response_current + result = nd.rest_send.result_current + + results.action = "create" + results.response_current = response + results.result_current = result + results.diff_current = payload + results.register_task_result() + + if not result.get("success"): + msg = ( + f"Bulk add switches failed for " + f"{', '.join(serial_numbers)}: {response}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + return response + + def bulk_delete( + self, + switches: List[Union[SwitchDataModel, SwitchDiscoveryModel]], + ) -> List[str]: + """Remove multiple switches from the fabric. + + Args: + switches: Switch models to delete. + + Returns: + List of switch identifiers submitted for deletion. + + Raises: + SwitchOperationError: Raised when the delete API call fails. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + + log.debug("ENTER: bulk_delete()") + + if nd.module.check_mode: + log.debug("Check mode: Skipping actual deletion") + return [] + + serial_numbers: List[str] = [] + for switch in switches: + sn = None + if hasattr(switch, 'switch_id'): + sn = switch.switch_id + elif hasattr(switch, 'serial_number'): + sn = switch.serial_number + + if sn: + serial_numbers.append(sn) + else: + ip = getattr(switch, 'fabric_management_ip', None) or getattr(switch, 'ip', None) + log.warning(f"Cannot delete switch {ip}: no serial number/switch_id") + + if not serial_numbers: + log.warning("No valid serial numbers found for deletion") + log.debug("EXIT: bulk_delete() - nothing to delete") + return [] + + endpoint = V1ManageFabricSwitchActionsRemovePost() + endpoint.fabric_name = self.ctx.fabric + payload = {"switchIds": serial_numbers} + + log.info( + f"Bulk removing {len(serial_numbers)} switch(es) from fabric " + f"{self.ctx.fabric}: {serial_numbers}" + ) + log.debug(f"Delete endpoint: {endpoint.path}") + log.debug(f"Delete payload: {payload}") + + try: + nd.request(path=endpoint.path, verb=endpoint.verb, data=payload) + + response = nd.rest_send.response_current + result = nd.rest_send.result_current + + results.action = "delete" + results.response_current = response + results.result_current = result + results.diff_current = {"deleted": serial_numbers} + results.register_task_result() + + log.info(f"Bulk delete submitted for {len(serial_numbers)} switch(es)") + log.debug("EXIT: bulk_delete()") + return serial_numbers + + except Exception as e: + log.error(f"Bulk delete failed: {e}") + raise SwitchOperationError( + f"Bulk delete failed for {serial_numbers}: {e}" + ) from e + + def bulk_save_credentials( + self, + switch_actions: List[Tuple[str, SwitchConfigModel]], + ) -> None: + """Save switch credentials grouped by username and password. + + Args: + switch_actions: ``(switch_id, SwitchConfigModel)`` pairs. + + Returns: + None. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + + log.debug("ENTER: bulk_save_credentials()") + + cred_groups: Dict[Tuple[str, str], List[str]] = {} + for sn, cfg in switch_actions: + if not cfg.user_name or not cfg.password: + log.debug(f"Skipping credentials for {sn}: missing user_name or password") + continue + key = (cfg.user_name, cfg.password) + cred_groups.setdefault(key, []).append(sn) + + if not cred_groups: + log.debug("EXIT: bulk_save_credentials() - no credentials to save") + return + + endpoint = V1ManageCredentialsSwitchesPost() + + for (username, password), serial_numbers in cred_groups.items(): + creds_request = SwitchCredentialsRequestModel( + switchIds=serial_numbers, + switchUsername=username, + switchPassword=password, + ) + payload = creds_request.to_payload() + + log.info( + f"Saving credentials for {len(serial_numbers)} switch(es): {serial_numbers}" + ) + log.debug(f"Credentials endpoint: {endpoint.path}") + log.debug( + f"Credentials payload (masked): {mask_password(payload)}" + ) + + try: + nd.request(path=endpoint.path, verb=endpoint.verb, data=payload) + + response = nd.rest_send.response_current + result = nd.rest_send.result_current + + results.action = "save_credentials" + results.response_current = response + results.result_current = result + results.diff_current = { + "switchIds": serial_numbers, + "username": username, + } + results.register_task_result() + log.info(f"Credentials saved for {len(serial_numbers)} switch(es)") + except Exception as e: + msg = ( + f"Failed to save credentials for " + f"switches {serial_numbers}: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + log.debug("EXIT: bulk_save_credentials()") + + def bulk_update_roles( + self, + switch_actions: List[Tuple[str, SwitchConfigModel]], + ) -> None: + """Update switch roles in bulk. + + Args: + switch_actions: ``(switch_id, SwitchConfigModel)`` pairs. + + Returns: + None. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + + log.debug("ENTER: bulk_update_roles()") + + switch_roles = [] + for sn, cfg in switch_actions: + role = get_switch_field(cfg, ['role']) + if not role: + continue + role_value = role.value if isinstance(role, SwitchRole) else str(role) + switch_roles.append({"switchId": sn, "role": role_value}) + + if not switch_roles: + log.debug("EXIT: bulk_update_roles() - no roles to update") + return + + endpoint = V1ManageFabricSwitchActionsChangeRolesPost() + endpoint.fabric_name = self.ctx.fabric + payload = {"switchRoles": switch_roles} + + log.info(f"Bulk updating roles for {len(switch_roles)} switch(es)") + log.debug(f"ChangeRoles endpoint: {endpoint.path}") + log.debug(f"ChangeRoles payload: {payload}") + + try: + nd.request(path=endpoint.path, verb=endpoint.verb, data=payload) + + response = nd.rest_send.response_current + result = nd.rest_send.result_current + + results.action = "update_role" + results.response_current = response + results.result_current = result + results.diff_current = payload + results.register_task_result() + log.info(f"Roles updated for {len(switch_roles)} switch(es)") + except Exception as e: + msg = ( + f"Failed to bulk update roles for switches: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + log.debug("EXIT: bulk_update_roles()") + + def finalize(self) -> None: + """Run optional save and deploy actions for the fabric. + + Uses service context flags to decide whether save and deploy should be + executed. No-op in check mode. + + Returns: + None. + """ + if self.ctx.nd.module.check_mode: + return + + if self.ctx.save_config: + self.ctx.log.info("Saving fabric configuration") + self.fabric_utils.save_config() + + if self.ctx.deploy_config: + self.ctx.log.info("Deploying fabric configuration") + self.fabric_utils.deploy_config() + + def post_add_processing( + self, + switch_actions: List[Tuple[str, SwitchConfigModel]], + wait_utils, + context: str, + all_preserve_config: bool = False, + skip_greenfield_check: bool = False, + update_roles: bool = False, + ) -> None: + """Run post-add tasks for newly processed switches. + + Args: + switch_actions: ``(switch_id, SwitchConfigModel)`` pairs. + wait_utils: Wait utility used for manageability checks. + context: Label used in logs and error messages. + all_preserve_config: Whether to use preserve-config wait behavior. + skip_greenfield_check: Whether to skip greenfield wait shortcut. + update_roles: Whether to apply bulk role updates. + + Returns: + None. + """ + nd = self.ctx.nd + log = self.ctx.log + all_serials = [sn for sn, _ in switch_actions] + + log.info( + f"Waiting for {len(all_serials)} {context} " + f"switch(es) to become manageable: {all_serials}" + ) + + wait_kwargs: Dict[str, Any] = {} + if all_preserve_config: + wait_kwargs["all_preserve_config"] = True + if skip_greenfield_check: + wait_kwargs["skip_greenfield_check"] = True + + success = wait_utils.wait_for_switch_manageable( + all_serials, + **wait_kwargs, + ) + if not success: + msg = ( + f"One or more {context} switches failed to become " + f"manageable in fabric '{self.ctx.fabric}'. " + f"Switches: {all_serials}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + self.bulk_save_credentials(switch_actions) + + if update_roles: + self.bulk_update_roles(switch_actions) + + try: + self.finalize() + except Exception as e: + msg = ( + f"Failed to finalize (config-save/deploy) for " + f"{context} switches {all_serials}: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + +# ========================================================================= +# POAP Handler (Bootstrap / Pre-Provision) +# ========================================================================= + +class POAPHandler: + """Handle POAP workflows for bootstrap, pre-provision, and serial swap.""" + + def __init__( + self, + ctx: SwitchServiceContext, + fabric_ops: SwitchFabricOps, + wait_utils: SwitchWaitUtils, + ): + """Initialize the POAP workflow handler. + + Args: + ctx: Shared service context. + fabric_ops: Fabric operation service. + wait_utils: Switch wait utility service. + + Returns: + None. + """ + self.ctx = ctx + self.fabric_ops = fabric_ops + self.wait_utils = wait_utils + + def handle( + self, + proposed_config: List[SwitchConfigModel], + existing: Optional[List[SwitchDataModel]] = None, + ) -> None: + """Execute POAP processing for the provided switch configs. + + Args: + proposed_config: Validated switch configs for POAP operations. + existing: Current fabric inventory snapshot. + + Returns: + None. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + + log.debug("ENTER: POAPHandler.handle()") + log.info(f"Processing POAP for {len(proposed_config)} switch config(s)") + + # Check mode — preview only + if nd.module.check_mode: + log.info("Check mode: would run POAP bootstrap / pre-provision") + results.action = "poap" + results.response_current = {"MESSAGE": "check mode — skipped"} + results.result_current = {"success": True, "changed": True} + results.diff_current = { + "poap_switches": [pc.seed_ip for pc in proposed_config] + } + results.register_task_result() + return + + # Classify entries + bootstrap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]] = [] + preprov_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]] = [] + swap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]] = [] + + for switch_cfg in proposed_config: + if not switch_cfg.poap: + log.warning( + f"Switch config for {switch_cfg.seed_ip} has no POAP block — skipping" + ) + continue + + for poap_cfg in switch_cfg.poap: + if poap_cfg.serial_number and poap_cfg.preprovision_serial: + swap_entries.append((switch_cfg, poap_cfg)) + elif poap_cfg.preprovision_serial: + preprov_entries.append((switch_cfg, poap_cfg)) + elif poap_cfg.serial_number: + bootstrap_entries.append((switch_cfg, poap_cfg)) + else: + log.warning( + f"POAP entry for {switch_cfg.seed_ip} has neither " + f"serial_number nor preprovision_serial — skipping" + ) + + log.info( + f"POAP classification: {len(bootstrap_entries)} bootstrap, " + f"{len(preprov_entries)} pre-provision, " + f"{len(swap_entries)} swap" + ) + + # Handle swap entries (change serial number on pre-provisioned switches) + if swap_entries: + self._handle_poap_swap(swap_entries, existing or []) + + # Handle bootstrap entries + if bootstrap_entries: + self._handle_poap_bootstrap(bootstrap_entries) + + # Handle pre-provision entries + if preprov_entries: + preprov_models: List[PreProvisionSwitchModel] = [] + for switch_cfg, poap_cfg in preprov_entries: + pp_model = self._build_preprovision_model(switch_cfg, poap_cfg) + preprov_models.append(pp_model) + log.info( + f"Built pre-provision model for serial=" + f"{pp_model.serial_number}, hostname={pp_model.hostname}, " + f"ip={pp_model.ip}" + ) + + if preprov_models: + self._preprovision_switches(preprov_models) + + # Edge case: nothing actionable + if not bootstrap_entries and not preprov_entries and not swap_entries: + log.warning("No POAP switch models built — nothing to process") + results.action = "poap" + results.response_current = {"MESSAGE": "no switches to process"} + results.result_current = {"success": True, "changed": False} + results.diff_current = {} + results.register_task_result() + + log.debug("EXIT: POAPHandler.handle()") + + def _handle_poap_bootstrap( + self, + bootstrap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]], + ) -> None: + """Process bootstrap POAP entries. + + Args: + bootstrap_entries: ``(SwitchConfigModel, POAPConfigModel)`` pairs + for bootstrap operations. + + Returns: + None. + """ + nd = self.ctx.nd + log = self.ctx.log + + log.debug("ENTER: _handle_poap_bootstrap()") + log.info(f"Processing {len(bootstrap_entries)} bootstrap entries") + + bootstrap_switches = query_bootstrap_switches(nd, self.ctx.fabric, log) + bootstrap_idx = build_bootstrap_index(bootstrap_switches) + log.debug( + f"Bootstrap index contains {len(bootstrap_idx)} switch(es): " + f"{list(bootstrap_idx.keys())}" + ) + + import_models: List[BootstrapImportSwitchModel] = [] + for switch_cfg, poap_cfg in bootstrap_entries: + serial = poap_cfg.serial_number + bootstrap_data = bootstrap_idx.get(serial) + + if not bootstrap_data: + msg = ( + f"Serial {serial} not found in bootstrap API " + f"response. The switch is not in the POAP loop. " + f"Ensure the switch is powered on and POAP/DHCP " + f"is enabled in the fabric." + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + model = self._build_bootstrap_import_model( + switch_cfg, poap_cfg, bootstrap_data + ) + import_models.append(model) + log.info( + f"Built bootstrap model for serial={serial}, " + f"hostname={model.hostname}, ip={model.ip}" + ) + + if not import_models: + log.warning("No bootstrap import models built") + log.debug("EXIT: _handle_poap_bootstrap()") + return + + self._import_bootstrap_switches(import_models) + + # Post-import: wait for manageability, save credentials, finalize + switch_actions: List[Tuple[str, SwitchConfigModel]] = [] + for switch_cfg, poap_cfg in bootstrap_entries: + switch_actions.append((poap_cfg.serial_number, switch_cfg)) + + self.fabric_ops.post_add_processing( + switch_actions, + wait_utils=self.wait_utils, + context="bootstrap", + skip_greenfield_check=True, + ) + + log.debug("EXIT: _handle_poap_bootstrap()") + + def _build_bootstrap_import_model( + self, + switch_cfg: SwitchConfigModel, + poap_cfg: POAPConfigModel, + bootstrap_data: Optional[Dict[str, Any]], + ) -> BootstrapImportSwitchModel: + """Build a bootstrap import model from config and bootstrap data. + + Args: + switch_cfg: Parent switch config. + poap_cfg: POAP config entry. + bootstrap_data: Matching bootstrap response entry. + + Returns: + Completed ``BootstrapImportSwitchModel`` for API submission. + """ + log = self.ctx.log + log.debug( + f"ENTER: _build_bootstrap_import_model(serial={poap_cfg.serial_number})" + ) + + bs = bootstrap_data or {} + + # User config fields + serial_number = poap_cfg.serial_number + hostname = poap_cfg.hostname + ip = switch_cfg.seed_ip + model = poap_cfg.model + version = poap_cfg.version + image_policy = poap_cfg.image_policy + gateway_ip_mask = poap_cfg.config_data.gateway if poap_cfg.config_data else None + switch_role = switch_cfg.role + password = switch_cfg.password + auth_proto = SnmpV3AuthProtocol.MD5 # POAP/bootstrap always uses MD5 + + discovery_username = getattr(poap_cfg, "discovery_username", None) + discovery_password = getattr(poap_cfg, "discovery_password", None) + + # Bootstrap API response fields + fingerprint = bs.get("fingerPrint", bs.get("fingerprint", "")) + public_key = bs.get("publicKey", "") + re_add = bs.get("reAdd", False) + in_inventory = bs.get("inInventory", False) + + # Shared data block builder + data_block = build_poap_data_block(poap_cfg) + + bootstrap_model = BootstrapImportSwitchModel( + serialNumber=serial_number, + model=model, + version=version, + hostname=hostname, + ipAddress=ip, + password=password, + discoveryAuthProtocol=auth_proto, + discoveryUsername=discovery_username, + discoveryPassword=discovery_password, + data=data_block, + fingerprint=fingerprint, + publicKey=public_key, + reAdd=re_add, + inInventory=in_inventory, + imagePolicy=image_policy or "", + switchRole=switch_role, + ip=ip, + softwareVersion=version, + gatewayIpMask=gateway_ip_mask, + ) + + log.debug( + f"EXIT: _build_bootstrap_import_model() -> {bootstrap_model.serial_number}" + ) + return bootstrap_model + + def _import_bootstrap_switches( + self, + models: List[BootstrapImportSwitchModel], + ) -> None: + """Submit bootstrap import models. + + Args: + models: ``BootstrapImportSwitchModel`` objects to submit. + + Returns: + None. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + + log.debug("ENTER: _import_bootstrap_switches()") + + endpoint = V1ManageFabricSwitchActionsImportBootstrapPost() + endpoint.fabric_name = self.ctx.fabric + + request_model = ImportBootstrapSwitchesRequestModel(switches=models) + payload = request_model.to_payload() + + log.debug(f"importBootstrap endpoint: {endpoint.path}") + log.debug( + f"importBootstrap payload (masked): {mask_password(payload)}" + ) + log.info( + f"Importing {len(models)} bootstrap switch(es): " + f"{[m.serial_number for m in models]}" + ) + + try: + nd.request(path=endpoint.path, verb=endpoint.verb, data=payload) + except Exception as e: + msg = ( + f"importBootstrap API call failed for " + f"{[m.serial_number for m in models]}: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + response = nd.rest_send.response_current + result = nd.rest_send.result_current + + results.action = "bootstrap" + results.response_current = response + results.result_current = result + results.diff_current = payload + results.register_task_result() + + if not result.get("success"): + msg = ( + f"importBootstrap failed for " + f"{[m.serial_number for m in models]}: {response}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + log.info(f"importBootstrap API response success: {result.get('success')}") + log.debug("EXIT: _import_bootstrap_switches()") + + def _build_preprovision_model( + self, + switch_cfg: SwitchConfigModel, + poap_cfg: POAPConfigModel, + ) -> PreProvisionSwitchModel: + """Build a pre-provision model from POAP configuration. + + Args: + switch_cfg: Parent switch config. + poap_cfg: POAP config entry. + + Returns: + Completed ``PreProvisionSwitchModel`` for API submission. + """ + log = self.ctx.log + log.debug( + f"ENTER: _build_preprovision_model(serial={poap_cfg.preprovision_serial})" + ) + + serial_number = poap_cfg.preprovision_serial + hostname = poap_cfg.hostname + ip = switch_cfg.seed_ip + model_name = poap_cfg.model + version = poap_cfg.version + image_policy = poap_cfg.image_policy + gateway_ip_mask = poap_cfg.config_data.gateway if poap_cfg.config_data else None + switch_role = switch_cfg.role + password = switch_cfg.password + auth_proto = SnmpV3AuthProtocol.MD5 # Pre-provision always uses MD5 + + discovery_username = getattr(poap_cfg, "discovery_username", None) + discovery_password = getattr(poap_cfg, "discovery_password", None) + + # Shared data block builder + data_block = build_poap_data_block(poap_cfg) + + preprov_model = PreProvisionSwitchModel( + serialNumber=serial_number, + hostname=hostname, + ip=ip, + model=model_name, + softwareVersion=version, + gatewayIpMask=gateway_ip_mask, + password=password, + discoveryAuthProtocol=auth_proto, + discoveryUsername=discovery_username, + discoveryPassword=discovery_password, + data=data_block, + imagePolicy=image_policy or None, + switchRole=switch_role, + ) + + log.debug( + f"EXIT: _build_preprovision_model() -> {preprov_model.serial_number}" + ) + return preprov_model + + def _preprovision_switches( + self, + models: List[PreProvisionSwitchModel], + ) -> None: + """Submit pre-provision switch models. + + Args: + models: ``PreProvisionSwitchModel`` objects to submit. + + Returns: + None. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + + log.debug("ENTER: _preprovision_switches()") + + endpoint = V1ManageFabricSwitchActionsPreProvisionPost() + endpoint.fabric_name = self.ctx.fabric + + request_model = PreProvisionSwitchesRequestModel(switches=models) + payload = request_model.to_payload() + + log.debug(f"preProvision endpoint: {endpoint.path}") + log.debug( + f"preProvision payload (masked): {mask_password(payload)}" + ) + log.info( + f"Pre-provisioning {len(models)} switch(es): " + f"{[m.serial_number for m in models]}" + ) + + try: + nd.request(path=endpoint.path, verb=endpoint.verb, data=payload) + except Exception as e: + msg = ( + f"preProvision API call failed for " + f"{[m.serial_number for m in models]}: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + response = nd.rest_send.response_current + result = nd.rest_send.result_current + + results.action = "preprovision" + results.response_current = response + results.result_current = result + results.diff_current = payload + results.register_task_result() + + if not result.get("success"): + msg = ( + f"preProvision failed for " + f"{[m.serial_number for m in models]}: {response}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + log.info(f"preProvision API response success: {result.get('success')}") + log.debug("EXIT: _preprovision_switches()") + + def _handle_poap_swap( + self, + swap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]], + existing: List[SwitchDataModel], + ) -> None: + """Process POAP serial-swap entries. + + Args: + swap_entries: ``(SwitchConfigModel, POAPConfigModel)`` swap pairs. + existing: Current fabric inventory snapshot. + + Returns: + None. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + fabric = self.ctx.fabric + + log.debug("ENTER: _handle_poap_swap()") + log.info(f"Processing {len(swap_entries)} POAP swap entries") + + # ------------------------------------------------------------------ + # Step 1: Validate preprovision serials exist in fabric inventory + # ------------------------------------------------------------------ + fabric_index: Dict[str, Dict[str, Any]] = { + sw.switch_id: sw.model_dump(by_alias=True) + for sw in existing + if sw.switch_id + } + log.debug( + f"Fabric inventory contains {len(fabric_index)} switch(es): " + f"{list(fabric_index.keys())}" + ) + + for switch_cfg, poap_cfg in swap_entries: + old_serial = poap_cfg.preprovision_serial + if old_serial not in fabric_index: + msg = ( + f"Pre-provisioned serial '{old_serial}' not found in " + f"fabric '{fabric}' inventory. The switch must be " + f"pre-provisioned before a swap can be performed." + ) + log.error(msg) + nd.module.fail_json(msg=msg) + log.info( + f"Validated: pre-provisioned serial '{old_serial}' exists " + f"in fabric inventory" + ) + + # ------------------------------------------------------------------ + # Step 2: Validate new serials exist in bootstrap list + # ------------------------------------------------------------------ + bootstrap_switches = query_bootstrap_switches(nd, fabric, log) + bootstrap_index = build_bootstrap_index(bootstrap_switches) + log.debug( + f"Bootstrap list contains {len(bootstrap_index)} switch(es): " + f"{list(bootstrap_index.keys())}" + ) + + for switch_cfg, poap_cfg in swap_entries: + new_serial = poap_cfg.serial_number + if new_serial not in bootstrap_index: + msg = ( + f"New serial '{new_serial}' not found in the bootstrap " + f"(POAP) list for fabric '{fabric}'. The physical " + f"switch must be in the POAP loop before a swap can be " + f"performed." + ) + log.error(msg) + nd.module.fail_json(msg=msg) + log.info( + f"Validated: new serial '{new_serial}' exists in " + f"bootstrap list" + ) + + # ------------------------------------------------------------------ + # Step 3: Call changeSwitchSerialNumber for each swap entry + # ------------------------------------------------------------------ + for switch_cfg, poap_cfg in swap_entries: + old_serial = poap_cfg.preprovision_serial + new_serial = poap_cfg.serial_number + + log.info( + f"Swapping serial for pre-provisioned switch: " + f"{old_serial} → {new_serial}" + ) + + endpoint = V1ManageFabricSwitchChangeSerialNumberPost() + endpoint.fabric_name = fabric + endpoint.switch_sn = old_serial + + request_body = ChangeSwitchSerialNumberRequestModel( + newSwitchId=new_serial + ) + payload = request_body.to_payload() + + log.debug(f"changeSwitchSerialNumber endpoint: {endpoint.path}") + log.debug(f"changeSwitchSerialNumber payload: {payload}") + + try: + nd.request( + path=endpoint.path, verb=endpoint.verb, data=payload + ) + except Exception as e: + msg = ( + f"changeSwitchSerialNumber API call failed for " + f"{old_serial} → {new_serial}: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + response = nd.rest_send.response_current + result = nd.rest_send.result_current + + results.action = "swap_serial" + results.response_current = response + results.result_current = result + results.diff_current = { + "old_serial": old_serial, + "new_serial": new_serial, + } + results.register_task_result() + + if not result.get("success"): + msg = ( + f"Failed to swap serial number from {old_serial} " + f"to {new_serial}: {response}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + log.info( + f"Serial number swap successful: {old_serial} → {new_serial}" + ) + + # ------------------------------------------------------------------ + # Step 4: Re-query bootstrap API for post-swap data + # ------------------------------------------------------------------ + post_swap_bootstrap = query_bootstrap_switches(nd, fabric, log) + post_swap_index = build_bootstrap_index(post_swap_bootstrap) + log.debug( + f"Post-swap bootstrap list contains " + f"{len(post_swap_index)} switch(es)" + ) + + # ------------------------------------------------------------------ + # Step 5: Build BootstrapImportSwitchModels and POST importBootstrap + # ------------------------------------------------------------------ + import_models: List[BootstrapImportSwitchModel] = [] + for switch_cfg, poap_cfg in swap_entries: + new_serial = poap_cfg.serial_number + bootstrap_data = post_swap_index.get(new_serial) + + if not bootstrap_data: + msg = ( + f"Serial '{new_serial}' not found in bootstrap API " + f"response after swap. The controller may not have " + f"updated the bootstrap list yet." + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + model = self._build_bootstrap_import_model( + switch_cfg, poap_cfg, bootstrap_data + ) + import_models.append(model) + log.info( + f"Built bootstrap model for swapped serial={new_serial}, " + f"hostname={model.hostname}, ip={model.ip}" + ) + + if not import_models: + log.warning("No bootstrap import models built after swap") + log.debug("EXIT: _handle_poap_swap()") + return + + try: + self._import_bootstrap_switches(import_models) + except Exception as e: + msg = ( + f"importBootstrap failed after serial swap: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + # ------------------------------------------------------------------ + # Step 6: Wait for manageability, save credentials, finalize + # ------------------------------------------------------------------ + switch_actions: List[Tuple[str, SwitchConfigModel]] = [] + for switch_cfg, poap_cfg in swap_entries: + switch_actions.append((poap_cfg.serial_number, switch_cfg)) + + self.fabric_ops.post_add_processing( + switch_actions, + wait_utils=self.wait_utils, + context="swap", + skip_greenfield_check=True, + ) + + log.info( + f"POAP swap completed successfully for {len(swap_entries)} " + f"switch(es): {[sn for sn, _ in switch_actions]}" + ) + log.debug("EXIT: _handle_poap_swap()") + + +# ========================================================================= +# RMA Handler (Return Material Authorization) +# ========================================================================= + +class RMAHandler: + """Handle RMA workflows for switch replacement.""" + + def __init__( + self, + ctx: SwitchServiceContext, + fabric_ops: SwitchFabricOps, + wait_utils: SwitchWaitUtils, + ): + """Initialize the RMA workflow handler. + + Args: + ctx: Shared service context. + fabric_ops: Fabric operation service. + wait_utils: Switch wait utility service. + + Returns: + None. + """ + self.ctx = ctx + self.fabric_ops = fabric_ops + self.wait_utils = wait_utils + + def handle( + self, + proposed_config: List[SwitchConfigModel], + existing: List[SwitchDataModel], + ) -> None: + """Execute RMA processing for the provided switch configs. + + Args: + proposed_config: Validated switch configs for RMA operations. + existing: Current fabric inventory snapshot. + + Returns: + None. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + + log.debug("ENTER: RMAHandler.handle()") + log.info(f"Processing RMA for {len(proposed_config)} switch config(s)") + + # Check mode — preview only + if nd.module.check_mode: + log.info("Check mode: would run RMA provision") + results.action = "rma" + results.response_current = {"MESSAGE": "check mode — skipped"} + results.result_current = {"success": True, "changed": True} + results.diff_current = { + "rma_switches": [pc.seed_ip for pc in proposed_config] + } + results.register_task_result() + return + + # Collect (SwitchConfigModel, RMAConfigModel) pairs + rma_entries: List[Tuple[SwitchConfigModel, RMAConfigModel]] = [] + for switch_cfg in proposed_config: + if not switch_cfg.rma: + log.warning( + f"Switch config for {switch_cfg.seed_ip} has no RMA block — skipping" + ) + continue + for rma_cfg in switch_cfg.rma: + rma_entries.append((switch_cfg, rma_cfg)) + + if not rma_entries: + log.warning("No RMA entries found — nothing to process") + results.action = "rma" + results.response_current = {"MESSAGE": "no switches to process"} + results.result_current = {"success": True, "changed": False} + results.diff_current = {} + results.register_task_result() + return + + log.info(f"Found {len(rma_entries)} RMA entry/entries to process") + + # Validate old switches exist and are in correct state + old_switch_info = self._validate_prerequisites(rma_entries, existing) + + # Query bootstrap API for publicKey / fingerPrint of new switches + bootstrap_switches = query_bootstrap_switches(nd, self.ctx.fabric, log) + bootstrap_idx = build_bootstrap_index(bootstrap_switches) + log.debug( + f"Bootstrap index contains {len(bootstrap_idx)} switch(es): " + f"{list(bootstrap_idx.keys())}" + ) + + # Build and submit each RMA request + switch_actions: List[Tuple[str, SwitchConfigModel]] = [] + for switch_cfg, rma_cfg in rma_entries: + new_serial = rma_cfg.serial_number + bootstrap_data = bootstrap_idx.get(new_serial) + + if not bootstrap_data: + msg = ( + f"New switch serial {new_serial} not found in " + f"bootstrap API response. The switch is not in the " + f"POAP loop. Ensure the replacement switch is powered " + f"on and POAP/DHCP is enabled in the fabric." + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + rma_model = self._build_rma_model( + switch_cfg, rma_cfg, bootstrap_data, + old_switch_info[rma_cfg.old_serial], + ) + log.info( + f"Built RMA model: replacing {rma_cfg.old_serial} with " + f"{rma_model.new_switch_id}" + ) + + self._provision_rma_switch(rma_cfg.old_serial, rma_model) + switch_actions.append((rma_model.new_switch_id, switch_cfg)) + + # Post-processing: wait, save credentials, finalize + self.fabric_ops.post_add_processing( + switch_actions, + wait_utils=self.wait_utils, + context="RMA", + skip_greenfield_check=True, + ) + + log.debug("EXIT: RMAHandler.handle()") + + def _validate_prerequisites( + self, + rma_entries: List[Tuple[SwitchConfigModel, RMAConfigModel]], + existing: List[SwitchDataModel], + ) -> Dict[str, Dict[str, Any]]: + """Validate RMA prerequisites for each requested replacement. + + Args: + rma_entries: ``(SwitchConfigModel, RMAConfigModel)`` pairs. + existing: Current fabric inventory snapshot. + + Returns: + Dict keyed by old serial with prerequisite metadata. + """ + nd = self.ctx.nd + log = self.ctx.log + + log.debug("ENTER: _validate_prerequisites()") + + existing_by_serial: Dict[str, SwitchDataModel] = { + sw.serial_number: sw for sw in existing if sw.serial_number + } + + result: Dict[str, Dict[str, Any]] = {} + + for switch_cfg, rma_cfg in rma_entries: + old_serial = rma_cfg.old_serial + + old_switch = existing_by_serial.get(old_serial) + if old_switch is None: + nd.module.fail_json( + msg=( + f"RMA: old_serial '{old_serial}' not found in " + f"fabric '{self.ctx.fabric}'. The switch being " + f"replaced must exist in the inventory." + ) + ) + + ad = old_switch.additional_data + if ad is None: + nd.module.fail_json( + msg=( + f"RMA: Switch '{old_serial}' has no additional data " + f"in the inventory response. Cannot verify discovery " + f"status and system mode." + ) + ) + + if ad.discovery_status != DiscoveryStatus.UNREACHABLE.value: + nd.module.fail_json( + msg=( + f"RMA: Switch '{old_serial}' has discovery status " + f"'{ad.discovery_status or 'unknown'}', " + f"expected 'unreachable'. The old switch must be " + f"unreachable before RMA can proceed." + ) + ) + + if ad.system_mode != SystemMode.MAINTENANCE.value: + nd.module.fail_json( + msg=( + f"RMA: Switch '{old_serial}' is in " + f"'{ad.system_mode or 'unknown'}' " + f"mode, expected 'maintenance'. Put the switch in " + f"maintenance mode before initiating RMA." + ) + ) + + result[old_serial] = { + "hostname": old_switch.hostname or "", + "switch_data": old_switch, + } + log.info( + f"RMA prerequisite check passed for old_serial " + f"'{old_serial}' (hostname={old_switch.hostname}, " + f"discovery={ad.discovery_status}, mode={ad.system_mode})" + ) + + log.debug("EXIT: _validate_prerequisites()") + return result + + def _build_rma_model( + self, + switch_cfg: SwitchConfigModel, + rma_cfg: RMAConfigModel, + bootstrap_data: Dict[str, Any], + old_switch_info: Dict[str, Any], + ) -> RMASwitchModel: + """Build an RMA model from config and bootstrap data. + + Args: + switch_cfg: Parent switch config. + rma_cfg: RMA config entry. + bootstrap_data: Bootstrap response entry for the replacement switch. + old_switch_info: Prerequisite metadata for the switch being replaced. + + Returns: + Completed ``RMASwitchModel`` for API submission. + """ + log = self.ctx.log + log.debug( + f"ENTER: _build_rma_model(new={rma_cfg.serial_number}, " + f"old={rma_cfg.old_serial})" + ) + + # User config fields + new_switch_id = rma_cfg.serial_number + hostname = old_switch_info.get("hostname", "") + ip = switch_cfg.seed_ip + model_name = rma_cfg.model + version = rma_cfg.version + image_policy = rma_cfg.image_policy + gateway_ip_mask = rma_cfg.config_data.gateway + switch_role = switch_cfg.role + password = switch_cfg.password + auth_proto = SnmpV3AuthProtocol.MD5 # RMA always uses MD5 + + discovery_username = rma_cfg.discovery_username + discovery_password = rma_cfg.discovery_password + + # Bootstrap API response fields + public_key = bootstrap_data.get("publicKey", "") + finger_print = bootstrap_data.get( + "fingerPrint", bootstrap_data.get("fingerprint", "") + ) + + rma_model = RMASwitchModel( + gatewayIpMask=gateway_ip_mask, + model=model_name, + softwareVersion=version, + imagePolicy=image_policy, + switchRole=switch_role, + password=password, + discoveryAuthProtocol=auth_proto, + discoveryUsername=discovery_username, + discoveryPassword=discovery_password, + hostname=hostname, + ip=ip, + newSwitchId=new_switch_id, + publicKey=public_key, + fingerPrint=finger_print, + ) + + log.debug( + f"EXIT: _build_rma_model() -> newSwitchId={rma_model.new_switch_id}" + ) + return rma_model + + def _provision_rma_switch( + self, + old_switch_id: str, + rma_model: RMASwitchModel, + ) -> None: + """Submit an RMA provisioning request for one switch. + + Args: + old_switch_id: Identifier of the switch being replaced. + rma_model: RMA model for the replacement switch. + + Returns: + None. + """ + nd = self.ctx.nd + log = self.ctx.log + results = self.ctx.results + + log.debug("ENTER: _provision_rma_switch()") + + endpoint = V1ManageFabricSwitchProvisionRMAPost() + endpoint.fabric_name = self.ctx.fabric + endpoint.switch_id = old_switch_id + + payload = rma_model.to_payload() + + log.info(f"RMA: Replacing {old_switch_id} with {rma_model.new_switch_id}") + log.debug(f"RMA endpoint: {endpoint.path}") + log.debug(f"RMA payload (masked): {mask_password(payload)}") + + try: + nd.request(path=endpoint.path, verb=endpoint.verb, data=payload) + except Exception as e: + msg = ( + f"RMA provision API call failed for " + f"{old_switch_id} → {rma_model.new_switch_id}: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + response = nd.rest_send.response_current + result = nd.rest_send.result_current + + results.action = "rma" + results.response_current = response + results.result_current = result + results.diff_current = { + "old_switch_id": old_switch_id, + "new_switch_id": rma_model.new_switch_id, + } + results.register_task_result() + + if not result.get("success"): + msg = ( + f"RMA provision failed for {old_switch_id} → " + f"{rma_model.new_switch_id}: {response}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + log.info(f"RMA provision API response success: {result.get('success')}") + log.debug("EXIT: _provision_rma_switch()") + + +# ========================================================================= +# Orchestrator (Thin State Router) +# ========================================================================= + +class NDSwitchResourceModule(): + """Orchestrate switch lifecycle management across supported states.""" + + # ===================================================================== + # Initialization & Lifecycle + # ===================================================================== + + def __init__( + self, + nd: NDModule, + results: Results, + logger: Optional[logging.Logger] = None, + ): + """Initialize module state, services, and inventory snapshots. + + Args: + nd: ND module wrapper. + results: Shared results aggregator. + logger: Optional logger instance. + + Returns: + None. + """ + log = logger or logging.getLogger("nd.NDSwitchResourceModule") + self.log = log + self.nd = nd + self.module = nd.module + self.results = results + + # Module parameters + self.config = self.module.params.get("config", {}) + self.fabric = self.module.params.get("fabric") + self.state = self.module.params.get("state") + + # Shared context for service classes + self.ctx = SwitchServiceContext( + nd=nd, + results=results, + fabric=self.fabric, + log=log, + save_config=self.module.params.get("save", True), + deploy_config=self.module.params.get("deploy", True), + ) + + # Switch collections + try: + self.proposed: List[SwitchDataModel] = [] + self.existing: List[SwitchDataModel] = [ + SwitchDataModel.model_validate(sw) + for sw in self._query_all_switches() + ] + self.previous: List[SwitchDataModel] = deepcopy(self.existing) + except Exception as e: + msg = ( + f"Failed to query fabric '{self.fabric}' inventory " + f"during initialization: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + # Operation tracking + self.nd_logs: List[Dict[str, Any]] = [] + + # Utility instances (SwitchWaitUtils / FabricUtils depend on self) + self.fabric_utils = FabricUtils(self.nd, self.fabric, log) + self.wait_utils = SwitchWaitUtils( + self, self.fabric, log, fabric_utils=self.fabric_utils + ) + + # Service instances (Dependency Injection) + self.discovery = SwitchDiscoveryService(self.ctx) + self.fabric_ops = SwitchFabricOps(self.ctx, self.fabric_utils) + self.poap_handler = POAPHandler(self.ctx, self.fabric_ops, self.wait_utils) + self.rma_handler = RMAHandler(self.ctx, self.fabric_ops, self.wait_utils) + + log.info(f"Initialized NDSwitchResourceModule for fabric: {self.fabric}") + + def exit_json(self) -> None: + """Finalize collected results and exit the Ansible module. + + Includes operation logs and previous/current inventory snapshots in the + final response payload. + + Returns: + None. + """ + self.results.build_final_result() + final = self.results.final_result + + final["logs"] = self.nd_logs + final["previous"] = ( + [sw.model_dump(by_alias=True) for sw in self.previous] + if self.previous + else [] + ) + final["current"] = ( + [sw.model_dump(by_alias=True) for sw in self.existing] + if self.existing + else [] + ) + + if True in self.results.failed: + self.nd.module.fail_json(**final) + self.nd.module.exit_json(**final) + + # ===================================================================== + # Public API – State Management + # ===================================================================== + + def manage_state(self) -> None: + """Dispatch the requested module state to the appropriate workflow. + + This method validates input, routes POAP and RMA operations to dedicated + handlers, and executes state-specific orchestration for query, merged, + overridden, and deleted operations. + + Returns: + None. + """ + self.log.info(f"Managing state: {self.state}") + + # query / deleted — config is optional + if self.state in ("query", "deleted"): + proposed_config = ( + SwitchDiffEngine.validate_configs(self.config, self.state, self.nd, self.log) + if self.config + else None + ) + if self.state == "deleted": + return self._handle_deleted_state(proposed_config) + return self._handle_query_state(proposed_config) + + # merged / overridden — config is required + if not self.config: + self.nd.module.fail_json( + msg=f"'config' is required for '{self.state}' state." + ) + + proposed_config = SwitchDiffEngine.validate_configs( + self.config, self.state, self.nd, self.log + ) + self.operation_type = proposed_config[0].operation_type + + # POAP and RMA bypass normal discovery — delegate to handlers + if self.operation_type == "poap": + return self.poap_handler.handle(proposed_config, self.existing) + if self.operation_type == "rma": + return self.rma_handler.handle(proposed_config, self.existing) + + # Normal: discover → build proposed models → compute diff → delegate + discovered_data = self.discovery.discover(proposed_config) + self.proposed = self.discovery.build_proposed( + proposed_config, discovered_data, self.existing + ) + diff = SwitchDiffEngine.compute_changes( + self.proposed, self.existing, self.log + ) + + state_handlers = { + "merged": self._handle_merged_state, + "overridden": self._handle_overridden_state, + } + handler = state_handlers.get(self.state) + if handler is None: + self.nd.module.fail_json(msg=f"Unsupported state: {self.state}") + return handler(diff, proposed_config, discovered_data) + + # ===================================================================== + # State Handlers (orchestration only — delegate to services) + # ===================================================================== + + def _handle_query_state( + self, + proposed_config: Optional[List[SwitchConfigModel]] = None, + ) -> None: + """Return inventory switches matching the optional proposed config. + + Args: + proposed_config: Optional filter config list for matching switches. + + Returns: + None. + """ + self.log.debug("ENTER: _handle_query_state()") + self.log.info("Handling query state") + self.log.debug(f"Found {len(self.existing)} existing switches") + + if proposed_config is None: + matched_switches = list(self.existing) + self.log.info("No proposed config — returning all existing switches") + else: + matched_switches: List[SwitchDataModel] = [] + for cfg in proposed_config: + match = next( + ( + sw for sw in self.existing + if sw.fabric_management_ip == cfg.seed_ip + ), + None, + ) + if match is None: + self.log.info(f"Switch {cfg.seed_ip} not found in fabric") + continue + + if cfg.role is not None and match.switch_role != cfg.role: + self.log.info( + f"Switch {cfg.seed_ip} found but role mismatch: " + f"expected {cfg.role.value}, got " + f"{match.switch_role.value if match.switch_role else 'None'}" + ) + continue + + matched_switches.append(match) + + self.log.info( + f"Matched {len(matched_switches)}/{len(proposed_config)} " + f"switch(es) from proposed config" + ) + + switch_data = [sw.model_dump(by_alias=True) for sw in matched_switches] + + self.results.action = "query" + self.results.state = self.state + self.results.check_mode = self.nd.module.check_mode + self.results.operation_type = OperationType.QUERY + self.results.response_current = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": switch_data, + } + self.results.result_current = { + "found": len(matched_switches) > 0, + "success": True, + } + self.results.diff_current = {} + self.results.register_task_result() + + self.log.debug(f"Returning {len(switch_data)} switches in results") + self.log.debug("EXIT: _handle_query_state()") + + def _handle_merged_state( + self, + diff: Dict[str, List[SwitchDataModel]], + proposed_config: List[SwitchConfigModel], + discovered_data: Optional[Dict[str, Dict[str, Any]]] = None, + ) -> None: + """Handle merged-state add and migration workflows. + + Args: + diff: Categorized switch diff output. + proposed_config: Validated switch config list. + discovered_data: Optional discovery data by seed IP. + + Returns: + None. + """ + self.log.debug("ENTER: _handle_merged_state()") + self.log.info("Handling merged state") + self.log.debug(f"Proposed configs: {len(self.proposed)}") + self.log.debug(f"Existing switches: {len(self.existing)}") + + if not self.proposed: + self.log.info("No configurations provided for merged state") + self.log.debug("EXIT: _handle_merged_state() - no configs") + return + + config_by_ip = {sw.seed_ip: sw for sw in proposed_config} + + # Phase 1: Log idempotent switches + for sw in diff.get("idempotent", []): + self.log.info( + f"Switch {sw.fabric_management_ip} ({sw.switch_id}) " + f"is idempotent - no changes needed" + ) + + # Phase 2: Warn about to_update (merged state doesn't support updates) + if diff.get("to_update"): + ips = [sw.fabric_management_ip for sw in diff["to_update"]] + self.log.warning( + f"Switches require updates which is not supported in merged state. " + f"Use overridden state for updates. Affected switches: {ips}" + ) + + switches_to_add = diff.get("to_add", []) + migration_switches = diff.get("migration_mode", []) + + if not switches_to_add and not migration_switches: + self.log.info("No switches need adding or migration processing") + return + + # Check mode — preview only + if self.nd.module.check_mode: + self.log.info( + f"Check mode: would add {len(switches_to_add)} and " + f"process {len(migration_switches)} migration switches" + ) + self.results.action = "merge" + self.results.state = self.state + self.results.operation_type = OperationType.CREATE + self.results.response_current = {"MESSAGE": "check mode — skipped", "RETURN_CODE": 200} + self.results.result_current = {"success": True, "changed": True} + self.results.diff_current = { + "to_add": [sw.fabric_management_ip for sw in switches_to_add], + "migration_mode": [sw.fabric_management_ip for sw in migration_switches], + } + self.results.register_task_result() + return + + # Collect (serial_number, SwitchConfigModel) pairs for post-processing + switch_actions: List[Tuple[str, SwitchConfigModel]] = [] + + # Phase 3: Bulk add new switches to fabric + if switches_to_add and discovered_data: + add_configs = [] + for sw in switches_to_add: + cfg = config_by_ip.get(sw.fabric_management_ip) + if cfg: + add_configs.append(cfg) + else: + self.log.warning( + f"No config found for switch {sw.fabric_management_ip}, skipping add" + ) + + if add_configs: + credential_groups = group_switches_by_credentials(add_configs, self.log) + for group_key, group_switches in credential_groups.items(): + username, password_hash, auth_proto, platform_type, preserve_config = group_key + password = group_switches[0].password + + pairs = [] + for cfg in group_switches: + disc = discovered_data.get(cfg.seed_ip) + if disc: + pairs.append((cfg, disc)) + else: + self.log.warning(f"No discovery data for {cfg.seed_ip}, skipping") + + if not pairs: + continue + + self.fabric_ops.bulk_add( + switches=pairs, + username=username, + password=password, + auth_proto=auth_proto, + platform_type=platform_type, + preserve_config=preserve_config, + ) + + for cfg, disc in pairs: + sn = disc.get("serialNumber") + if sn: + switch_actions.append((sn, cfg)) + self._log_operation("add", cfg.seed_ip) + + # Phase 4: Collect migration switches for post-processing + for mig_sw in migration_switches: + cfg = config_by_ip.get(mig_sw.fabric_management_ip) + if cfg and mig_sw.switch_id: + switch_actions.append((mig_sw.switch_id, cfg)) + self._log_operation("migrate", mig_sw.fabric_management_ip) + + if not switch_actions: + self.log.info("No switch actions to process after add/migration collection") + return + + # Common post-processing for all switches (new + migration) + # Brownfield optimisation: if every switch in this batch uses + # preserve_config=True the switches will NOT reload after being + # added to the fabric. Passing this flag lets the wait utility + # skip the unreachable/reload detection phases. + all_preserve_config = all( + cfg.preserve_config for _, cfg in switch_actions + ) + if all_preserve_config: + self.log.info( + "All switches in batch are brownfield (preserve_config=True) — " + "reload detection will be skipped" + ) + + self.fabric_ops.post_add_processing( + switch_actions, + wait_utils=self.wait_utils, + context="merged", + all_preserve_config=all_preserve_config, + update_roles=True, + ) + + self.log.debug("EXIT: _handle_merged_state() - completed") + + def _handle_overridden_state( + self, + diff: Dict[str, List[SwitchDataModel]], + proposed_config: List[SwitchConfigModel], + discovered_data: Optional[Dict[str, Dict[str, Any]]] = None, + ) -> None: + """Handle overridden-state reconciliation for the fabric. + + Args: + diff: Categorized switch diff output. + proposed_config: Validated switch config list. + discovered_data: Optional discovery data by seed IP. + + Returns: + None. + """ + self.log.debug("ENTER: _handle_overridden_state()") + self.log.info("Handling overridden state") + + if not self.proposed: + self.log.warning("No configurations provided for overridden state") + return + + # Check mode — preview only + if self.nd.module.check_mode: + n_delete = len(diff.get("to_delete", [])) + n_update = len(diff.get("to_update", [])) + n_add = len(diff.get("to_add", [])) + n_migrate = len(diff.get("migration_mode", [])) + self.log.info( + f"Check mode: would delete {n_delete}, " + f"delete-and-re-add {n_update}, " + f"add {n_add}, migrate {n_migrate}" + ) + would_change = (n_delete + n_update + n_add + n_migrate) > 0 + self.results.action = "override" + self.results.state = self.state + self.results.operation_type = OperationType.CREATE + self.results.response_current = {"MESSAGE": "check mode — skipped", "RETURN_CODE": 200} + self.results.result_current = {"success": True, "changed": would_change} + self.results.diff_current = { + "to_delete": n_delete, + "to_update": n_update, + "to_add": n_add, + "migration_mode": n_migrate, + } + self.results.register_task_result() + return + + switches_to_delete: List[SwitchDataModel] = [] + + # Phase 1: Switches not in proposed config + for sw in diff.get("to_delete", []): + self.log.info( + f"Marking for deletion (not in proposed): " + f"{sw.fabric_management_ip} ({sw.switch_id})" + ) + switches_to_delete.append(sw) + self._log_operation("delete", sw.fabric_management_ip) + + # Phase 2: Switches that need updating (delete-then-re-add) + for sw in diff.get("to_update", []): + existing_sw = next( + (e for e in self.existing + if e.switch_id == sw.switch_id + or e.fabric_management_ip == sw.fabric_management_ip), + None, + ) + if existing_sw: + self.log.info( + f"Marking for deletion (re-add update): " + f"{existing_sw.fabric_management_ip} ({existing_sw.switch_id})" + ) + switches_to_delete.append(existing_sw) + self._log_operation("delete_for_update", existing_sw.fabric_management_ip) + + diff["to_add"].append(sw) + + if switches_to_delete: + try: + self.fabric_ops.bulk_delete(switches_to_delete) + except SwitchOperationError as e: + msg = ( + f"Failed to delete switches during overridden state: {e}" + ) + self.log.error(msg) + self.nd.module.fail_json(msg=msg) + + diff["to_update"] = [] + + # Phase 3: Delegate add + migration to merged state + self._handle_merged_state(diff, proposed_config, discovered_data) + self.log.debug("EXIT: _handle_overridden_state()") + + def _handle_deleted_state( + self, + proposed_config: Optional[List[SwitchConfigModel]] = None, + ) -> None: + """Handle deleted-state switch removal. + + Args: + proposed_config: Optional config list that limits deletion scope. + + Returns: + None. + """ + self.log.debug("ENTER: _handle_deleted_state()") + self.log.info("Handling deleted state") + + if proposed_config is None: + switches_to_delete = list(self.existing) + self.log.info( + f"No proposed config — targeting all {len(switches_to_delete)} " + f"existing switch(es) for deletion" + ) + for sw in switches_to_delete: + self._log_operation("delete", sw.fabric_management_ip) + else: + switches_to_delete: List[SwitchDataModel] = [] + for switch_config in proposed_config: + identifier = switch_config.seed_ip + self.log.debug(f"Looking for switch to delete with seed IP: {identifier}") + existing_switch = next( + (sw for sw in self.existing if sw.fabric_management_ip == identifier), + None, + ) + if existing_switch: + self.log.info( + f"Marking for deletion: {identifier} ({existing_switch.switch_id})" + ) + switches_to_delete.append(existing_switch) + else: + self.log.info(f"Switch not found for deletion: {identifier}") + + self.log.info(f"Total switches marked for deletion: {len(switches_to_delete)}") + if not switches_to_delete: + self.log.info("No switches to delete") + return + + # Check mode — preview only + if self.nd.module.check_mode: + self.log.info(f"Check mode: would delete {len(switches_to_delete)} switch(es)") + self.results.action = "delete" + self.results.state = self.state + self.results.operation_type = OperationType.DELETE + self.results.response_current = {"MESSAGE": "check mode — skipped", "RETURN_CODE": 200} + self.results.result_current = {"success": True, "changed": True} + self.results.diff_current = { + "to_delete": [sw.fabric_management_ip for sw in switches_to_delete], + } + self.results.register_task_result() + return + + self.log.info( + f"Proceeding to delete {len(switches_to_delete)} switch(es) from fabric" + ) + self.fabric_ops.bulk_delete(switches_to_delete) + self.log.debug("EXIT: _handle_deleted_state()") + + # ===================================================================== + # Query Helpers + # ===================================================================== + + def _query_all_switches(self) -> List[Dict[str, Any]]: + """Query all switches from the fabric inventory API. + + Returns: + List of raw switch dictionaries returned by the controller. + """ + endpoint = V1ManageFabricSwitchesGet() + endpoint.fabric_name = self.fabric + self.log.debug(f"Querying all switches with endpoint: {endpoint.path}") + self.log.debug(f"Query verb: {endpoint.verb}") + + try: + result = self.nd.request(path=endpoint.path, verb=endpoint.verb) + except Exception as e: + msg = ( + f"Failed to query switches from " + f"fabric '{self.fabric}': {e}" + ) + self.log.error(msg) + self.nd.module.fail_json(msg=msg) + + if isinstance(result, list): + switches = result + elif isinstance(result, dict): + switches = result.get("switches", []) + else: + switches = [] + + self.log.debug(f"Queried {len(switches)} switches from fabric {self.fabric}") + return switches + + # ===================================================================== + # Operation Tracking + # ===================================================================== + + def _log_operation(self, operation: str, identifier: str) -> None: + """Append a successful operation record to the module log. + + Args: + operation: Operation label. + identifier: Switch identifier for the operation. + + Returns: + None. + """ + self.nd_logs.append({ + "operation": operation, + "identifier": identifier, + "status": "success", + }) diff --git a/plugins/module_utils/utils/nd_manage_switches/__init__.py b/plugins/module_utils/utils/nd_manage_switches/__init__.py new file mode 100644 index 00000000..ff3d215b --- /dev/null +++ b/plugins/module_utils/utils/nd_manage_switches/__init__.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Akshayant Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""nd_manage_switches utilities package. + +Re-exports all utility classes, functions, and exceptions so that +consumers can import directly from the package: + + from .utils.nd_manage_switches import ( + SwitchOperationError, PayloadUtils, FabricUtils, SwitchWaitUtils, + mask_password, get_switch_field, determine_operation_type, + group_switches_by_credentials, query_bootstrap_switches, + build_bootstrap_index, build_poap_data_block, + ) +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from .exceptions import SwitchOperationError # noqa: F401 +from .payload_utils import PayloadUtils, mask_password # noqa: F401 +from .fabric_utils import FabricUtils # noqa: F401 +from .switch_wait_utils import SwitchWaitUtils # noqa: F401 +from .switch_helpers import ( # noqa: F401 + get_switch_field, + determine_operation_type, + group_switches_by_credentials, +) +from .bootstrap_utils import ( # noqa: F401 + query_bootstrap_switches, + build_bootstrap_index, + build_poap_data_block, +) + + +__all__ = [ + "SwitchOperationError", + "PayloadUtils", + "FabricUtils", + "SwitchWaitUtils", + "mask_password", + "get_switch_field", + "determine_operation_type", + "group_switches_by_credentials", + "query_bootstrap_switches", + "build_bootstrap_index", + "build_poap_data_block", +] diff --git a/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py b/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py new file mode 100644 index 00000000..1356428a --- /dev/null +++ b/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or +# https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Bootstrap API helpers for POAP switch queries, serial-number indexing, and payload construction.""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +from typing import Any, Dict, List, Optional + +from ...endpoints.v1.nd_manage_switches.manage_fabric_bootstrap import ( + V1ManageFabricBootstrapGet, +) + + +def query_bootstrap_switches( + nd, + fabric: str, + log: logging.Logger, +) -> List[Dict[str, Any]]: + """GET switches currently in the bootstrap (POAP / PnP) loop. + + Args: + nd: NDModule instance (REST client). + fabric: Fabric name. + log: Logger. + + Returns: + List of raw switch dicts from the bootstrap API. + """ + log.debug("ENTER: query_bootstrap_switches()") + + endpoint = V1ManageFabricBootstrapGet() + endpoint.fabric_name = fabric + log.debug(f"Bootstrap endpoint: {endpoint.path}") + + try: + result = nd.request( + path=endpoint.path, verb=endpoint.verb, + ) + except Exception as e: + msg = ( + f"Failed to query bootstrap switches for " + f"fabric '{fabric}': {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + if isinstance(result, dict): + switches = result.get("switches", []) + elif isinstance(result, list): + switches = result + else: + switches = [] + + log.info( + f"Bootstrap API returned {len(switches)} " + f"switch(es) in POAP loop" + ) + log.debug("EXIT: query_bootstrap_switches()") + return switches + + +def build_bootstrap_index( + bootstrap_switches: List[Dict[str, Any]], +) -> Dict[str, Dict[str, Any]]: + """Build a serial-number-keyed index from bootstrap API data. + + Args: + bootstrap_switches: Raw switch dicts from the bootstrap API. + + Returns: + Dict mapping ``serial_number`` -> switch dict. + """ + return { + sw.get("serialNumber", sw.get("serial_number", "")): sw + for sw in bootstrap_switches + } + + +def build_poap_data_block(poap_cfg) -> Optional[Dict[str, Any]]: + """Build optional data block for bootstrap and pre-provision models. + + Args: + poap_cfg: ``POAPConfigModel`` from the user playbook. + + Returns: + Data block dict, or ``None`` if no ``config_data`` is present. + """ + if not poap_cfg.config_data: + return None + data_block: Dict[str, Any] = {} + gateway = poap_cfg.config_data.gateway + if gateway: + data_block["gatewayIpMask"] = gateway + if poap_cfg.config_data.models: + data_block["models"] = poap_cfg.config_data.models + return data_block or None + + +__all__ = [ + "query_bootstrap_switches", + "build_bootstrap_index", + "build_poap_data_block", +] diff --git a/plugins/module_utils/utils/nd_manage_switches/exceptions.py b/plugins/module_utils/utils/nd_manage_switches/exceptions.py new file mode 100644 index 00000000..09d7ebb5 --- /dev/null +++ b/plugins/module_utils/utils/nd_manage_switches/exceptions.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Custom exceptions for ND Switch Resource operations.""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +class SwitchOperationError(Exception): + """Raised when a switch operation fails.""" + + +__all__ = [ + "SwitchOperationError", +] diff --git a/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py b/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py new file mode 100644 index 00000000..e1d6e4a7 --- /dev/null +++ b/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Fabric-level operations: config save, deploy, and info retrieval.""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +import time +from typing import Any, Dict, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.nd_manage_switches.manage_fabric_config import ( + V1ManageFabricConfigDeployPost, + V1ManageFabricConfigSavePost, + V1ManageFabricGet, +) + +from .exceptions import SwitchOperationError + + +class FabricUtils: + """Fabric-level operations: config save, deploy, and info retrieval.""" + + def __init__( + self, + nd_module, + fabric: str, + logger: Optional[logging.Logger] = None, + ): + """Initialize FabricUtils. + + Args: + nd_module: NDModule or NDNetworkResourceModule instance. + fabric: Fabric name. + logger: Optional logger; defaults to ``nd.FabricUtils``. + """ + self.nd = nd_module + self.fabric = fabric + self.log = logger or logging.getLogger("nd.FabricUtils") + + # Pre-configure endpoints + self.ep_config_save = V1ManageFabricConfigSavePost() + self.ep_config_save.fabric_name = fabric + + self.ep_config_deploy = V1ManageFabricConfigDeployPost() + self.ep_config_deploy.fabric_name = fabric + + self.ep_fabric_get = V1ManageFabricGet() + self.ep_fabric_get.fabric_name = fabric + + # ----------------------------------------------------------------- + # Public API + # ----------------------------------------------------------------- + + def save_config( + self, + max_retries: int = 3, + retry_delay: int = 600, + ) -> Dict[str, Any]: + """Save (recalculate) fabric configuration. + + Retries up to ``max_retries`` times with ``retry_delay`` seconds + between attempts. + + Args: + max_retries: Maximum number of attempts (default ``3``). + retry_delay: Seconds to wait between failed attempts + (default ``600``). + + Returns: + API response dict from the first successful attempt. + + Raises: + SwitchOperationError: If all attempts fail. + """ + last_error: Exception = SwitchOperationError( + f"Config save produced no attempts for fabric {self.fabric}" + ) + for attempt in range(1, max_retries + 1): + try: + response = self._request_endpoint( + self.ep_config_save, action="Config save" + ) + self.log.info( + f"Config save succeeded on attempt " + f"{attempt}/{max_retries} for fabric {self.fabric}" + ) + return response + except SwitchOperationError as exc: + last_error = exc + self.log.warning( + f"Config save attempt {attempt}/{max_retries} failed " + f"for fabric {self.fabric}: {exc}" + ) + if attempt < max_retries: + self.log.info( + f"Retrying config save in {retry_delay}s " + f"(attempt {attempt + 1}/{max_retries})" + ) + time.sleep(retry_delay) + raise SwitchOperationError( + f"Config save failed after {max_retries} attempt(s) " + f"for fabric {self.fabric}: {last_error}" + ) + + def deploy_config(self) -> Dict[str, Any]: + """Deploy pending configuration to all switches in the fabric. + + The ``configDeploy`` endpoint requires no request body; it deploys + all pending changes for the fabric. + + Returns: + API response dict. + + Raises: + SwitchOperationError: If the deploy request fails. + """ + return self._request_endpoint( + self.ep_config_deploy, action="Config deploy" + ) + + def get_fabric_info(self) -> Dict[str, Any]: + """Retrieve fabric information. + + Returns: + Fabric information dict. + + Raises: + SwitchOperationError: If the request fails. + """ + return self._request_endpoint( + self.ep_fabric_get, action="Get fabric info" + ) + + # ----------------------------------------------------------------- + # Internal helpers + # ----------------------------------------------------------------- + + def _request_endpoint( + self, endpoint, action: str = "Request" + ) -> Dict[str, Any]: + """Execute a request against a pre-configured endpoint. + + Args: + endpoint: Endpoint object with ``.path`` and ``.verb``. + action: Human-readable label for log messages. + + Returns: + API response dict. + + Raises: + SwitchOperationError: On any request failure. + """ + self.log.info(f"{action} for fabric: {self.fabric}") + try: + response = self.nd.request(endpoint.path, verb=endpoint.verb) + self.log.info( + f"{action} completed for fabric: {self.fabric}" + ) + return response + except Exception as e: + self.log.error( + f"{action} failed for fabric {self.fabric}: {e}" + ) + raise SwitchOperationError( + f"{action} failed for fabric {self.fabric}: {e}" + ) from e + + +__all__ = [ + "FabricUtils", +] diff --git a/plugins/module_utils/utils/nd_manage_switches/payload_utils.py b/plugins/module_utils/utils/nd_manage_switches/payload_utils.py new file mode 100644 index 00000000..effadfb8 --- /dev/null +++ b/plugins/module_utils/utils/nd_manage_switches/payload_utils.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""API payload builders for ND Switch Resource operations.""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +from copy import deepcopy +from typing import Any, Dict, List, Optional + + +def mask_password(payload: Dict[str, Any]) -> Dict[str, Any]: + """Return a deep copy of *payload* with password fields masked. + + Useful for safe logging of API payloads that contain credentials. + + Args: + payload: API payload dict (may contain ``password`` keys). + + Returns: + Copy with every ``password`` value replaced by ``"********"``. + """ + masked = deepcopy(payload) + if "password" in masked: + masked["password"] = "********" + if isinstance(masked.get("switches"), list): + for switch in masked["switches"]: + if isinstance(switch, dict) and "password" in switch: + switch["password"] = "********" + return masked + + +class PayloadUtils: + """Stateless helper for building ND Switch Resource API request payloads.""" + + def __init__(self, logger: Optional[logging.Logger] = None): + """Initialize PayloadUtils. + + Args: + logger: Optional logger; defaults to ``nd.PayloadUtils``. + """ + self.log = logger or logging.getLogger("nd.PayloadUtils") + + def build_credentials_payload( + self, + serial_numbers: List[str], + username: str, + password: str, + ) -> Dict[str, Any]: + """Build payload for saving switch credentials. + + Args: + serial_numbers: Switch serial numbers. + username: Switch username. + password: Switch password. + + Returns: + Credentials API payload dict. + """ + return { + "switchIds": serial_numbers, + "username": username, + "password": password, + } + + def build_switch_ids_payload( + self, + serial_numbers: List[str], + ) -> Dict[str, Any]: + """Build payload with switch IDs for remove / batch operations. + + Args: + serial_numbers: Switch serial numbers. + + Returns: + ``{"switchIds": [...]}`` payload dict. + """ + return {"switchIds": serial_numbers} + + +__all__ = [ + "mask_password", + "PayloadUtils", +] diff --git a/plugins/module_utils/utils/nd_manage_switches/switch_helpers.py b/plugins/module_utils/utils/nd_manage_switches/switch_helpers.py new file mode 100644 index 00000000..bffb2bdb --- /dev/null +++ b/plugins/module_utils/utils/nd_manage_switches/switch_helpers.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or +# https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Stateless utility helpers for switch field extraction, operation-type detection, and credential grouping.""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +from typing import Any, Dict, List, Optional, Tuple, Union + + +def get_switch_field( + switch, + field_names: List[str], +) -> Optional[Any]: + """Extract a field value from a switch config, trying multiple names. + + Supports Pydantic models and plain dicts with both snake_case and + camelCase key lookups. + + Args: + switch: Switch model or dict to extract from. + field_names: Candidate field names to try, in priority order. + + Returns: + First non-``None`` value found, or ``None``. + """ + for name in field_names: + if hasattr(switch, name): + value = getattr(switch, name) + if value is not None: + return value + elif isinstance(switch, dict): + if name in switch and switch[name] is not None: + return switch[name] + # Try camelCase variant + camel = ''.join( + word.capitalize() if i > 0 else word + for i, word in enumerate(name.split('_')) + ) + if camel in switch and switch[camel] is not None: + return switch[camel] + return None + + +def determine_operation_type(switch) -> str: + """Determine the operation type from switch configuration. + + Args: + switch: A ``SwitchConfigModel``, ``SwitchDiscoveryModel``, + or raw dict. + + Returns: + ``'normal'``, ``'poap'``, or ``'rma'``. + """ + # Pydantic model with .operation_type attribute + if hasattr(switch, 'operation_type'): + return switch.operation_type + + if isinstance(switch, dict): + if 'poap' in switch or 'bootstrap' in switch: + return 'poap' + if ( + 'rma' in switch + or 'old_serial' in switch + or 'oldSerial' in switch + ): + return 'rma' + + return 'normal' + + +def group_switches_by_credentials( + switches, + log: logging.Logger, +) -> Dict[Tuple, list]: + """Group switches by shared credentials for bulk API operations. + + Args: + switches: Validated ``SwitchConfigModel`` instances. + log: Logger. + + Returns: + Dict mapping a ``(username, password_hash, auth_proto, + platform_type, preserve_config)`` tuple to the list of switches + sharing those credentials. + """ + groups: Dict[Tuple, list] = {} + + for switch in switches: + password_hash = hash(switch.password) + group_key = ( + switch.user_name, + password_hash, + switch.auth_proto, + switch.platform_type, + switch.preserve_config, + ) + groups.setdefault(group_key, []).append(switch) + + log.info( + f"Grouped {len(switches)} switches into " + f"{len(groups)} credential group(s)" + ) + + for idx, (key, group_switches) in enumerate(groups.items(), 1): + username, _, auth_proto, platform_type, preserve_config = key + auth_value = ( + auth_proto.value + if hasattr(auth_proto, 'value') + else str(auth_proto) + ) + platform_value = ( + platform_type.value + if hasattr(platform_type, 'value') + else str(platform_type) + ) + log.debug( + f"Group {idx}: {len(group_switches)} switches with " + f"username={username}, auth={auth_value}, " + f"platform={platform_value}, " + f"preserve_config={preserve_config}" + ) + + return groups + + +__all__ = [ + "get_switch_field", + "determine_operation_type", + "group_switches_by_credentials", +] diff --git a/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py b/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py new file mode 100644 index 00000000..5f9350ae --- /dev/null +++ b/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py @@ -0,0 +1,593 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Multi-phase wait utilities for switch lifecycle operations.""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import logging +import time +from typing import Any, Dict, List, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.nd_manage_switches.manage_fabric_config import ( + V1ManageFabricInventoryDiscoverGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.nd_manage_switches.manage_fabric_switches import ( + V1ManageFabricSwitchesGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.nd_manage_switches.manage_fabric_switch_actions import ( + V1ManageFabricSwitchActionsRediscoverPost, +) + +from .fabric_utils import FabricUtils + + +class SwitchWaitUtils: + """Multi-phase wait utilities for switch lifecycle operations. + + Polls the fabric switches API until target switches reach a manageable state, + handling migration mode, greenfield/brownfield shortcuts, and rediscovery. + """ + + # Default wait parameters + DEFAULT_MAX_ATTEMPTS: int = 300 + DEFAULT_WAIT_INTERVAL: int = 5 # seconds + + # Status values indicating the switch is ready + MANAGEABLE_STATUSES = frozenset({"ok", "manageable"}) + + # Status values indicating an operation is still in progress + IN_PROGRESS_STATUSES = frozenset({ + "inProgress", "migration", "discovering", "rediscovering", + }) + + # Status values indicating failure + FAILED_STATUSES = frozenset({ + "failed", + "unreachable", + "authenticationFailed", + "timeout", + "discoveryTimeout", + "notReacheable", # Note: typo matches the API spec + "notAuthorized", + "unknownUserPassword", + "connectionError", + "sshSessionError", + }) + + # Sleep multipliers for each phase + _MIGRATION_SLEEP_FACTOR: float = 2.0 + _REDISCOVERY_SLEEP_FACTOR: float = 3.5 + + def __init__( + self, + nd_module, + fabric: str, + logger: Optional[logging.Logger] = None, + max_attempts: Optional[int] = None, + wait_interval: Optional[int] = None, + fabric_utils: Optional["FabricUtils"] = None, + ): + """Initialize SwitchWaitUtils. + + Args: + nd_module: Parent module instance (must expose ``.nd``). + fabric: Fabric name. + logger: Optional logger; defaults to ``nd.SwitchWaitUtils``. + max_attempts: Max polling iterations (default ``300``). + wait_interval: Seconds between polls (default ``5``). + fabric_utils: Optional ``FabricUtils`` instance for fabric + info queries. Created internally if not provided. + """ + self.nd = nd_module.nd + self.fabric = fabric + self.log = logger or logging.getLogger("nd.SwitchWaitUtils") + self.max_attempts = max_attempts or self.DEFAULT_MAX_ATTEMPTS + self.wait_interval = wait_interval or self.DEFAULT_WAIT_INTERVAL + self.fabric_utils = ( + fabric_utils or FabricUtils(nd_module, fabric, self.log) + ) + + # Pre-configure endpoints + self.ep_switches_get = V1ManageFabricSwitchesGet() + self.ep_switches_get.fabric_name = fabric + + self.ep_inventory_discover = V1ManageFabricInventoryDiscoverGet() + self.ep_inventory_discover.fabric_name = fabric + + self.ep_rediscover = V1ManageFabricSwitchActionsRediscoverPost() + self.ep_rediscover.fabric_name = fabric + + # Cached greenfield flag + self._greenfield_debug_enabled: Optional[bool] = None + + # ===================================================================== + # Public API – Wait Methods + # ===================================================================== + + def wait_for_switch_manageable( + self, + serial_numbers: List[str], + all_preserve_config: bool = False, + skip_greenfield_check: bool = False, + ) -> bool: + """Wait for switches to exit migration mode and become manageable. + + Runs a multi-phase poll: migration-mode exit, normal-mode entry, + brownfield shortcut, greenfield shortcut, unreachable detection, + and final rediscovery to ok status. + + Args: + serial_numbers: Switch serial numbers to monitor. + all_preserve_config: Set to ``True`` when all switches in the + batch are brownfield (``preserve_config=True``). Skips + reload detection, as brownfield switches never reload. + skip_greenfield_check: Set to ``True`` to bypass the greenfield + debug flag shortcut (required for POAP bootstrap where + the device always reboots). + + Returns: + ``True`` if all switches are manageable, ``False`` on timeout. + """ + self.log.info( + f"Waiting for switches to become manageable: {serial_numbers}" + ) + + # Phase 1 + 2: migration → normal + if not self._wait_for_system_mode(serial_numbers): + return False + + # Phase 3: brownfield shortcut — no reload expected + if all_preserve_config: + self.log.info( + "All switches are brownfield (preserve_config=True) — " + "skipping reload detection (phases 5-6)" + ) + return True + + # Phase 4: greenfield shortcut (skipped for POAP bootstrap) + if ( + not skip_greenfield_check + and self._is_greenfield_debug_enabled() + ): + self.log.info( + "Greenfield debug flag enabled — " + "skipping reload detection" + ) + return True + + if skip_greenfield_check: + self.log.info( + "Greenfield debug check skipped " + "(POAP bootstrap — device always reboots)" + ) + + # Phase 5: wait for "unreachable" (switch is reloading) + if not self._wait_for_discovery_state( + serial_numbers, "unreachable" + ): + return False + + # Phase 6: wait for "ok" (switch is ready) + return self._wait_for_discovery_state( + serial_numbers, "ok" + ) + + def wait_for_discovery( + self, + seed_ip: str, + max_attempts: Optional[int] = None, + wait_interval: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + """Poll until a single switch discovery completes. + + Args: + seed_ip: IP address of the switch being discovered. + max_attempts: Override max attempts (default ``30``). + wait_interval: Override interval in seconds (default ``5``). + + Returns: + Discovery data dict on success, ``None`` on failure or timeout. + """ + attempts = max_attempts or 30 + interval = wait_interval or self.wait_interval + + self.log.info(f"Waiting for discovery of: {seed_ip}") + + for attempt in range(attempts): + status = self._get_discovery_status(seed_ip) + + if ( + status + and status.get("status") in self.MANAGEABLE_STATUSES + ): + self.log.info(f"Discovery completed for {seed_ip}") + return status + + if ( + status + and status.get("status") in self.FAILED_STATUSES + ): + self.log.error( + f"Discovery failed for {seed_ip}: {status}" + ) + return None + + self.log.debug( + f"Discovery attempt {attempt + 1}/{attempts} " + f"for {seed_ip}" + ) + time.sleep(interval) + + self.log.warning(f"Discovery timeout for {seed_ip}") + return None + + # ===================================================================== + # Phase Helpers – System Mode + # ===================================================================== + + def _wait_for_system_mode( + self, serial_numbers: List[str] + ) -> bool: + """Poll until all switches transition from migration mode to normal mode. + + Args: + serial_numbers: Switch serial numbers to monitor. + + Returns: + ``True`` when all switches are in ``normal`` mode, + ``False`` on timeout or API failure. + """ + # Sub-phase A: exit "migration" mode + pending = self._poll_system_mode( + serial_numbers, + target_mode="migration", + expect_match=True, + ) + if pending is None: + return False + + # Sub-phase B: enter "normal" mode + pending = self._poll_system_mode( + serial_numbers, + target_mode="normal", + expect_match=False, + ) + if pending is None: + return False + + self.log.info( + "All switches in normal system mode — " + "proceeding to discovery checks" + ) + return True + + def _poll_system_mode( + self, + serial_numbers: List[str], + target_mode: str, + expect_match: bool, + ) -> Optional[List[str]]: + """Poll until no switches remain in (or outside) ``target_mode``. + + Args: + serial_numbers: Switches to check. + target_mode: System mode string (e.g. ``"migration"``). + expect_match: When ``True``, waits for switches to leave + ``target_mode``. When ``False``, waits for + switches to enter ``target_mode``. + + Returns: + Empty list on success, ``None`` on timeout or API error. + """ + pending = list(serial_numbers) + label = ( + f"exit '{target_mode}'" + if expect_match + else f"enter '{target_mode}'" + ) + + for attempt in range(1, self.max_attempts + 1): + if not pending: + return pending + + switch_data = self._fetch_switch_data() + if switch_data is None: + return None + + remaining = self._filter_by_system_mode( + pending, switch_data, target_mode, expect_match + ) + + if not remaining: + self.log.info( + f"All switches {label} mode (attempt {attempt})" + ) + return remaining + + pending = remaining + self.log.debug( + f"Attempt {attempt}/{self.max_attempts}: " + f"{len(pending)} switch(es) waiting to " + f"{label}: {pending}" + ) + time.sleep( + self.wait_interval * self._MIGRATION_SLEEP_FACTOR + ) + + self.log.warning( + f"Timeout waiting for switches to {label}: {pending}" + ) + return None + + # ===================================================================== + # Filtering (static, pure-logic helpers) + # ===================================================================== + + @staticmethod + def _filter_by_system_mode( + serial_numbers: List[str], + switch_data: List[Dict[str, Any]], + target_mode: str, + expect_match: bool, + ) -> List[str]: + """Return serial numbers that have NOT yet satisfied the mode check. + + Args: + serial_numbers: Switches to inspect. + switch_data: Raw switch dicts from the GET API. + target_mode: e.g. ``"migration"`` or ``"normal"``. + expect_match: When ``True``, waits for switches to leave + ``target_mode``. When ``False``, waits for + switches to enter ``target_mode``. + + Returns: + Serial numbers still waiting. + """ + switch_index = { + sw.get("serialNumber"): sw for sw in switch_data + } + remaining: List[str] = [] + for sn in serial_numbers: + sw = switch_index.get(sn) + if sw is None: + remaining.append(sn) + continue + mode = ( + sw.get("additionalData", {}) + .get("systemMode", "") + .lower() + ) + # expect_match=True: "still in target_mode" → not done + # expect_match=False: "not yet in target_mode" → not done + still_waiting = ( + (mode == target_mode) + if expect_match + else (mode != target_mode) + ) + if still_waiting: + remaining.append(sn) + return remaining + + @staticmethod + def _filter_by_discovery_status( + serial_numbers: List[str], + switch_data: List[Dict[str, Any]], + target_state: str, + ) -> List[str]: + """Return serial numbers not yet at ``target_state``. + + Args: + serial_numbers: Switches to inspect. + switch_data: Raw switch dicts from the GET API. + target_state: e.g. ``"unreachable"`` or ``"ok"``. + + Returns: + Serial numbers still waiting. + """ + switch_index = { + sw.get("serialNumber"): sw for sw in switch_data + } + remaining: List[str] = [] + for sn in serial_numbers: + sw = switch_index.get(sn) + if sw is None: + remaining.append(sn) + continue + status = ( + sw.get("additionalData", {}) + .get("discoveryStatus", "") + .lower() + ) + if status != target_state: + remaining.append(sn) + return remaining + + # ===================================================================== + # Phase Helpers – Discovery Status + # ===================================================================== + + def _wait_for_discovery_state( + self, + serial_numbers: List[str], + target_state: str, + ) -> bool: + """Poll until all switches reach the given discovery status. + + Triggers rediscovery on each iteration for switches that have not + yet reached the target state. + + Args: + serial_numbers: Switch serial numbers to monitor. + target_state: Expected discovery status, e.g. ``"unreachable"`` + or ``"ok"``. + + Returns: + ``True`` when all switches reach ``target_state``, + ``False`` on timeout. + """ + pending = list(serial_numbers) + + for attempt in range(1, self.max_attempts + 1): + if not pending: + return True + + switch_data = self._fetch_switch_data() + if switch_data is None: + return False + + pending = self._filter_by_discovery_status( + pending, switch_data, target_state + ) + + if not pending: + self.log.info( + f"All switches reached '{target_state}' state " + f"(attempt {attempt})" + ) + return True + + self._trigger_rediscovery(pending) + self.log.debug( + f"Attempt {attempt}/{self.max_attempts}: " + f"{len(pending)} switch(es) not yet " + f"'{target_state}': {pending}" + ) + time.sleep( + self.wait_interval * self._REDISCOVERY_SLEEP_FACTOR + ) + + self.log.warning( + f"Timeout waiting for '{target_state}' state: " + f"{serial_numbers}" + ) + return False + + # ===================================================================== + # API Helpers + # ===================================================================== + + def _fetch_switch_data( + self, + ) -> Optional[List[Dict[str, Any]]]: + """GET current switch data for the fabric. + + Returns: + List of switch dicts, or ``None`` on failure. + """ + try: + response = self.nd.request( + self.ep_switches_get.path, + verb=self.ep_switches_get.verb, + ) + switch_data = response.get("switches", []) + if not switch_data: + self.log.error( + "No switch data returned for fabric" + ) + return None + return switch_data + except Exception as e: + self.log.error(f"Failed to fetch switch data: {e}") + return None + + def _trigger_rediscovery( + self, serial_numbers: List[str] + ) -> None: + """POST a rediscovery request for the given switches. + + Args: + serial_numbers: Switch serial numbers to rediscover. + """ + if not serial_numbers: + return + + payload = {"switchIds": serial_numbers} + self.log.info( + f"Triggering rediscovery for: {serial_numbers}" + ) + try: + self.nd.request( + self.ep_rediscover.path, + verb=self.ep_rediscover.verb, + data=payload, + ) + except Exception as e: + self.log.warning( + f"Failed to trigger rediscovery: {e}" + ) + + def _get_discovery_status( + self, seed_ip: str, + ) -> Optional[Dict[str, Any]]: + """GET discovery status for a single switch by IP. + + Args: + seed_ip: IP address of the switch. + + Returns: + Switch dict from the discovery API, or ``None``. + """ + try: + response = self.nd.request( + self.ep_inventory_discover.path, + verb=self.ep_inventory_discover.verb, + ) + for switch in response.get("switches", []): + if ( + switch.get("ip") == seed_ip + or switch.get("ipaddr") == seed_ip + ): + return switch + return None + except Exception as e: + self.log.debug( + f"Discovery status check failed: {e}" + ) + return None + + def _is_greenfield_debug_enabled(self) -> bool: + """Check whether the fabric has the greenfield debug flag enabled. + + Uses the ``FabricUtils`` instance. Result is cached for the + lifetime of the instance. + + Returns: + ``True`` if the flag is ``"enable"``, ``False`` otherwise. + """ + if self._greenfield_debug_enabled is not None: + return self._greenfield_debug_enabled + + try: + fabric_info = self.fabric_utils.get_fabric_info() + self.log.debug( + f"Fabric info retrieved for greenfield check: " + f"{fabric_info}" + ) + flag = ( + fabric_info + .get("management", {}) + .get("greenfieldDebugFlag", "") + .lower() + ) + self.log.debug( + f"Greenfield debug flag value: '{flag}'" + ) + self._greenfield_debug_enabled = flag == "enable" + except Exception as e: + self.log.debug( + f"Failed to get greenfield debug flag: {e}" + ) + self._greenfield_debug_enabled = False + + return self._greenfield_debug_enabled + + +__all__ = [ + "SwitchWaitUtils", +] diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py new file mode 100644 index 00000000..559f1bd0 --- /dev/null +++ b/plugins/modules/nd_manage_switches.py @@ -0,0 +1,622 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type +__copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." +__author__ = "Akshayanat Chengam Saravanan" + +DOCUMENTATION = """ +--- +module: nd_manage_switches +short_description: Manage switches in Cisco Nexus Dashboard (ND). +version_added: "1.0.0" +author: Akshayanat Chengam Saravanan (@achengam) +description: +- Add, delete, override, and query switches in Cisco Nexus Dashboard. +- Supports normal discovery, POAP (bootstrap/preprovision), and RMA operations. +- Uses Pydantic model validation for switch configurations. +- Provides state-based operations with intelligent diff calculation. +options: + fabric: + description: + - Name of the target fabric for switch operations. + type: str + required: yes + state: + description: + - The state of ND and switch(es) after module completion. + - C(merged) and C(query) are the only states supported for POAP. + - C(merged) is the only state supported for RMA. + type: str + default: merged + choices: + - merged + - overridden + - deleted + - query + save: + description: + - Save/Recalculate the configuration of the fabric after inventory is updated. + type: bool + default: true + deploy: + description: + - Deploy the pending configuration of the fabric after inventory is updated. + type: bool + default: true + config: + description: + - List of switch configurations. Optional for state C(deleted). + type: list + elements: dict + suboptions: + seed_ip: + description: + - Seed IP address or DNS name of the switch to manage. + type: str + required: true + auth_proto: + description: + - SNMP authentication protocol to use. + - For POAP and RMA, should be C(MD5). + type: str + default: MD5 + choices: ['MD5', 'SHA', 'MD5_DES', 'MD5_AES', 'SHA_DES', 'SHA_AES'] + user_name: + description: + - Login username for the switch. + - For POAP and RMA, should be C(admin). + type: str + default: admin + password: + description: + - Login password for the switch. + type: str + required: true + role: + description: + - Role to assign to the switch in the fabric. + type: str + default: leaf + choices: + - leaf + - spine + - border + - border_spine + - border_gateway + - border_gateway_spine + - super_spine + - border_super_spine + - border_gateway_super_spine + - access + - aggregation + - edge_router + - core_router + - tor + preserve_config: + description: + - Set to C(false) for greenfield deployment, C(true) for brownfield. + type: bool + default: false + poap: + description: + - POAP (PowerOn Auto Provisioning) configurations for bootstrap/preprovision. + - POAP and DHCP must be enabled in fabric before using. + type: list + elements: dict + suboptions: + discovery_username: + description: + - Username for device discovery during POAP. + type: str + discovery_password: + description: + - Password for device discovery during POAP. + type: str + no_log: true + serial_number: + description: + - Serial number of the physical switch to Bootstrap. + - When used together with C(preprovision_serial), performs a swap operation + that changes the serial number of a pre-provisioned switch and then + imports it via bootstrap. + type: str + preprovision_serial: + description: + - Serial number of switch to Pre-provision. + - When used together with C(serial_number), performs a swap operation + that changes the serial number of this pre-provisioned switch to + C(serial_number) and then imports it via bootstrap. + type: str + model: + description: + - Model of switch to Bootstrap/Pre-provision. + type: str + version: + description: + - Software version of switch. + type: str + hostname: + description: + - Hostname for the switch. + type: str + image_policy: + description: + - Image policy to apply. + type: str + config_data: + description: + - Basic configuration data for the switch during Bootstrap/Pre-provision. + - C(models) and C(gateway) are mandatory. + - C(models) is list of model of modules in switch to Bootstrap/Pre-provision. + - C(gateway) is the gateway IP with mask for the switch. + type: dict + suboptions: + models: + description: + - List of module models in the switch (e.g., N9K-X9364v, N9K-vSUP). + type: list + elements: str + gateway: + description: + - Gateway IP with subnet mask (e.g., 192.168.0.1/24). + type: str + rma: + description: + - RMA an existing switch with a new one. + - Please note that the existing switch should be configured and deployed in maintenance mode. + - Please note that the existing switch being replaced should be shutdown state or out of network. + type: list + elements: dict + suboptions: + discovery_username: + description: + - Username for device discovery during POAP and RMA discovery. + type: str + discovery_password: + description: + - Password for device discovery during POAP and RMA discovery. + type: str + serial_number: + description: + - Serial number of switch to Bootstrap for RMA. + type: str + required: true + old_serial: + description: + - Serial number of switch to be replaced by RMA. + type: str + required: true + model: + description: + - Model of switch to Bootstrap for RMA. + type: str + required: true + version: + description: + - Software version of switch to Bootstrap for RMA. + type: str + required: true + image_policy: + description: + - Name of the image policy to be applied on switch during Bootstrap for RMA. + type: str + config_data: + description: + - Basic config data of switch to Bootstrap for RMA. + - C(models) and C(gateway) are mandatory. + - C(models) is list of model of modules in switch to Bootstrap for RMA. + - C(gateway) is the gateway IP with mask for the switch to Bootstrap for RMA. + type: dict + required: true + suboptions: + models: + description: + - List of module models in the switch. + type: list + elements: str + required: true + gateway: + description: + - Gateway IP with subnet mask (e.g., 192.168.0.1/24). + type: str + required: true + - Serial number of new replacement switch. + type: str + required: true + model: + description: + - Model of new switch. + type: str + required: true + version: + description: + - Software version of new switch. + type: str + required: true + hostname: + description: + - Hostname for the replacement switch. + type: str + required: true + image_policy: + description: + - Image policy to apply. + type: str + required: true + ip: + description: + - IP address of the replacement switch. + type: str + required: true + gateway_ip: + description: + - Gateway IP with subnet mask. + type: str + required: true + discovery_password: + description: + - Password for device discovery during RMA. + type: str + required: true +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +notes: +- This module requires NDFC 12.x or higher. +- POAP operations require POAP and DHCP to be enabled in fabric settings. +- RMA operations require the old switch to be in a replaceable state. +""" + +EXAMPLES = """ +- name: Add a switch to fabric + cisco.nd.nd_manage_switches: + fabric: my-fabric + config: + - seed_ip: 192.168.10.201 + user_name: admin + password: "{{ switch_password }}" + role: leaf + preserve_config: false + state: merged + +- name: Add multiple switches + cisco.nd.nd_manage_switches: + fabric: my-fabric + config: + - seed_ip: 192.168.10.201 + user_name: admin + password: "{{ switch_password }}" + role: leaf + - seed_ip: 192.168.10.202 + user_name: admin + password: "{{ switch_password }}" + role: spine + state: merged + +- name: Preprovision a switch via POAP + cisco.nd.nd_manage_switches: + fabric: my-fabric + config: + - seed_ip: 192.168.10.1 + user_name: admin + password: "{{ switch_password }}" + poap: + - preprovision_serial: SAL1234ABCD + model: N9K-C93180YC-EX + version: "10.3(1)" + hostname: leaf-preprov + gateway_ip: 192.168.10.1/24 + state: merged + +- name: Bootstrap a switch via POAP + cisco.nd.nd_manage_switches: + fabric: my-fabric + config: + - seed_ip: 192.168.10.1 + user_name: admin + password: "{{ switch_password }}" + poap: + - serial_number: SAL5678EFGH + model: N9K-C93180YC-EX + version: "10.3(1)" + hostname: leaf-bootstrap + gateway_ip: 192.168.10.1/24 + state: merged + +- name: Swap serial number on a pre-provisioned switch (POAP swap) + cisco.nd.nd_manage_switches: + fabric: my-fabric + config: + - seed_ip: 192.168.10.1 + user_name: admin + password: "{{ switch_password }}" + poap: + - serial_number: SAL5678EFGH + preprovision_serial: SAL1234ABCD + state: merged + +- name: RMA - Replace a switch + cisco.nd.nd_manage_switches: + fabric: my-fabric + config: + - seed_ip: 192.168.10.1 + user_name: admin + password: "{{ switch_password }}" + rma: + - old_serial: SAL1234ABCD + serial_number: SAL9999ZZZZ + model: N9K-C93180YC-EX + version: "10.3(1)" + hostname: leaf-replaced + image_policy: my-image-policy + ip: 192.168.10.50 + gateway_ip: 192.168.10.1/24 + discovery_password: "{{ discovery_password }}" + state: merged + +- name: Remove switches from fabric + cisco.nd.nd_manage_switches: + fabric: my-fabric + config: + - seed_ip: 192.168.10.201 + - seed_ip: 192.168.10.202 + state: deleted + +- name: Query all switches in fabric + cisco.nd.nd_manage_switches: + fabric: my-fabric + state: query + register: switches_result +""" + +RETURN = """ +previous: + description: The configuration prior to the module execution. + returned: always + type: list + elements: dict +proposed: + description: The proposed configuration sent to the API. + returned: always + type: list + elements: dict +sent: + description: The configuration sent to the API. + returned: when state is not query + type: list + elements: dict +current: + description: The current configuration after module execution. + returned: always + type: list + elements: dict +""" + +import logging + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log +from ansible_collections.cisco.nd.plugins.module_utils.nd_switch_resources import NDSwitchResourceModule +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule, + NDModuleError, + nd_argument_spec, +) +from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results + + +def main(): + """Main entry point for the nd_manage_switches module.""" + + # Build argument spec + argument_spec = nd_argument_spec() + argument_spec.update( + fabric=dict(type="str", required=True), + config=dict( + type="list", + elements="dict", + options=dict( + seed_ip=dict(type="str", required=True), + auth_proto=dict( + type="str", + default="MD5", + choices=["MD5", "SHA", "MD5_DES", "MD5_AES", "SHA_DES", "SHA_AES"] + ), + user_name=dict(type="str", default="admin"), + password=dict(type="str", no_log=True), + role=dict( + type="str", + default="leaf", + choices=[ + "leaf", "spine", "border", "border_spine", + "border_gateway", "border_gateway_spine", + "super_spine", "border_super_spine", + "border_gateway_super_spine", "access", + "aggregation", "edge_router", "core_router", "tor" + ] + ), + preserve_config=dict(type="bool", default=False), + poap=dict( + type="list", + elements="dict", + options=dict( + discovery_username=dict(type="str"), + discovery_password=dict(type="str", no_log=True), + serial_number=dict(type="str"), + preprovision_serial=dict(type="str"), + model=dict(type="str"), + version=dict(type="str"), + hostname=dict(type="str"), + image_policy=dict(type="str"), + config_data=dict( + type="dict", + options=dict( + models=dict( + type="list", + elements="str", + ), + gateway=dict( + type="str", + ), + ), + ), + ), + ), + rma=dict( + type="list", + elements="dict", + options=dict( + old_serial=dict(type="str", required=True), + serial_number=dict(type="str", required=True), + model=dict(type="str", required=True), + version=dict(type="str", required=True), + image_policy=dict(type="str"), + discovery_username=dict(type="str"), + discovery_password=dict(type="str", no_log=True), + config_data=dict( + type="dict", + required=True, + options=dict( + models=dict( + type="list", + elements="str", + required=True, + ), + gateway=dict( + type="str", + required=True, + ), + ), + ), + ), + ), + ), + ), + save=dict(type="bool", default=True), + deploy=dict(type="bool", default=True), + state=dict( + type="str", + default="merged", + choices=["merged", "overridden", "deleted", "query"] + ), + ) + + # Create Ansible module + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ("state", "merged", ["config"]), + ("state", "overridden", ["config"]), + ], + ) + + # Initialize logging + try: + log_config = Log() + log_config.config = "/Users/achengam/Documents/Ansible_Dev/NDBranch/ansible_collections/cisco/nd/ansible_cisco_log_r.json" + log_config.commit() + # Create logger instance for this module + log = logging.getLogger("nd.nd_manage_switches") + except ValueError as error: + module.fail_json(msg=str(error)) + + # Get parameters + state = module.params.get("state") + fabric = module.params.get("fabric") + output_level = module.params.get("output_level") + + # Initialize Results - this collects all operation results + results = Results() + results.state = state + results.check_mode = module.check_mode + results.action = f"manage_switches_{state}" + + try: + log.info(f"Starting nd_manage_switches module: fabric={fabric}, state={state}") + + # Initialize NDModule (uses RestSend infrastructure internally) + nd = NDModule(module) + log.info("NDModule initialized successfully") + + # Create NDSwitchResourceModule + sw_module = NDSwitchResourceModule( + nd=nd, + results=results, + logger=log + ) + log.info(f"NDSwitchResourceModule initialized for fabric: {fabric}") + + # Manage state for merged, overridden, deleted, query + log.info(f"Managing state: {state}") + sw_module.manage_state() + + # Exit with results + log.info(f"State management completed successfully. Changed: {results.changed}") + sw_module.exit_json() + + except NDModuleError as error: + # NDModule-specific errors (API failures, authentication issues, etc.) + log.error(f"NDModule error: {error.msg}") + + # Try to get response from RestSend if available + try: + results.response_current = nd.rest_send.response_current + results.result_current = nd.rest_send.result_current + except (AttributeError, ValueError): + # Fallback if RestSend wasn't initialized or no response available + results.response_current = { + "RETURN_CODE": error.status if error.status else -1, + "MESSAGE": error.msg, + "DATA": error.response_payload if error.response_payload else {}, + } + results.result_current = { + "success": False, + "found": False, + } + + results.diff_current = {} + results.register_task_result() + results.build_final_result() + + # Add error details if debug output is requested + if output_level == "debug": + results.final_result["error_details"] = error.to_dict() + + log.error(f"Module failed: {results.final_result}") + module.fail_json(msg=error.msg, **results.final_result) + + except Exception as error: + # Unexpected errors + log.error(f"Unexpected error during module execution: {str(error)}") + log.error(f"Error type: {type(error).__name__}") + + # Build failed result + results.response_current = { + "RETURN_CODE": -1, + "MESSAGE": f"Unexpected error: {str(error)}", + "DATA": {}, + } + results.result_current = { + "success": False, + "found": False, + } + results.diff_current = {} + results.register_task_result() + results.build_final_result() + + if output_level == "debug": + import traceback + results.final_result["traceback"] = traceback.format_exc() + + module.fail_json(msg=str(error), **results.final_result) + + +if __name__ == "__main__": + main() From b1fe93995ab9249deca00e7897bf6a90d199e882 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Thu, 12 Mar 2026 23:47:21 +0530 Subject: [PATCH 02/27] Add Hostname/DNS support in lieu of IP + Handle Switch Inconsistent State Handling --- .../nd_manage_switches/config_models.py | 28 ++- .../nd_manage_switches/switch_data_models.py | 4 - plugins/module_utils/nd_switch_resources.py | 198 +++++++++++++++--- 3 files changed, 193 insertions(+), 37 deletions(-) diff --git a/plugins/module_utils/models/nd_manage_switches/config_models.py b/plugins/module_utils/models/nd_manage_switches/config_models.py index 1ed4aa72..20023b0e 100644 --- a/plugins/module_utils/models/nd_manage_switches/config_models.py +++ b/plugins/module_utils/models/nd_manage_switches/config_models.py @@ -16,6 +16,7 @@ __metaclass__ = type +import socket from ipaddress import ip_address, ip_interface from pydantic import Field, ValidationInfo, computed_field, field_validator, model_validator from typing import Any, Dict, List, Optional, ClassVar, Literal, Union @@ -526,24 +527,37 @@ def apply_state_defaults(self, info: ValidationInfo) -> Self: @field_validator('seed_ip', mode='before') @classmethod def validate_seed_ip(cls, v: str) -> str: - """Validate seed IP is valid IP address or DNS name.""" + """Resolve seed_ip to an IP address. + + Accepts IPv4, IPv6, or a DNS name / hostname. When the input + is not a valid IP address a DNS lookup is performed and the + resolved IPv4 address is returned so that downstream code + always works with a clean IP. + """ if not v or not v.strip(): raise ValueError("seed_ip cannot be empty") v = v.strip() - # Try to validate as IP address first + # Fast path: already a valid IP address try: ip_address(v) return v except ValueError: pass - # If not an IP, assume it's a DNS name - basic validation - if not v.replace('-', '').replace('.', '').replace('_', '').isalnum(): - raise ValueError(f"Invalid seed_ip: {v}. Must be a valid IP address or DNS name") - - return v + # Not an IP — attempt DNS resolution (IPv4 first, then IPv6) + for family in (socket.AF_INET, socket.AF_INET6): + try: + addr_info = socket.getaddrinfo(v, None, family) + if addr_info: + return addr_info[0][4][0] + except socket.gaierror: + continue + + raise ValueError( + f"'{v}' is not a valid IP address and could not be resolved via DNS" + ) @field_validator('poap', 'rma', mode='before') @classmethod diff --git a/plugins/module_utils/models/nd_manage_switches/switch_data_models.py b/plugins/module_utils/models/nd_manage_switches/switch_data_models.py index 5afc6117..08147ce4 100644 --- a/plugins/module_utils/models/nd_manage_switches/switch_data_models.py +++ b/plugins/module_utils/models/nd_manage_switches/switch_data_models.py @@ -382,10 +382,6 @@ class SwitchDataModel(NDBaseModel): default=None, alias="switchRole" ) - mode: Optional[str] = Field( - default=None, - description="Switch mode (Normal, Migration, etc.)" - ) system_up_time: Optional[str] = Field( default=None, alias="systemUpTime", diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index 3d9a7f69..625651b7 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -30,6 +30,7 @@ PlatformType, DiscoveryStatus, SystemMode, + ConfigSyncStatus, SwitchDiscoveryModel, SwitchDataModel, AddSwitchesRequestModel, @@ -232,6 +233,7 @@ def compute_changes( changes: Dict[str, list] = { "to_add": [], "to_update": [], + "role_change": [], "to_delete": [], "migration_mode": [], "idempotent": [], @@ -257,12 +259,13 @@ def compute_changes( changes["to_add"].append(prop_sw) continue + log.debug(f"Switch {ip} (id={sid}) found in existing with {match_key} match {existing_sw}") log.debug( f"Switch {ip} matched existing by {match_key} " f"(existing_id={existing_sw.switch_id})" ) - if existing_sw.mode == "Migration": + if existing_sw.additional_data.system_mode == SystemMode.MIGRATION: log.info( f"Switch {ip} ({existing_sw.switch_id}) is in Migration mode" ) @@ -284,16 +287,24 @@ def compute_changes( k for k in set(prop_dict) | set(existing_dict) if prop_dict.get(k) != existing_dict.get(k) } - log.info( - f"Switch {ip} has differences — marking to_update. " - f"Changed fields: {diff_keys}" - ) - log.debug( - f"Switch {ip} diff detail — " - f"proposed: { {k: prop_dict.get(k) for k in diff_keys} }, " - f"existing: { {k: existing_dict.get(k) for k in diff_keys} }" - ) - changes["to_update"].append(prop_sw) + if diff_keys == {"switch_role"}: + log.info( + f"Switch {ip} has role-only difference — marking role_change. " + f"proposed: {prop_dict.get('switch_role')}, " + f"existing: {existing_dict.get('switch_role')}" + ) + changes["role_change"].append(prop_sw) + else: + log.info( + f"Switch {ip} has differences — marking to_update. " + f"Changed fields: {diff_keys}" + ) + log.debug( + f"Switch {ip} diff detail — " + f"proposed: { {k: prop_dict.get(k) for k in diff_keys} }, " + f"existing: { {k: existing_dict.get(k) for k in diff_keys} }" + ) + changes["to_update"].append(prop_sw) # Switches in existing but not in proposed (for overridden state) proposed_ids = {sw.switch_id for sw in proposed} @@ -309,6 +320,7 @@ def compute_changes( f"Compute changes summary: " f"to_add={len(changes['to_add'])}, " f"to_update={len(changes['to_update'])}, " + f"role_change={len(changes['role_change'])}, " f"to_delete={len(changes['to_delete'])}, " f"migration_mode={len(changes['migration_mode'])}, " f"idempotent={len(changes['idempotent'])}" @@ -2273,21 +2285,16 @@ def _handle_merged_state( return config_by_ip = {sw.seed_ip: sw for sw in proposed_config} + existing_by_ip = {sw.fabric_management_ip: sw for sw in self.existing} - # Phase 1: Log idempotent switches - for sw in diff.get("idempotent", []): - self.log.info( - f"Switch {sw.fabric_management_ip} ({sw.switch_id}) " - f"is idempotent - no changes needed" - ) + # Phase 1: Handle role-change switches + self._merged_handle_role_changes(diff, config_by_ip, existing_by_ip) - # Phase 2: Warn about to_update (merged state doesn't support updates) - if diff.get("to_update"): - ips = [sw.fabric_management_ip for sw in diff["to_update"]] - self.log.warning( - f"Switches require updates which is not supported in merged state. " - f"Use overridden state for updates. Affected switches: {ips}" - ) + # Phase 2: Handle idempotent switches that may need config sync + self._merged_handle_idempotent(diff, existing_by_ip) + + # Phase 3: Fail on to_update (merged state doesn't support updates) + self._merged_handle_to_update(diff) switches_to_add = diff.get("to_add", []) migration_switches = diff.get("migration_mode", []) @@ -2317,7 +2324,7 @@ def _handle_merged_state( # Collect (serial_number, SwitchConfigModel) pairs for post-processing switch_actions: List[Tuple[str, SwitchConfigModel]] = [] - # Phase 3: Bulk add new switches to fabric + # Phase 4: Bulk add new switches to fabric if switches_to_add and discovered_data: add_configs = [] for sw in switches_to_add: @@ -2361,7 +2368,8 @@ def _handle_merged_state( switch_actions.append((sn, cfg)) self._log_operation("add", cfg.seed_ip) - # Phase 4: Collect migration switches for post-processing + # Phase 5: Collect migration switches for post-processing + # Migration mode switches get role updates during post-add processing. for mig_sw in migration_switches: cfg = config_by_ip.get(mig_sw.fabric_management_ip) if cfg and mig_sw.switch_id: @@ -2396,6 +2404,140 @@ def _handle_merged_state( self.log.debug("EXIT: _handle_merged_state() - completed") + # ----------------------------------------------------------------- + # Merged-state sub-handlers (modular phases) + # ----------------------------------------------------------------- + + def _merged_handle_role_changes( + self, + diff: Dict[str, List[SwitchDataModel]], + config_by_ip: Dict[str, SwitchConfigModel], + existing_by_ip: Dict[str, SwitchDataModel], + ) -> None: + """Handle role-change switches in merged state. + + Role changes are only allowed when configSyncStatus is notApplicable. + Any other status fails the module. + + Args: + diff: Categorized switch diff output. + config_by_ip: Config lookup by seed IP. + existing_by_ip: Existing switch lookup by management IP. + + Returns: + None. + """ + role_change_switches = diff.get("role_change", []) + if not role_change_switches: + return + + # Validate configSyncStatus for every role-change switch + for sw in role_change_switches: + existing_sw = existing_by_ip.get(sw.fabric_management_ip) + status = ( + existing_sw.additional_data.config_sync_status + if existing_sw and existing_sw.additional_data + else None + ) + if status != ConfigSyncStatus.NOT_APPLICABLE: + self.nd.module.fail_json( + msg=( + f"Role change not possible for switch " + f"{sw.fabric_management_ip} ({sw.switch_id}). " + f"configSyncStatus is " + f"'{status.value if status else 'unknown'}', " + f"expected '{ConfigSyncStatus.NOT_APPLICABLE.value}'." + ) + ) + + # Build (switch_id, SwitchConfigModel) pairs and apply role change + role_actions: List[Tuple[str, SwitchConfigModel]] = [] + for sw in role_change_switches: + cfg = config_by_ip.get(sw.fabric_management_ip) + if cfg and sw.switch_id: + role_actions.append((sw.switch_id, cfg)) + + if role_actions: + self.log.info( + f"Performing role change for {len(role_actions)} switch(es)" + ) + self.fabric_ops.bulk_update_roles(role_actions) + self.fabric_ops.finalize() + + def _merged_handle_idempotent( + self, + diff: Dict[str, List[SwitchDataModel]], + existing_by_ip: Dict[str, SwitchDataModel], + ) -> None: + """Handle idempotent switches that may need config save and deploy. + + If configSyncStatus is anything other than inSync, run config save + and deploy to bring the switch back in sync. + + Args: + diff: Categorized switch diff output. + existing_by_ip: Existing switch lookup by management IP. + + Returns: + None. + """ + idempotent_switches = diff.get("idempotent", []) + if not idempotent_switches: + return + + finalize_needed = False + for sw in idempotent_switches: + existing_sw = existing_by_ip.get(sw.fabric_management_ip) + status = ( + existing_sw.additional_data.config_sync_status + if existing_sw and existing_sw.additional_data + else None + ) + if status != ConfigSyncStatus.IN_SYNC: + self.log.info( + f"Switch {sw.fabric_management_ip} ({sw.switch_id}) is " + f"config-idempotent but configSyncStatus is " + f"'{status.value if status else 'unknown'}' — " + f"will run config save and deploy" + ) + finalize_needed = True + else: + self.log.info( + f"Switch {sw.fabric_management_ip} ({sw.switch_id}) " + f"is idempotent — no changes needed" + ) + + if finalize_needed: + self.fabric_ops.finalize() + + def _merged_handle_to_update( + self, + diff: Dict[str, List[SwitchDataModel]], + ) -> None: + """Fail the module if switches require field-level updates. + + Merged state does not support in-place updates beyond role changes. + Use overridden state which performs delete-and-re-add. + + Args: + diff: Categorized switch diff output. + + Returns: + None. + """ + to_update = diff.get("to_update", []) + if not to_update: + return + + ips = [sw.fabric_management_ip for sw in to_update] + self.nd.module.fail_json( + msg=( + f"Switches require updates that are not supported in merged state. " + f"Use 'overridden' state for in-place updates. " + f"Affected switches: {ips}" + ) + ) + def _handle_overridden_state( self, diff: Dict[str, List[SwitchDataModel]], @@ -2419,6 +2561,10 @@ def _handle_overridden_state( self.log.warning("No configurations provided for overridden state") return + # Merge role_change into to_update — overridden uses delete-and-re-add + diff["to_update"].extend(diff.get("role_change", [])) + diff["role_change"] = [] + # Check mode — preview only if self.nd.module.check_mode: n_delete = len(diff.get("to_delete", [])) From 2a135aeeda1581dd3f58637bcd33cc92f8897110 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 13 Mar 2026 00:55:20 +0530 Subject: [PATCH 03/27] Update Endpoints Inheritance, Directory Structure and Imports --- .../nd_manage_switches/credentials.py} | 4 +- .../nd_manage_switches/fabric_bootstrap.py} | 31 ++-- .../nd_manage_switches/fabric_config.py} | 56 ++++---- .../nd_manage_switches/fabric_discovery.py} | 28 +++- .../fabric_switch_actions.py} | 132 +++++++++--------- .../nd_manage_switches/fabric_switches.py} | 6 +- .../nd_manage_switches/config_models.py | 17 +-- .../nd_manage_switches/bootstrap_utils.py | 2 +- .../utils/nd_manage_switches/fabric_utils.py | 2 +- .../nd_manage_switches/switch_wait_utils.py | 6 +- 10 files changed, 155 insertions(+), 129 deletions(-) rename plugins/module_utils/endpoints/v1/{nd_manage_switches/manage_credentials.py => manage/nd_manage_switches/credentials.py} (97%) rename plugins/module_utils/endpoints/v1/{nd_manage_switches/manage_fabric_bootstrap.py => manage/nd_manage_switches/fabric_bootstrap.py} (87%) rename plugins/module_utils/endpoints/v1/{nd_manage_switches/manage_fabric_config.py => manage/nd_manage_switches/fabric_config.py} (86%) rename plugins/module_utils/endpoints/v1/{nd_manage_switches/manage_fabric_discovery.py => manage/nd_manage_switches/fabric_discovery.py} (80%) rename plugins/module_utils/endpoints/v1/{nd_manage_switches/manage_fabric_switch_actions.py => manage/nd_manage_switches/fabric_switch_actions.py} (88%) rename plugins/module_utils/endpoints/v1/{nd_manage_switches/manage_fabric_switches.py => manage/nd_manage_switches/fabric_switches.py} (97%) diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_credentials.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py similarity index 97% rename from plugins/module_utils/endpoints/v1/nd_manage_switches/manage_credentials.py rename to plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py index 9007be8d..242948a7 100644 --- a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_credentials.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py @@ -29,7 +29,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( EndpointQueryParams, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( +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 ( @@ -77,7 +77,7 @@ class _V1ManageCredentialsSwitchesBase(BaseModel): @property def _base_path(self) -> str: """Build the base endpoint path.""" - return BasePath.nd_manage("credentials", "switches") + return BasePath.path("credentials", "switches") class V1ManageCredentialsSwitchesPost(_V1ManageCredentialsSwitchesBase): diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_bootstrap.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py similarity index 87% rename from plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_bootstrap.py rename to plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py index 48212482..d2e07828 100644 --- a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_bootstrap.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py @@ -29,7 +29,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( EndpointQueryParams, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( +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 ( @@ -68,7 +68,25 @@ class FabricBootstrapEndpointParams(EndpointQueryParams): filter: Optional[str] = Field(default=None, min_length=1, description="Lucene filter expression") -class V1ManageFabricBootstrapGet(FabricNameMixin, BaseModel): +class _V1ManageFabricBootstrapBase(FabricNameMixin, BaseModel): + """ + Base class for Fabric Bootstrap endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabricName}/bootstrap endpoint. + """ + + model_config = COMMON_CONFIG + + @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, "bootstrap") + + +class V1ManageFabricBootstrapGet(_V1ManageFabricBootstrapBase): """ # Summary @@ -113,8 +131,6 @@ class V1ManageFabricBootstrapGet(FabricNameMixin, BaseModel): ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -137,13 +153,10 @@ def path(self) -> str: - Complete endpoint path string, optionally including query parameters """ - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - base_path = BasePath.nd_manage("fabrics", self.fabric_name, "bootstrap") query_string = self.endpoint_params.to_query_string() if query_string: - return f"{base_path}?{query_string}" - return base_path + return f"{self._base_path}?{query_string}" + return self._base_path @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_config.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py similarity index 86% rename from plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_config.py rename to plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py index bb037e1e..078afc6c 100644 --- a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_config.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py @@ -32,7 +32,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( EndpointQueryParams, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( +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 ( @@ -69,7 +69,25 @@ class FabricConfigDeployEndpointParams(EndpointQueryParams): incl_all_msd_switches: Optional[bool] = Field(default=None, description="Include all MSD fabric switches") -class V1ManageFabricConfigSavePost(FabricNameMixin, BaseModel): +class _V1ManageFabricConfigBase(FabricNameMixin, BaseModel): + """ + Base class for Fabric Config endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabricName} endpoint family. + """ + + model_config = COMMON_CONFIG + + @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) + + +class V1ManageFabricConfigSavePost(_V1ManageFabricConfigBase): """ # Summary @@ -97,8 +115,6 @@ class V1ManageFabricConfigSavePost(FabricNameMixin, BaseModel): ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -110,9 +126,7 @@ class V1ManageFabricConfigSavePost(FabricNameMixin, BaseModel): @property def path(self) -> str: """Build the endpoint path.""" - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - return BasePath.nd_manage("fabrics", self.fabric_name, "actions", "configSave") + return f"{self._base_path}/actions/configSave" @property def verb(self) -> HttpVerbEnum: @@ -120,7 +134,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class V1ManageFabricConfigDeployPost(FabricNameMixin, BaseModel): +class V1ManageFabricConfigDeployPost(_V1ManageFabricConfigBase): """ # Summary @@ -163,8 +177,6 @@ class V1ManageFabricConfigDeployPost(FabricNameMixin, BaseModel): ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -187,13 +199,11 @@ def path(self) -> str: - Complete endpoint path string, optionally including query parameters """ - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - base_path = BasePath.nd_manage("fabrics", self.fabric_name, "actions", "configDeploy") + base = f"{self._base_path}/actions/configDeploy" query_string = self.endpoint_params.to_query_string() if query_string: - return f"{base_path}?{query_string}" - return base_path + return f"{base}?{query_string}" + return base @property def verb(self) -> HttpVerbEnum: @@ -201,7 +211,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class V1ManageFabricGet(FabricNameMixin, BaseModel): +class V1ManageFabricGet(_V1ManageFabricConfigBase): """ # Summary @@ -229,8 +239,6 @@ class V1ManageFabricGet(FabricNameMixin, BaseModel): ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -242,9 +250,7 @@ class V1ManageFabricGet(FabricNameMixin, BaseModel): @property def path(self) -> str: """Build the endpoint path.""" - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - return BasePath.nd_manage("fabrics", self.fabric_name) + return self._base_path @property def verb(self) -> HttpVerbEnum: @@ -252,7 +258,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.GET -class V1ManageFabricInventoryDiscoverGet(FabricNameMixin, BaseModel): +class V1ManageFabricInventoryDiscoverGet(_V1ManageFabricConfigBase): """ # Summary @@ -280,8 +286,6 @@ class V1ManageFabricInventoryDiscoverGet(FabricNameMixin, BaseModel): ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -293,9 +297,7 @@ class V1ManageFabricInventoryDiscoverGet(FabricNameMixin, BaseModel): @property def path(self) -> str: """Build the endpoint path.""" - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - return BasePath.nd_manage("fabrics", self.fabric_name, "inventory", "discover") + return f"{self._base_path}/inventory/discover" @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_discovery.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py similarity index 80% rename from plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_discovery.py rename to plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py index d7d2e1f2..928b4b67 100644 --- a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_discovery.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py @@ -26,7 +26,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( FabricNameMixin, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import ( BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( @@ -39,7 +39,25 @@ COMMON_CONFIG = ConfigDict(validate_assignment=True) -class V1ManageFabricShallowDiscoveryPost(FabricNameMixin, BaseModel): +class _V1ManageFabricDiscoveryBase(FabricNameMixin, BaseModel): + """ + Base class for Fabric Discovery endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabricName}/actions/shallowDiscovery endpoint. + """ + + model_config = COMMON_CONFIG + + @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, "actions", "shallowDiscovery") + + +class V1ManageFabricShallowDiscoveryPost(_V1ManageFabricDiscoveryBase): """ # Summary @@ -67,8 +85,6 @@ class V1ManageFabricShallowDiscoveryPost(FabricNameMixin, BaseModel): ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -80,9 +96,7 @@ class V1ManageFabricShallowDiscoveryPost(FabricNameMixin, BaseModel): @property def path(self) -> str: """Build the endpoint path.""" - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - return BasePath.nd_manage("fabrics", self.fabric_name, "actions", "shallowDiscovery") + return self._base_path @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switch_actions.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py similarity index 88% rename from plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switch_actions.py rename to plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py index 73aa93ea..6b90f160 100644 --- a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switch_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py @@ -35,7 +35,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( EndpointQueryParams, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( +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 ( @@ -128,7 +128,25 @@ class SwitchActionsImportEndpointParams(EndpointQueryParams): # ============================================================================ -class V1ManageFabricSwitchActionsRemovePost(FabricNameMixin, BaseModel): +class _V1ManageFabricSwitchActionsBase(FabricNameMixin, BaseModel): + """ + Base class for Fabric Switch Actions endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabricName}/switchActions endpoint. + """ + + model_config = COMMON_CONFIG + + @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, "switchActions") + + +class V1ManageFabricSwitchActionsRemovePost(_V1ManageFabricSwitchActionsBase): """ # Summary @@ -172,8 +190,6 @@ class V1ManageFabricSwitchActionsRemovePost(FabricNameMixin, BaseModel): ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -196,13 +212,11 @@ def path(self) -> str: - Complete endpoint path string, optionally including query parameters """ - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - base_path = BasePath.nd_manage("fabrics", self.fabric_name, "switchActions", "remove") + base = f"{self._base_path}/remove" query_string = self.endpoint_params.to_query_string() if query_string: - return f"{base_path}?{query_string}" - return base_path + return f"{base}?{query_string}" + return base @property def verb(self) -> HttpVerbEnum: @@ -210,7 +224,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class V1ManageFabricSwitchActionsChangeRolesPost(FabricNameMixin, BaseModel): +class V1ManageFabricSwitchActionsChangeRolesPost(_V1ManageFabricSwitchActionsBase): """ # Summary @@ -252,8 +266,6 @@ class V1ManageFabricSwitchActionsChangeRolesPost(FabricNameMixin, BaseModel): ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -277,13 +289,11 @@ def path(self) -> str: - Complete endpoint path string, optionally including query parameters """ - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - base_path = BasePath.nd_manage("fabrics", self.fabric_name, "switchActions", "changeRoles") + base = f"{self._base_path}/changeRoles" query_string = self.endpoint_params.to_query_string() if query_string: - return f"{base_path}?{query_string}" - return base_path + return f"{base}?{query_string}" + return base @property def verb(self) -> HttpVerbEnum: @@ -291,7 +301,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class V1ManageFabricSwitchActionsImportBootstrapPost(FabricNameMixin, BaseModel): +class V1ManageFabricSwitchActionsImportBootstrapPost(_V1ManageFabricSwitchActionsBase): """ # Summary @@ -335,8 +345,6 @@ class V1ManageFabricSwitchActionsImportBootstrapPost(FabricNameMixin, BaseModel) ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -359,13 +367,11 @@ def path(self) -> str: - Complete endpoint path string, optionally including query parameters """ - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - base_path = BasePath.nd_manage("fabrics", self.fabric_name, "switchActions", "importBootstrap") + base = f"{self._base_path}/importBootstrap" query_string = self.endpoint_params.to_query_string() if query_string: - return f"{base_path}?{query_string}" - return base_path + return f"{base}?{query_string}" + return base @property def verb(self) -> HttpVerbEnum: @@ -378,7 +384,7 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class V1ManageFabricSwitchActionsPreProvisionPost(FabricNameMixin, BaseModel): +class V1ManageFabricSwitchActionsPreProvisionPost(_V1ManageFabricSwitchActionsBase): """ # Summary @@ -425,8 +431,6 @@ class V1ManageFabricSwitchActionsPreProvisionPost(FabricNameMixin, BaseModel): ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -450,13 +454,11 @@ def path(self) -> str: - Complete endpoint path string, optionally including query parameters """ - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - base_path = BasePath.nd_manage("fabrics", self.fabric_name, "switchActions", "preProvision") + base = f"{self._base_path}/preProvision" query_string = self.endpoint_params.to_query_string() if query_string: - return f"{base_path}?{query_string}" - return base_path + return f"{base}?{query_string}" + return base @property def verb(self) -> HttpVerbEnum: @@ -469,7 +471,27 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class V1ManageFabricSwitchProvisionRMAPost(FabricNameMixin, SwitchSerialNumberMixin, BaseModel): +class _V1ManageFabricSwitchActionsPerSwitchBase(FabricNameMixin, SwitchSerialNumberMixin, BaseModel): + """ + Base class for per-switch action endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions endpoint. + """ + + model_config = COMMON_CONFIG + + @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") + if self.switch_sn is None: + raise ValueError("switch_sn must be set before accessing path") + return BasePath.path("fabrics", self.fabric_name, "switches", self.switch_sn, "actions") + + +class V1ManageFabricSwitchProvisionRMAPost(_V1ManageFabricSwitchActionsPerSwitchBase): """ # Summary @@ -513,8 +535,6 @@ class V1ManageFabricSwitchProvisionRMAPost(FabricNameMixin, SwitchSerialNumberMi ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -537,17 +557,11 @@ def path(self) -> str: - Complete endpoint path string, optionally including query parameters """ - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - if self.switch_sn is None: - raise ValueError("switch_sn must be set before accessing path") - base_path = BasePath.nd_manage( - "fabrics", self.fabric_name, "switches", self.switch_sn, "actions", "provisionRMA" - ) + base = f"{self._base_path}/provisionRMA" query_string = self.endpoint_params.to_query_string() if query_string: - return f"{base_path}?{query_string}" - return base_path + return f"{base}?{query_string}" + return base @property def verb(self) -> HttpVerbEnum: @@ -582,7 +596,7 @@ class SwitchActionsClusterEndpointParams(EndpointQueryParams): cluster_name: Optional[str] = Field(default=None, min_length=1, description="Target cluster name") -class V1ManageFabricSwitchChangeSerialNumberPost(FabricNameMixin, SwitchSerialNumberMixin, BaseModel): +class V1ManageFabricSwitchChangeSerialNumberPost(_V1ManageFabricSwitchActionsPerSwitchBase): """ # Summary @@ -626,8 +640,6 @@ class V1ManageFabricSwitchChangeSerialNumberPost(FabricNameMixin, SwitchSerialNu ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -650,17 +662,11 @@ def path(self) -> str: - Complete endpoint path string, optionally including query parameters """ - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - if self.switch_sn is None: - raise ValueError("switch_sn must be set before accessing path") - base_path = BasePath.nd_manage( - "fabrics", self.fabric_name, "switches", self.switch_sn, "actions", "changeSwitchSerialNumber" - ) + base = f"{self._base_path}/changeSwitchSerialNumber" query_string = self.endpoint_params.to_query_string() if query_string: - return f"{base_path}?{query_string}" - return base_path + return f"{base}?{query_string}" + return base @property def verb(self) -> HttpVerbEnum: @@ -673,7 +679,7 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class V1ManageFabricSwitchActionsRediscoverPost(FabricNameMixin, BaseModel): +class V1ManageFabricSwitchActionsRediscoverPost(_V1ManageFabricSwitchActionsBase): """ # Summary @@ -715,8 +721,6 @@ class V1ManageFabricSwitchActionsRediscoverPost(FabricNameMixin, BaseModel): ``` """ - model_config = COMMON_CONFIG - # Version metadata api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") @@ -740,13 +744,11 @@ def path(self) -> str: - Complete endpoint path string, optionally including query parameters """ - if self.fabric_name is None: - raise ValueError("fabric_name must be set before accessing path") - base_path = BasePath.nd_manage("fabrics", self.fabric_name, "switchActions", "rediscover") + base = f"{self._base_path}/rediscover" query_string = self.endpoint_params.to_query_string() if query_string: - return f"{base_path}?{query_string}" - return base_path + return f"{base}?{query_string}" + return base @property def verb(self) -> HttpVerbEnum: diff --git a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switches.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py similarity index 97% rename from plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switches.py rename to plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py index b771fb1d..9594a64c 100644 --- a/plugins/module_utils/endpoints/v1/nd_manage_switches/manage_fabric_switches.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py @@ -31,7 +31,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( EndpointQueryParams, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.base_paths_manage import ( +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 ( @@ -111,7 +111,7 @@ 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.nd_manage("fabrics", self.fabric_name, "switches") + return BasePath.path("fabrics", self.fabric_name, "switches") class V1ManageFabricSwitchesGet(_V1ManageFabricSwitchesBase): @@ -287,4 +287,4 @@ def _base_path(self) -> str: raise ValueError("fabric_name must be set before accessing path") if self.switch_sn is None: raise ValueError("switch_sn must be set before accessing path") - return BasePath.nd_manage("fabrics", self.fabric_name, "switches", self.switch_sn) + return BasePath.path("fabrics", self.fabric_name, "switches", self.switch_sn) diff --git a/plugins/module_utils/models/nd_manage_switches/config_models.py b/plugins/module_utils/models/nd_manage_switches/config_models.py index 20023b0e..4dca8c6b 100644 --- a/plugins/module_utils/models/nd_manage_switches/config_models.py +++ b/plugins/module_utils/models/nd_manage_switches/config_models.py @@ -17,7 +17,7 @@ __metaclass__ = type import socket -from ipaddress import ip_address, ip_interface +from ipaddress import ip_address from pydantic import Field, ValidationInfo, computed_field, field_validator, model_validator from typing import Any, Dict, List, Optional, ClassVar, Literal, Union from typing_extensions import Self @@ -58,11 +58,7 @@ def validate_gateway(cls, v: str) -> str: """Validate gateway is a valid CIDR.""" if not v or not v.strip(): raise ValueError("gateway cannot be empty") - try: - ip_interface(v.strip()) - except ValueError as e: - raise ValueError(f"Invalid gateway IP address with mask: {v}") from e - return v.strip() + return SwitchValidators.validate_cidr(v) class POAPConfigModel(NDNestedModel): @@ -209,9 +205,7 @@ def validate_discovery_credentials_pair(self) -> Self: @classmethod def validate_serial_numbers(cls, v: Optional[str]) -> Optional[str]: """Validate serial numbers are not empty strings.""" - if v is not None and not v.strip(): - raise ValueError("Serial number cannot be empty") - return v + return SwitchValidators.validate_serial_number(v) class RMAConfigModel(NDNestedModel): @@ -280,9 +274,10 @@ class RMAConfigModel(NDNestedModel): @classmethod def validate_serial_numbers(cls, v: str) -> str: """Validate serial numbers are not empty.""" - if not v or not v.strip(): + result = SwitchValidators.validate_serial_number(v) + if result is None: raise ValueError("Serial number cannot be empty") - return v.strip() + return result @model_validator(mode='after') def validate_discovery_credentials_pair(self) -> Self: diff --git a/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py b/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py index 1356428a..89904696 100644 --- a/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py +++ b/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py @@ -14,7 +14,7 @@ import logging from typing import Any, Dict, List, Optional -from ...endpoints.v1.nd_manage_switches.manage_fabric_bootstrap import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_bootstrap import ( V1ManageFabricBootstrapGet, ) diff --git a/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py b/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py index e1d6e4a7..1fb8da7e 100644 --- a/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py +++ b/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py @@ -14,7 +14,7 @@ import time from typing import Any, Dict, Optional -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.nd_manage_switches.manage_fabric_config import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_config import ( V1ManageFabricConfigDeployPost, V1ManageFabricConfigSavePost, V1ManageFabricGet, diff --git a/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py b/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py index 5f9350ae..31665ee7 100644 --- a/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py +++ b/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py @@ -14,13 +14,13 @@ import time from typing import Any, Dict, List, Optional -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.nd_manage_switches.manage_fabric_config import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_config import ( V1ManageFabricInventoryDiscoverGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.nd_manage_switches.manage_fabric_switches import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switches import ( V1ManageFabricSwitchesGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.nd_manage_switches.manage_fabric_switch_actions import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switch_actions import ( V1ManageFabricSwitchActionsRediscoverPost, ) From 081afe06859ea5ba55e083cb3f22db8c035f3916 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 13 Mar 2026 00:58:02 +0530 Subject: [PATCH 04/27] Update Module Imports --- plugins/module_utils/nd_switch_resources.py | 22 ++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index 625651b7..3894de7e 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -21,10 +21,10 @@ from pydantic import ValidationError -from .nd_v2 import NDModule -from .enums import OperationType -from .rest.results import Results -from .models.nd_manage_switches import ( +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule +from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType +from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches import ( SwitchRole, SnmpV3AuthProtocol, PlatformType, @@ -46,7 +46,7 @@ POAPConfigModel, RMAConfigModel, ) -from .utils.nd_manage_switches import ( +from ansible_collections.cisco.nd.plugins.module_utils.utils.nd_manage_switches import ( FabricUtils, SwitchWaitUtils, SwitchOperationError, @@ -57,12 +57,14 @@ build_bootstrap_index, build_poap_data_block, ) -from .endpoints.v1.nd_manage_switches.manage_fabric_switches import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switches import ( V1ManageFabricSwitchesGet, V1ManageFabricSwitchesPost, ) -from .endpoints.v1.nd_manage_switches.manage_fabric_discovery import V1ManageFabricShallowDiscoveryPost -from .endpoints.v1.nd_manage_switches.manage_fabric_switch_actions import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_discovery import ( + V1ManageFabricShallowDiscoveryPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switch_actions import ( V1ManageFabricSwitchProvisionRMAPost, V1ManageFabricSwitchActionsImportBootstrapPost, V1ManageFabricSwitchActionsPreProvisionPost, @@ -70,7 +72,9 @@ V1ManageFabricSwitchActionsChangeRolesPost, V1ManageFabricSwitchChangeSerialNumberPost, ) -from .endpoints.v1.nd_manage_switches.manage_credentials import V1ManageCredentialsSwitchesPost +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.credentials import ( + V1ManageCredentialsSwitchesPost, +) # ========================================================================= From 888ee57a245f49d52e50777437922e49c4104066 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 13 Mar 2026 13:51:31 +0530 Subject: [PATCH 05/27] Add constants, and fix comparison of constants in RMA. --- plugins/module_utils/nd_switch_resources.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index 3894de7e..b84b143d 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -78,9 +78,13 @@ # ========================================================================= -# Shared Dependency Container +# Constants & Globals # ========================================================================= +# Max hops is not supported by the module. +_DISCOVERY_MAX_HOPS: int = 0 + + @dataclass class SwitchServiceContext: """Store shared dependencies used by service classes. @@ -431,7 +435,7 @@ def bulk_discover( seed_ips = [switch.seed_ip for switch in switches] log.debug(f"Seed IPs: {seed_ips}") - max_hops = switches[0].max_hops if hasattr(switches[0], 'max_hops') else 0 + max_hops = _DISCOVERY_MAX_HOPS discovery_request = ShallowDiscoveryRequestModel( seedIpCollection=seed_ips, @@ -558,7 +562,7 @@ def build_proposed( if discovered: if cfg.role is not None: - discovered["role"] = cfg.role + discovered = {**discovered, "role": cfg.role} proposed.append( SwitchDataModel.from_response(discovered) ) @@ -1854,21 +1858,21 @@ def _validate_prerequisites( ) ) - if ad.discovery_status != DiscoveryStatus.UNREACHABLE.value: + if ad.discovery_status != DiscoveryStatus.UNREACHABLE: nd.module.fail_json( msg=( f"RMA: Switch '{old_serial}' has discovery status " - f"'{ad.discovery_status or 'unknown'}', " + f"'{ad.discovery_status.value if ad.discovery_status else 'unknown'}', " f"expected 'unreachable'. The old switch must be " f"unreachable before RMA can proceed." ) ) - if ad.system_mode != SystemMode.MAINTENANCE.value: + if ad.system_mode != SystemMode.MAINTENANCE: nd.module.fail_json( msg=( f"RMA: Switch '{old_serial}' is in " - f"'{ad.system_mode or 'unknown'}' " + f"'{ad.system_mode.value if ad.system_mode else 'unknown'}' " f"mode, expected 'maintenance'. Put the switch in " f"maintenance mode before initiating RMA." ) From 267846a2ce9f0f03f5c2996205824991f3569374 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 13 Mar 2026 16:06:36 +0530 Subject: [PATCH 06/27] Add further POAP Bootstrap Validation and Fixes --- .../nd_manage_switches/config_models.py | 48 +++++-- plugins/module_utils/nd_switch_resources.py | 123 ++++++++++++++++-- 2 files changed, 151 insertions(+), 20 deletions(-) diff --git a/plugins/module_utils/models/nd_manage_switches/config_models.py b/plugins/module_utils/models/nd_manage_switches/config_models.py index 4dca8c6b..eb912e7e 100644 --- a/plugins/module_utils/models/nd_manage_switches/config_models.py +++ b/plugins/module_utils/models/nd_manage_switches/config_models.py @@ -52,6 +52,29 @@ class ConfigDataModel(NDNestedModel): description="Gateway IP with mask for the switch (e.g., 192.168.0.1/24)" ) + @field_validator('models', mode='before') + @classmethod + def validate_models_list(cls, v: Any) -> List[str]: + """Validate models is a non-empty list of strings.""" + if v is None: + raise ValueError( + "'models' is required in config_data. " + "Provide a list of module model strings, " + "e.g. models: [N9K-X9364v, N9K-vSUP]" + ) + if not isinstance(v, list): + raise ValueError( + f"'models' must be a list of module model strings, got: {type(v).__name__}. " + f"e.g. models: [N9K-X9364v, N9K-vSUP]" + ) + if len(v) == 0: + raise ValueError( + "'models' list cannot be empty. " + "Provide at least one module model string, " + "e.g. models: [N9K-X9364v, N9K-vSUP]" + ) + return v + @field_validator('gateway', mode='before') @classmethod def validate_gateway(cls, v: str) -> str: @@ -149,21 +172,25 @@ def validate_operation_type(self) -> Self: @model_validator(mode='after') def validate_required_fields_for_non_swap(self) -> Self: - """Validate model/version/hostname/config_data are all provided for non-swap POAP. + """Validate model/version/hostname/config_data for pre-provision operations. + + Pre-provision (preprovision_serial only): + model, version, hostname, config_data are all mandatory because the + controller has no physical switch to pull these values from. - For Bootstrap (serial_number only) or Pre-provision (preprovision_serial only) - all four descriptor fields are mandatory. This mirrors the - dcnm_inventory.py check: - if only one serial provided → model, version, hostname, config_data required. + Bootstrap (serial_number only): + These fields are optional — they can be omitted and the module will + pull them from the bootstrap GET API response at runtime. If + provided, they are validated against the bootstrap data before import. - When both serials are present (swap mode), these fields are not - required because the swap API only needs the new serial number. + Swap (both serials present): + No check needed — the swap API only requires the new serial number. """ has_serial = bool(self.serial_number) has_preprov = bool(self.preprovision_serial) - # XOR: exactly one serial → non-swap case - if has_serial != has_preprov: + # Pre-provision only: all four descriptor fields are mandatory + if has_preprov and not has_serial: missing = [] if not self.model: missing.append("model") @@ -174,10 +201,9 @@ def validate_required_fields_for_non_swap(self) -> Self: if not self.config_data: missing.append("config_data") if missing: - op = "Bootstrap" if has_serial else "Pre-provisioning" raise ValueError( f"model, version, hostname and config_data are required for " - f"{op} a switch. Missing: {', '.join(missing)}" + f"Pre-provisioning a switch. Missing: {', '.join(missing)}" ) return self diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index b84b143d..1b67e18a 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -1185,6 +1185,10 @@ def _handle_poap_bootstrap( log.error(msg) nd.module.fail_json(msg=msg) + # Validate user-supplied fields against bootstrap data (if provided) + # and warn about any fields that will be pulled from the API. + self._validate_bootstrap_fields(poap_cfg, bootstrap_data, log) + model = self._build_bootstrap_import_model( switch_cfg, poap_cfg, bootstrap_data ) @@ -1215,6 +1219,90 @@ def _handle_poap_bootstrap( log.debug("EXIT: _handle_poap_bootstrap()") + def _validate_bootstrap_fields( + self, + poap_cfg: POAPConfigModel, + bootstrap_data: Dict[str, Any], + log: logging.Logger, + ) -> None: + """Validate user-supplied bootstrap fields against the bootstrap API response. + + If a field is provided in the playbook config, it must match what the + bootstrap API reports. Fields that are omitted are silently filled in + from the API at import time — no error is raised for those. + + Args: + poap_cfg: POAP config entry from the playbook. + bootstrap_data: Matching entry from the bootstrap GET API. + log: Logger instance. + + Returns: + None. + """ + serial = poap_cfg.serial_number + bs_data = bootstrap_data.get("data") or {} + mismatches: List[str] = [] + + if poap_cfg.model and poap_cfg.model != bootstrap_data.get("model"): + mismatches.append( + f"model: provided '{poap_cfg.model}', " + f"bootstrap reports '{bootstrap_data.get('model')}'" + ) + + if poap_cfg.version and poap_cfg.version != bootstrap_data.get("softwareVersion"): + mismatches.append( + f"version: provided '{poap_cfg.version}', " + f"bootstrap reports '{bootstrap_data.get('softwareVersion')}'" + ) + + if poap_cfg.config_data: + bs_gateway = ( + bootstrap_data.get("gatewayIpMask") + or bs_data.get("gatewayIpMask") + ) + if poap_cfg.config_data.gateway and poap_cfg.config_data.gateway != bs_gateway: + mismatches.append( + f"config_data.gateway: provided '{poap_cfg.config_data.gateway}', " + f"bootstrap reports '{bs_gateway}'" + ) + + bs_models = bs_data.get("models", []) + if ( + poap_cfg.config_data.models + and sorted(poap_cfg.config_data.models) != sorted(bs_models) + ): + mismatches.append( + f"config_data.models: provided {poap_cfg.config_data.models}, " + f"bootstrap reports {bs_models}" + ) + + if mismatches: + self.ctx.nd.module.fail_json( + msg=( + f"Bootstrap field mismatch for serial '{serial}'. " + f"The following provided values do not match the " + f"bootstrap API data:\n" + + "\n".join(f" - {m}" for m in mismatches) + ) + ) + + # Log which fields will be sourced from the bootstrap API + pulled: List[str] = [] + if not poap_cfg.model: + pulled.append("model") + if not poap_cfg.version: + pulled.append("version") + if not poap_cfg.hostname: + pulled.append("hostname") + if not poap_cfg.config_data: + pulled.append("config_data (gateway + models)") + if pulled: + log.info( + f"Bootstrap serial '{serial}': the following fields were not " + f"provided and will be sourced from the bootstrap API: " + f"{', '.join(pulled)}" + ) + def _build_bootstrap_import_model( self, switch_cfg: SwitchConfigModel, @@ -1237,31 +1325,48 @@ def _build_bootstrap_import_model( ) bs = bootstrap_data or {} + bs_data = bs.get("data") or {} - # User config fields serial_number = poap_cfg.serial_number - hostname = poap_cfg.hostname ip = switch_cfg.seed_ip - model = poap_cfg.model - version = poap_cfg.version - image_policy = poap_cfg.image_policy - gateway_ip_mask = poap_cfg.config_data.gateway if poap_cfg.config_data else None switch_role = switch_cfg.role password = switch_cfg.password auth_proto = SnmpV3AuthProtocol.MD5 # POAP/bootstrap always uses MD5 + image_policy = poap_cfg.image_policy discovery_username = getattr(poap_cfg, "discovery_username", None) discovery_password = getattr(poap_cfg, "discovery_password", None) + # Use user-provided values when available; fall back to bootstrap API data. + model = poap_cfg.model or bs.get("model", "") + version = poap_cfg.version or bs.get("softwareVersion", "") + hostname = poap_cfg.hostname or bs.get("hostname", "") + + gateway_ip_mask = ( + (poap_cfg.config_data.gateway if poap_cfg.config_data else None) + or bs.get("gatewayIpMask") + or bs_data.get("gatewayIpMask") + ) + data_models = ( + (poap_cfg.config_data.models if poap_cfg.config_data else None) + or bs_data.get("models", []) + ) + + # Build the data block from resolved values (replaces build_poap_data_block) + data_block: Optional[Dict[str, Any]] = None + if gateway_ip_mask or data_models: + data_block = {} + if gateway_ip_mask: + data_block["gatewayIpMask"] = gateway_ip_mask + if data_models: + data_block["models"] = data_models + # Bootstrap API response fields fingerprint = bs.get("fingerPrint", bs.get("fingerprint", "")) public_key = bs.get("publicKey", "") re_add = bs.get("reAdd", False) in_inventory = bs.get("inInventory", False) - # Shared data block builder - data_block = build_poap_data_block(poap_cfg) - bootstrap_model = BootstrapImportSwitchModel( serialNumber=serial_number, model=model, From 9bd95af5fa9f1faa7aebdbdc56577003506c9a9b Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 13 Mar 2026 17:16:49 +0530 Subject: [PATCH 07/27] Rebasing Mixins and Endpoints with Latest Endpoint Changes --- plugins/module_utils/endpoints/mixins.py | 24 +++++++ .../manage/nd_manage_switches/credentials.py | 27 +++----- .../nd_manage_switches/fabric_bootstrap.py | 31 +++------ .../nd_manage_switches/fabric_config.py | 28 ++------ .../nd_manage_switches/fabric_discovery.py | 16 ++--- .../fabric_switch_actions.py | 68 ++++--------------- .../nd_manage_switches/fabric_switches.py | 66 +++++------------- 7 files changed, 83 insertions(+), 177 deletions(-) diff --git a/plugins/module_utils/endpoints/mixins.py b/plugins/module_utils/endpoints/mixins.py index 47695611..a6065b8c 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.""" @@ -62,6 +68,12 @@ 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.""" @@ -74,12 +86,24 @@ class NodeNameMixin(BaseModel): node_name: Optional[str] = Field(default=None, min_length=1, description="Node name") +class OffsetMixin(BaseModel): + """Mixin for endpoints that require a pagination offset parameter.""" + + offset: Optional[int] = Field(default=None, ge=0, description="Pagination offset") + + class SwitchSerialNumberMixin(BaseModel): """Mixin for endpoints that require switch_sn parameter.""" switch_sn: Optional[str] = Field(default=None, min_length=1, description="Switch serial number") +class TicketIdMixin(BaseModel): + """Mixin for endpoints that require ticket_id parameter.""" + + ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") + + class VrfNameMixin(BaseModel): """Mixin for endpoints that require vrf_name parameter.""" diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py index 242948a7..1576e9b7 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py @@ -23,9 +23,12 @@ __author__ = "Akshayanat Chengam Saravanan" # pylint: enable=invalid-name -from typing import Literal, Optional +from typing import Literal from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + TicketIdMixin, +) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( EndpointQueryParams, ) @@ -33,16 +36,14 @@ BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, Field, ) - -# Common config for basic validation -COMMON_CONFIG = ConfigDict(validate_assignment=True) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) -class CredentialsSwitchesEndpointParams(EndpointQueryParams): +class CredentialsSwitchesEndpointParams(TicketIdMixin, EndpointQueryParams): """ # Summary @@ -50,7 +51,7 @@ class CredentialsSwitchesEndpointParams(EndpointQueryParams): ## Parameters - - ticket_id: Change control ticket ID (optional) + - ticket_id: Change control ticket ID (optional, from `TicketIdMixin`) ## Usage @@ -61,10 +62,8 @@ class CredentialsSwitchesEndpointParams(EndpointQueryParams): ``` """ - ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") - -class _V1ManageCredentialsSwitchesBase(BaseModel): +class _V1ManageCredentialsSwitchesBase(NDEndpointBaseModel): """ Base class for Credentials Switches endpoints. @@ -72,8 +71,6 @@ class _V1ManageCredentialsSwitchesBase(BaseModel): /api/v1/manage/credentials/switches endpoint. """ - model_config = COMMON_CONFIG - @property def _base_path(self) -> str: """Build the base endpoint path.""" @@ -120,10 +117,6 @@ class V1ManageCredentialsSwitchesPost(_V1ManageCredentialsSwitchesBase): ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageCredentialsSwitchesPost"] = Field( default="V1ManageCredentialsSwitchesPost", description="Class name for backward compatibility" ) diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py index d2e07828..cced1ce5 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py @@ -25,6 +25,9 @@ from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( FabricNameMixin, + FilterMixin, + MaxMixin, + OffsetMixin, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( EndpointQueryParams, @@ -33,16 +36,14 @@ BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, Field, ) - -# Common config for basic validation -COMMON_CONFIG = ConfigDict(validate_assignment=True) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) -class FabricBootstrapEndpointParams(EndpointQueryParams): +class FabricBootstrapEndpointParams(FilterMixin, MaxMixin, OffsetMixin, EndpointQueryParams): """ # Summary @@ -50,9 +51,9 @@ class FabricBootstrapEndpointParams(EndpointQueryParams): ## Parameters - - max: Maximum number of results to return (optional) - - offset: Pagination offset (optional) - - filter: Lucene filter expression (optional) + - max: Maximum number of results to return (optional, from `MaxMixin`) + - offset: Pagination offset (optional, from `OffsetMixin`) + - filter: Lucene filter expression (optional, from `FilterMixin`) ## Usage @@ -63,12 +64,8 @@ class FabricBootstrapEndpointParams(EndpointQueryParams): ``` """ - max: Optional[int] = Field(default=None, ge=1, description="Maximum number of results") - offset: Optional[int] = Field(default=None, ge=0, description="Pagination offset") - filter: Optional[str] = Field(default=None, min_length=1, description="Lucene filter expression") - -class _V1ManageFabricBootstrapBase(FabricNameMixin, BaseModel): +class _V1ManageFabricBootstrapBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Bootstrap endpoints. @@ -76,8 +73,6 @@ class _V1ManageFabricBootstrapBase(FabricNameMixin, BaseModel): /api/v1/manage/fabrics/{fabricName}/bootstrap endpoint. """ - model_config = COMMON_CONFIG - @property def _base_path(self) -> str: """Build the base endpoint path.""" @@ -131,10 +126,6 @@ class V1ManageFabricBootstrapGet(_V1ManageFabricBootstrapBase): ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricBootstrapGet"] = Field( default="V1ManageFabricBootstrapGet", description="Class name for backward compatibility" ) diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py index 078afc6c..e4d8e595 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py @@ -36,13 +36,11 @@ BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, Field, ) - -# Common config for basic validation -COMMON_CONFIG = ConfigDict(validate_assignment=True) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) class FabricConfigDeployEndpointParams(EndpointQueryParams): @@ -69,7 +67,7 @@ class FabricConfigDeployEndpointParams(EndpointQueryParams): incl_all_msd_switches: Optional[bool] = Field(default=None, description="Include all MSD fabric switches") -class _V1ManageFabricConfigBase(FabricNameMixin, BaseModel): +class _V1ManageFabricConfigBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Config endpoints. @@ -77,8 +75,6 @@ class _V1ManageFabricConfigBase(FabricNameMixin, BaseModel): /api/v1/manage/fabrics/{fabricName} endpoint family. """ - model_config = COMMON_CONFIG - @property def _base_path(self) -> str: """Build the base endpoint path.""" @@ -115,10 +111,6 @@ class V1ManageFabricConfigSavePost(_V1ManageFabricConfigBase): ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricConfigSavePost"] = Field( default="V1ManageFabricConfigSavePost", description="Class name for backward compatibility" ) @@ -177,10 +169,6 @@ class V1ManageFabricConfigDeployPost(_V1ManageFabricConfigBase): ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricConfigDeployPost"] = Field( default="V1ManageFabricConfigDeployPost", description="Class name for backward compatibility" ) @@ -239,10 +227,6 @@ class V1ManageFabricGet(_V1ManageFabricConfigBase): ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricGet"] = Field( default="V1ManageFabricGet", description="Class name for backward compatibility" ) @@ -286,10 +270,6 @@ class V1ManageFabricInventoryDiscoverGet(_V1ManageFabricConfigBase): ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricInventoryDiscoverGet"] = Field( default="V1ManageFabricInventoryDiscoverGet", description="Class name for backward compatibility" ) diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py index 928b4b67..471d2d9b 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py @@ -30,16 +30,14 @@ BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, Field, ) - -# Common config for basic validation -COMMON_CONFIG = ConfigDict(validate_assignment=True) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) -class _V1ManageFabricDiscoveryBase(FabricNameMixin, BaseModel): +class _V1ManageFabricDiscoveryBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Discovery endpoints. @@ -47,8 +45,6 @@ class _V1ManageFabricDiscoveryBase(FabricNameMixin, BaseModel): /api/v1/manage/fabrics/{fabricName}/actions/shallowDiscovery endpoint. """ - model_config = COMMON_CONFIG - @property def _base_path(self) -> str: """Build the base endpoint path.""" @@ -85,10 +81,6 @@ class V1ManageFabricShallowDiscoveryPost(_V1ManageFabricDiscoveryBase): ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricShallowDiscoveryPost"] = Field( default="V1ManageFabricShallowDiscoveryPost", description="Class name for backward compatibility" ) diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py index 6b90f160..86964dfd 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py @@ -29,8 +29,10 @@ from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ClusterNameMixin, FabricNameMixin, SwitchSerialNumberMixin, + TicketIdMixin, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( EndpointQueryParams, @@ -39,13 +41,11 @@ BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, Field, ) - -# Common config for basic validation -COMMON_CONFIG = ConfigDict(validate_assignment=True) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) # ============================================================================ @@ -53,7 +53,7 @@ # ============================================================================ -class SwitchActionsRemoveEndpointParams(EndpointQueryParams): +class SwitchActionsRemoveEndpointParams(TicketIdMixin, EndpointQueryParams): """ # Summary @@ -74,10 +74,9 @@ class SwitchActionsRemoveEndpointParams(EndpointQueryParams): """ force: Optional[bool] = Field(default=None, description="Force removal of switches") - ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") -class SwitchActionsTicketEndpointParams(EndpointQueryParams): +class SwitchActionsTicketEndpointParams(TicketIdMixin, EndpointQueryParams): """ # Summary @@ -96,10 +95,8 @@ class SwitchActionsTicketEndpointParams(EndpointQueryParams): ``` """ - ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") - -class SwitchActionsImportEndpointParams(EndpointQueryParams): +class SwitchActionsImportEndpointParams(ClusterNameMixin, TicketIdMixin, EndpointQueryParams): """ # Summary @@ -107,8 +104,8 @@ class SwitchActionsImportEndpointParams(EndpointQueryParams): ## Parameters - - cluster_name: Target cluster name for multi-cluster deployments (optional) - - ticket_id: Change control ticket ID (optional) + - cluster_name: Target cluster name for multi-cluster deployments (optional, from `ClusterNameMixin`) + - ticket_id: Change control ticket ID (optional, from `TicketIdMixin`) ## Usage @@ -119,16 +116,13 @@ class SwitchActionsImportEndpointParams(EndpointQueryParams): ``` """ - cluster_name: Optional[str] = Field(default=None, min_length=1, description="Target cluster name") - ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") - # ============================================================================ # Switch Actions Endpoints # ============================================================================ -class _V1ManageFabricSwitchActionsBase(FabricNameMixin, BaseModel): +class _V1ManageFabricSwitchActionsBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Switch Actions endpoints. @@ -136,8 +130,6 @@ class _V1ManageFabricSwitchActionsBase(FabricNameMixin, BaseModel): /api/v1/manage/fabrics/{fabricName}/switchActions endpoint. """ - model_config = COMMON_CONFIG - @property def _base_path(self) -> str: """Build the base endpoint path.""" @@ -190,10 +182,6 @@ class V1ManageFabricSwitchActionsRemovePost(_V1ManageFabricSwitchActionsBase): ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricSwitchActionsRemovePost"] = Field( default="V1ManageFabricSwitchActionsRemovePost", description="Class name for backward compatibility" ) @@ -266,10 +254,6 @@ class V1ManageFabricSwitchActionsChangeRolesPost(_V1ManageFabricSwitchActionsBas ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricSwitchActionsChangeRolesPost"] = Field( default="V1ManageFabricSwitchActionsChangeRolesPost", description="Class name for backward compatibility", @@ -345,10 +329,6 @@ class V1ManageFabricSwitchActionsImportBootstrapPost(_V1ManageFabricSwitchAction ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricSwitchActionsImportBootstrapPost"] = Field( default="V1ManageFabricSwitchActionsImportBootstrapPost", description="Class name for backward compatibility" ) @@ -431,10 +411,6 @@ class V1ManageFabricSwitchActionsPreProvisionPost(_V1ManageFabricSwitchActionsBa ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricSwitchActionsPreProvisionPost"] = Field( default="V1ManageFabricSwitchActionsPreProvisionPost", description="Class name for backward compatibility", @@ -471,7 +447,7 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class _V1ManageFabricSwitchActionsPerSwitchBase(FabricNameMixin, SwitchSerialNumberMixin, BaseModel): +class _V1ManageFabricSwitchActionsPerSwitchBase(FabricNameMixin, SwitchSerialNumberMixin, NDEndpointBaseModel): """ Base class for per-switch action endpoints. @@ -479,8 +455,6 @@ class _V1ManageFabricSwitchActionsPerSwitchBase(FabricNameMixin, SwitchSerialNum /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions endpoint. """ - model_config = COMMON_CONFIG - @property def _base_path(self) -> str: """Build the base endpoint path.""" @@ -535,10 +509,6 @@ class V1ManageFabricSwitchProvisionRMAPost(_V1ManageFabricSwitchActionsPerSwitch ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricSwitchProvisionRMAPost"] = Field( default="V1ManageFabricSwitchProvisionRMAPost", description="Class name for backward compatibility" ) @@ -574,7 +544,7 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class SwitchActionsClusterEndpointParams(EndpointQueryParams): +class SwitchActionsClusterEndpointParams(ClusterNameMixin, EndpointQueryParams): """ # Summary @@ -582,7 +552,7 @@ class SwitchActionsClusterEndpointParams(EndpointQueryParams): ## Parameters - - cluster_name: Target cluster name for multi-cluster deployments (optional) + - cluster_name: Target cluster name for multi-cluster deployments (optional, from `ClusterNameMixin`) ## Usage @@ -593,8 +563,6 @@ class SwitchActionsClusterEndpointParams(EndpointQueryParams): ``` """ - cluster_name: Optional[str] = Field(default=None, min_length=1, description="Target cluster name") - class V1ManageFabricSwitchChangeSerialNumberPost(_V1ManageFabricSwitchActionsPerSwitchBase): """ @@ -640,10 +608,6 @@ class V1ManageFabricSwitchChangeSerialNumberPost(_V1ManageFabricSwitchActionsPer ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricSwitchChangeSerialNumberPost"] = Field( default="V1ManageFabricSwitchChangeSerialNumberPost", description="Class name for backward compatibility" ) @@ -721,10 +685,6 @@ class V1ManageFabricSwitchActionsRediscoverPost(_V1ManageFabricSwitchActionsBase ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricSwitchActionsRediscoverPost"] = Field( default="V1ManageFabricSwitchActionsRediscoverPost", description="Class name for backward compatibility", diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py index 9594a64c..c9cc7e36 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py @@ -25,8 +25,12 @@ from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + ClusterNameMixin, FabricNameMixin, - SwitchSerialNumberMixin, + FilterMixin, + MaxMixin, + OffsetMixin, + TicketIdMixin, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( EndpointQueryParams, @@ -35,16 +39,14 @@ BasePath, ) from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( - BaseModel, - ConfigDict, Field, ) - -# Common config for basic validation -COMMON_CONFIG = ConfigDict(validate_assignment=True) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import ( + NDEndpointBaseModel, +) -class FabricSwitchesGetEndpointParams(EndpointQueryParams): +class FabricSwitchesGetEndpointParams(FilterMixin, MaxMixin, OffsetMixin, EndpointQueryParams): """ # Summary @@ -53,9 +55,9 @@ class FabricSwitchesGetEndpointParams(EndpointQueryParams): ## Parameters - hostname: Filter by switch hostname (optional) - - max: Maximum number of results (optional) - - offset: Pagination offset (optional) - - filter: Lucene filter expression (optional) + - max: Maximum number of results (optional, from `MaxMixin`) + - offset: Pagination offset (optional, from `OffsetMixin`) + - filter: Lucene filter expression (optional, from `FilterMixin`) ## Usage @@ -67,12 +69,9 @@ class FabricSwitchesGetEndpointParams(EndpointQueryParams): """ hostname: Optional[str] = Field(default=None, min_length=1, description="Filter by switch hostname") - max: Optional[int] = Field(default=None, ge=1, description="Maximum number of results") - offset: Optional[int] = Field(default=None, ge=0, description="Pagination offset") - filter: Optional[str] = Field(default=None, min_length=1, description="Lucene filter expression") -class FabricSwitchesAddEndpointParams(EndpointQueryParams): +class FabricSwitchesAddEndpointParams(ClusterNameMixin, TicketIdMixin, EndpointQueryParams): """ # Summary @@ -80,8 +79,8 @@ class FabricSwitchesAddEndpointParams(EndpointQueryParams): ## Parameters - - cluster_name: Target cluster name for multi-cluster deployments (optional) - - ticket_id: Change control ticket ID (optional) + - cluster_name: Target cluster name for multi-cluster deployments (optional, from `ClusterNameMixin`) + - ticket_id: Change control ticket ID (optional, from `TicketIdMixin`) ## Usage @@ -92,11 +91,8 @@ class FabricSwitchesAddEndpointParams(EndpointQueryParams): ``` """ - cluster_name: Optional[str] = Field(default=None, min_length=1, description="Target cluster name") - ticket_id: Optional[str] = Field(default=None, min_length=1, description="Change control ticket ID") - -class _V1ManageFabricSwitchesBase(FabricNameMixin, BaseModel): +class _V1ManageFabricSwitchesBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Switches endpoints. @@ -104,8 +100,6 @@ class _V1ManageFabricSwitchesBase(FabricNameMixin, BaseModel): /api/v1/manage/fabrics/{fabricName}/switches endpoint. """ - model_config = COMMON_CONFIG - @property def _base_path(self) -> str: """Build the base endpoint path.""" @@ -160,10 +154,6 @@ class V1ManageFabricSwitchesGet(_V1ManageFabricSwitchesBase): ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricSwitchesGet"] = Field( default="V1ManageFabricSwitchesGet", description="Class name for backward compatibility" ) @@ -237,10 +227,6 @@ class V1ManageFabricSwitchesPost(_V1ManageFabricSwitchesBase): ``` """ - # Version metadata - api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint") - min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint") - class_name: Literal["V1ManageFabricSwitchesPost"] = Field( default="V1ManageFabricSwitchesPost", description="Class name for backward compatibility" ) @@ -268,23 +254,3 @@ def path(self) -> str: def verb(self) -> HttpVerbEnum: """Return the HTTP verb for this endpoint.""" return HttpVerbEnum.POST - - -class _V1ManageFabricSwitchBase(FabricNameMixin, SwitchSerialNumberMixin, BaseModel): - """ - Base class for single switch endpoints. - - Provides common functionality for all HTTP methods on the - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn} endpoint. - """ - - model_config = COMMON_CONFIG - - @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") - if self.switch_sn is None: - raise ValueError("switch_sn must be set before accessing path") - return BasePath.path("fabrics", self.fabric_name, "switches", self.switch_sn) From 522f3602e02bf50d7b412e7cd8458a8c5a8b18d7 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Mon, 16 Mar 2026 11:33:40 +0530 Subject: [PATCH 08/27] Refactor Endpoints for consistency --- .../manage/nd_manage_switches/credentials.py | 12 +-- .../nd_manage_switches/fabric_bootstrap.py | 12 +-- .../nd_manage_switches/fabric_config.py | 36 ++++----- .../nd_manage_switches/fabric_discovery.py | 10 +-- .../fabric_switch_actions.py | 74 +++++++++---------- .../nd_manage_switches/fabric_switches.py | 22 +++--- plugins/module_utils/nd_switch_resources.py | 40 +++++----- .../nd_manage_switches/bootstrap_utils.py | 4 +- .../utils/nd_manage_switches/fabric_utils.py | 12 +-- .../nd_manage_switches/switch_wait_utils.py | 12 +-- 10 files changed, 117 insertions(+), 117 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py index 1576e9b7..9ca94d09 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py @@ -63,7 +63,7 @@ class CredentialsSwitchesEndpointParams(TicketIdMixin, EndpointQueryParams): """ -class _V1ManageCredentialsSwitchesBase(NDEndpointBaseModel): +class _EpManageCredentialsSwitchesBase(NDEndpointBaseModel): """ Base class for Credentials Switches endpoints. @@ -77,7 +77,7 @@ def _base_path(self) -> str: return BasePath.path("credentials", "switches") -class V1ManageCredentialsSwitchesPost(_V1ManageCredentialsSwitchesBase): +class EpManageCredentialsSwitchesPost(_EpManageCredentialsSwitchesBase): """ # Summary @@ -104,12 +104,12 @@ class V1ManageCredentialsSwitchesPost(_V1ManageCredentialsSwitchesBase): ```python # Create credentials without ticket - request = V1ManageCredentialsSwitchesPost() + request = EpManageCredentialsSwitchesPost() path = request.path verb = request.verb # Create credentials with change control ticket - request = V1ManageCredentialsSwitchesPost() + request = EpManageCredentialsSwitchesPost() request.endpoint_params.ticket_id = "CHG12345" path = request.path verb = request.verb @@ -117,8 +117,8 @@ class V1ManageCredentialsSwitchesPost(_V1ManageCredentialsSwitchesBase): ``` """ - class_name: Literal["V1ManageCredentialsSwitchesPost"] = Field( - default="V1ManageCredentialsSwitchesPost", description="Class name for backward compatibility" + class_name: Literal["EpManageCredentialsSwitchesPost"] = Field( + default="EpManageCredentialsSwitchesPost", description="Class name for backward compatibility" ) endpoint_params: CredentialsSwitchesEndpointParams = Field( default_factory=CredentialsSwitchesEndpointParams, description="Endpoint-specific query parameters" diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py index cced1ce5..25432637 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py @@ -65,7 +65,7 @@ class FabricBootstrapEndpointParams(FilterMixin, MaxMixin, OffsetMixin, Endpoint """ -class _V1ManageFabricBootstrapBase(FabricNameMixin, NDEndpointBaseModel): +class _EpManageFabricBootstrapBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Bootstrap endpoints. @@ -81,7 +81,7 @@ def _base_path(self) -> str: return BasePath.path("fabrics", self.fabric_name, "bootstrap") -class V1ManageFabricBootstrapGet(_V1ManageFabricBootstrapBase): +class EpManageFabricBootstrapGet(_EpManageFabricBootstrapBase): """ # Summary @@ -110,13 +110,13 @@ class V1ManageFabricBootstrapGet(_V1ManageFabricBootstrapBase): ```python # List all bootstrap switches - request = V1ManageFabricBootstrapGet() + request = EpManageFabricBootstrapGet() request.fabric_name = "MyFabric" path = request.path verb = request.verb # List with pagination - request = V1ManageFabricBootstrapGet() + request = EpManageFabricBootstrapGet() request.fabric_name = "MyFabric" request.endpoint_params.max = 50 request.endpoint_params.offset = 0 @@ -126,8 +126,8 @@ class V1ManageFabricBootstrapGet(_V1ManageFabricBootstrapBase): ``` """ - class_name: Literal["V1ManageFabricBootstrapGet"] = Field( - default="V1ManageFabricBootstrapGet", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricBootstrapGet"] = Field( + default="EpManageFabricBootstrapGet", description="Class name for backward compatibility" ) endpoint_params: FabricBootstrapEndpointParams = Field( default_factory=FabricBootstrapEndpointParams, description="Endpoint-specific query parameters" diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py index e4d8e595..5ab75028 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py @@ -67,7 +67,7 @@ class FabricConfigDeployEndpointParams(EndpointQueryParams): incl_all_msd_switches: Optional[bool] = Field(default=None, description="Include all MSD fabric switches") -class _V1ManageFabricConfigBase(FabricNameMixin, NDEndpointBaseModel): +class _EpManageFabricConfigBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Config endpoints. @@ -83,7 +83,7 @@ def _base_path(self) -> str: return BasePath.path("fabrics", self.fabric_name) -class V1ManageFabricConfigSavePost(_V1ManageFabricConfigBase): +class EpManageFabricConfigSavePost(_EpManageFabricConfigBase): """ # Summary @@ -104,15 +104,15 @@ class V1ManageFabricConfigSavePost(_V1ManageFabricConfigBase): ## Usage ```python - request = V1ManageFabricConfigSavePost() + request = EpManageFabricConfigSavePost() request.fabric_name = "MyFabric" path = request.path verb = request.verb ``` """ - class_name: Literal["V1ManageFabricConfigSavePost"] = Field( - default="V1ManageFabricConfigSavePost", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricConfigSavePost"] = Field( + default="EpManageFabricConfigSavePost", description="Class name for backward compatibility" ) @property @@ -126,7 +126,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class V1ManageFabricConfigDeployPost(_V1ManageFabricConfigBase): +class EpManageFabricConfigDeployPost(_EpManageFabricConfigBase): """ # Summary @@ -154,13 +154,13 @@ class V1ManageFabricConfigDeployPost(_V1ManageFabricConfigBase): ```python # Deploy with defaults - request = V1ManageFabricConfigDeployPost() + request = EpManageFabricConfigDeployPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Deploy forcing show run - request = V1ManageFabricConfigDeployPost() + request = EpManageFabricConfigDeployPost() request.fabric_name = "MyFabric" request.endpoint_params.force_show_run = True path = request.path @@ -169,8 +169,8 @@ class V1ManageFabricConfigDeployPost(_V1ManageFabricConfigBase): ``` """ - class_name: Literal["V1ManageFabricConfigDeployPost"] = Field( - default="V1ManageFabricConfigDeployPost", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricConfigDeployPost"] = Field( + default="EpManageFabricConfigDeployPost", description="Class name for backward compatibility" ) endpoint_params: FabricConfigDeployEndpointParams = Field( default_factory=FabricConfigDeployEndpointParams, description="Endpoint-specific query parameters" @@ -199,7 +199,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class V1ManageFabricGet(_V1ManageFabricConfigBase): +class EpManageFabricGet(_EpManageFabricConfigBase): """ # Summary @@ -220,15 +220,15 @@ class V1ManageFabricGet(_V1ManageFabricConfigBase): ## Usage ```python - request = V1ManageFabricGet() + request = EpManageFabricGet() request.fabric_name = "MyFabric" path = request.path verb = request.verb ``` """ - class_name: Literal["V1ManageFabricGet"] = Field( - default="V1ManageFabricGet", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricGet"] = Field( + default="EpManageFabricGet", description="Class name for backward compatibility" ) @property @@ -242,7 +242,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.GET -class V1ManageFabricInventoryDiscoverGet(_V1ManageFabricConfigBase): +class EpManageFabricInventoryDiscoverGet(_EpManageFabricConfigBase): """ # Summary @@ -263,15 +263,15 @@ class V1ManageFabricInventoryDiscoverGet(_V1ManageFabricConfigBase): ## Usage ```python - request = V1ManageFabricInventoryDiscoverGet() + request = EpManageFabricInventoryDiscoverGet() request.fabric_name = "MyFabric" path = request.path verb = request.verb ``` """ - class_name: Literal["V1ManageFabricInventoryDiscoverGet"] = Field( - default="V1ManageFabricInventoryDiscoverGet", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricInventoryDiscoverGet"] = Field( + default="EpManageFabricInventoryDiscoverGet", description="Class name for backward compatibility" ) @property diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py index 471d2d9b..e2416f98 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py @@ -37,7 +37,7 @@ ) -class _V1ManageFabricDiscoveryBase(FabricNameMixin, NDEndpointBaseModel): +class _EpManageFabricDiscoveryBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Discovery endpoints. @@ -53,7 +53,7 @@ def _base_path(self) -> str: return BasePath.path("fabrics", self.fabric_name, "actions", "shallowDiscovery") -class V1ManageFabricShallowDiscoveryPost(_V1ManageFabricDiscoveryBase): +class EpManageFabricShallowDiscoveryPost(_EpManageFabricDiscoveryBase): """ # Summary @@ -74,15 +74,15 @@ class V1ManageFabricShallowDiscoveryPost(_V1ManageFabricDiscoveryBase): ## Usage ```python - request = V1ManageFabricShallowDiscoveryPost() + request = EpManageFabricShallowDiscoveryPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb ``` """ - class_name: Literal["V1ManageFabricShallowDiscoveryPost"] = Field( - default="V1ManageFabricShallowDiscoveryPost", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricShallowDiscoveryPost"] = Field( + default="EpManageFabricShallowDiscoveryPost", description="Class name for backward compatibility" ) @property diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py index 86964dfd..40ea5808 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py @@ -122,7 +122,7 @@ class SwitchActionsImportEndpointParams(ClusterNameMixin, TicketIdMixin, Endpoin # ============================================================================ -class _V1ManageFabricSwitchActionsBase(FabricNameMixin, NDEndpointBaseModel): +class _EpManageFabricSwitchActionsBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Switch Actions endpoints. @@ -138,7 +138,7 @@ def _base_path(self) -> str: return BasePath.path("fabrics", self.fabric_name, "switchActions") -class V1ManageFabricSwitchActionsRemovePost(_V1ManageFabricSwitchActionsBase): +class EpManageFabricSwitchActionsRemovePost(_EpManageFabricSwitchActionsBase): """ # Summary @@ -166,13 +166,13 @@ class V1ManageFabricSwitchActionsRemovePost(_V1ManageFabricSwitchActionsBase): ```python # Remove switches - request = V1ManageFabricSwitchActionsRemovePost() + request = EpManageFabricSwitchActionsRemovePost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Remove switches with force and ticket - request = V1ManageFabricSwitchActionsRemovePost() + request = EpManageFabricSwitchActionsRemovePost() request.fabric_name = "MyFabric" request.endpoint_params.force = True request.endpoint_params.ticket_id = "CHG12345" @@ -182,8 +182,8 @@ class V1ManageFabricSwitchActionsRemovePost(_V1ManageFabricSwitchActionsBase): ``` """ - class_name: Literal["V1ManageFabricSwitchActionsRemovePost"] = Field( - default="V1ManageFabricSwitchActionsRemovePost", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricSwitchActionsRemovePost"] = Field( + default="EpManageFabricSwitchActionsRemovePost", description="Class name for backward compatibility" ) endpoint_params: SwitchActionsRemoveEndpointParams = Field( default_factory=SwitchActionsRemoveEndpointParams, description="Endpoint-specific query parameters" @@ -212,7 +212,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class V1ManageFabricSwitchActionsChangeRolesPost(_V1ManageFabricSwitchActionsBase): +class EpManageFabricSwitchActionsChangeRolesPost(_EpManageFabricSwitchActionsBase): """ # Summary @@ -239,13 +239,13 @@ class V1ManageFabricSwitchActionsChangeRolesPost(_V1ManageFabricSwitchActionsBas ```python # Change roles - request = V1ManageFabricSwitchActionsChangeRolesPost() + request = EpManageFabricSwitchActionsChangeRolesPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Change roles with change control ticket - request = V1ManageFabricSwitchActionsChangeRolesPost() + request = EpManageFabricSwitchActionsChangeRolesPost() request.fabric_name = "MyFabric" request.endpoint_params.ticket_id = "CHG12345" path = request.path @@ -254,8 +254,8 @@ class V1ManageFabricSwitchActionsChangeRolesPost(_V1ManageFabricSwitchActionsBas ``` """ - class_name: Literal["V1ManageFabricSwitchActionsChangeRolesPost"] = Field( - default="V1ManageFabricSwitchActionsChangeRolesPost", + class_name: Literal["EpManageFabricSwitchActionsChangeRolesPost"] = Field( + default="EpManageFabricSwitchActionsChangeRolesPost", description="Class name for backward compatibility", ) endpoint_params: SwitchActionsTicketEndpointParams = Field( @@ -285,7 +285,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class V1ManageFabricSwitchActionsImportBootstrapPost(_V1ManageFabricSwitchActionsBase): +class EpManageFabricSwitchActionsImportBootstrapPost(_EpManageFabricSwitchActionsBase): """ # Summary @@ -313,13 +313,13 @@ class V1ManageFabricSwitchActionsImportBootstrapPost(_V1ManageFabricSwitchAction ```python # Import bootstrap switches - request = V1ManageFabricSwitchActionsImportBootstrapPost() + request = EpManageFabricSwitchActionsImportBootstrapPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Import with cluster and ticket - request = V1ManageFabricSwitchActionsImportBootstrapPost() + request = EpManageFabricSwitchActionsImportBootstrapPost() request.fabric_name = "MyFabric" request.endpoint_params.cluster_name = "cluster1" request.endpoint_params.ticket_id = "CHG12345" @@ -329,8 +329,8 @@ class V1ManageFabricSwitchActionsImportBootstrapPost(_V1ManageFabricSwitchAction ``` """ - class_name: Literal["V1ManageFabricSwitchActionsImportBootstrapPost"] = Field( - default="V1ManageFabricSwitchActionsImportBootstrapPost", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricSwitchActionsImportBootstrapPost"] = Field( + default="EpManageFabricSwitchActionsImportBootstrapPost", description="Class name for backward compatibility" ) endpoint_params: SwitchActionsImportEndpointParams = Field( default_factory=SwitchActionsImportEndpointParams, description="Endpoint-specific query parameters" @@ -364,7 +364,7 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class V1ManageFabricSwitchActionsPreProvisionPost(_V1ManageFabricSwitchActionsBase): +class EpManageFabricSwitchActionsPreProvisionPost(_EpManageFabricSwitchActionsBase): """ # Summary @@ -395,13 +395,13 @@ class V1ManageFabricSwitchActionsPreProvisionPost(_V1ManageFabricSwitchActionsBa ```python # Pre-provision switches - request = V1ManageFabricSwitchActionsPreProvisionPost() + request = EpManageFabricSwitchActionsPreProvisionPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Pre-provision with cluster and ticket - request = V1ManageFabricSwitchActionsPreProvisionPost() + request = EpManageFabricSwitchActionsPreProvisionPost() request.fabric_name = "MyFabric" request.endpoint_params.cluster_name = "cluster1" request.endpoint_params.ticket_id = "CHG12345" @@ -411,8 +411,8 @@ class V1ManageFabricSwitchActionsPreProvisionPost(_V1ManageFabricSwitchActionsBa ``` """ - class_name: Literal["V1ManageFabricSwitchActionsPreProvisionPost"] = Field( - default="V1ManageFabricSwitchActionsPreProvisionPost", + class_name: Literal["EpManageFabricSwitchActionsPreProvisionPost"] = Field( + default="EpManageFabricSwitchActionsPreProvisionPost", description="Class name for backward compatibility", ) endpoint_params: SwitchActionsImportEndpointParams = Field( @@ -447,7 +447,7 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class _V1ManageFabricSwitchActionsPerSwitchBase(FabricNameMixin, SwitchSerialNumberMixin, NDEndpointBaseModel): +class _EpManageFabricSwitchActionsPerSwitchBase(FabricNameMixin, SwitchSerialNumberMixin, NDEndpointBaseModel): """ Base class for per-switch action endpoints. @@ -465,7 +465,7 @@ def _base_path(self) -> str: return BasePath.path("fabrics", self.fabric_name, "switches", self.switch_sn, "actions") -class V1ManageFabricSwitchProvisionRMAPost(_V1ManageFabricSwitchActionsPerSwitchBase): +class EpManageFabricSwitchProvisionRMAPost(_EpManageFabricSwitchActionsPerSwitchBase): """ # Summary @@ -492,14 +492,14 @@ class V1ManageFabricSwitchProvisionRMAPost(_V1ManageFabricSwitchActionsPerSwitch ```python # Provision RMA - request = V1ManageFabricSwitchProvisionRMAPost() + request = EpManageFabricSwitchProvisionRMAPost() request.fabric_name = "MyFabric" request.switch_sn = "SAL1948TRTT" path = request.path verb = request.verb # Provision RMA with change control ticket - request = V1ManageFabricSwitchProvisionRMAPost() + request = EpManageFabricSwitchProvisionRMAPost() request.fabric_name = "MyFabric" request.switch_sn = "SAL1948TRTT" request.endpoint_params.ticket_id = "CHG12345" @@ -509,8 +509,8 @@ class V1ManageFabricSwitchProvisionRMAPost(_V1ManageFabricSwitchActionsPerSwitch ``` """ - class_name: Literal["V1ManageFabricSwitchProvisionRMAPost"] = Field( - default="V1ManageFabricSwitchProvisionRMAPost", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricSwitchProvisionRMAPost"] = Field( + default="EpManageFabricSwitchProvisionRMAPost", description="Class name for backward compatibility" ) endpoint_params: SwitchActionsTicketEndpointParams = Field( default_factory=SwitchActionsTicketEndpointParams, description="Endpoint-specific query parameters" @@ -564,7 +564,7 @@ class SwitchActionsClusterEndpointParams(ClusterNameMixin, EndpointQueryParams): """ -class V1ManageFabricSwitchChangeSerialNumberPost(_V1ManageFabricSwitchActionsPerSwitchBase): +class EpManageFabricSwitchChangeSerialNumberPost(_EpManageFabricSwitchActionsPerSwitchBase): """ # Summary @@ -591,14 +591,14 @@ class V1ManageFabricSwitchChangeSerialNumberPost(_V1ManageFabricSwitchActionsPer ```python # Change serial number - request = V1ManageFabricSwitchChangeSerialNumberPost() + request = EpManageFabricSwitchChangeSerialNumberPost() request.fabric_name = "MyFabric" request.switch_sn = "SAL1948TRTT" path = request.path verb = request.verb # Change serial number with cluster name - request = V1ManageFabricSwitchChangeSerialNumberPost() + request = EpManageFabricSwitchChangeSerialNumberPost() request.fabric_name = "MyFabric" request.switch_sn = "SAL1948TRTT" request.endpoint_params.cluster_name = "cluster1" @@ -608,8 +608,8 @@ class V1ManageFabricSwitchChangeSerialNumberPost(_V1ManageFabricSwitchActionsPer ``` """ - class_name: Literal["V1ManageFabricSwitchChangeSerialNumberPost"] = Field( - default="V1ManageFabricSwitchChangeSerialNumberPost", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricSwitchChangeSerialNumberPost"] = Field( + default="EpManageFabricSwitchChangeSerialNumberPost", description="Class name for backward compatibility" ) endpoint_params: SwitchActionsClusterEndpointParams = Field( default_factory=SwitchActionsClusterEndpointParams, description="Endpoint-specific query parameters" @@ -643,7 +643,7 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class V1ManageFabricSwitchActionsRediscoverPost(_V1ManageFabricSwitchActionsBase): +class EpManageFabricSwitchActionsRediscoverPost(_EpManageFabricSwitchActionsBase): """ # Summary @@ -670,13 +670,13 @@ class V1ManageFabricSwitchActionsRediscoverPost(_V1ManageFabricSwitchActionsBase ```python # Rediscover switches - request = V1ManageFabricSwitchActionsRediscoverPost() + request = EpManageFabricSwitchActionsRediscoverPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Rediscover switches with change control ticket - request = V1ManageFabricSwitchActionsRediscoverPost() + request = EpManageFabricSwitchActionsRediscoverPost() request.fabric_name = "MyFabric" request.endpoint_params.ticket_id = "CHG12345" path = request.path @@ -685,8 +685,8 @@ class V1ManageFabricSwitchActionsRediscoverPost(_V1ManageFabricSwitchActionsBase ``` """ - class_name: Literal["V1ManageFabricSwitchActionsRediscoverPost"] = Field( - default="V1ManageFabricSwitchActionsRediscoverPost", + class_name: Literal["EpManageFabricSwitchActionsRediscoverPost"] = Field( + default="EpManageFabricSwitchActionsRediscoverPost", description="Class name for backward compatibility", ) endpoint_params: SwitchActionsTicketEndpointParams = Field( diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py index c9cc7e36..2334e98c 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py @@ -92,7 +92,7 @@ class FabricSwitchesAddEndpointParams(ClusterNameMixin, TicketIdMixin, EndpointQ """ -class _V1ManageFabricSwitchesBase(FabricNameMixin, NDEndpointBaseModel): +class _EpManageFabricSwitchesBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Switches endpoints. @@ -108,7 +108,7 @@ def _base_path(self) -> str: return BasePath.path("fabrics", self.fabric_name, "switches") -class V1ManageFabricSwitchesGet(_V1ManageFabricSwitchesBase): +class EpManageFabricSwitchesGet(_EpManageFabricSwitchesBase): """ # Summary @@ -138,13 +138,13 @@ class V1ManageFabricSwitchesGet(_V1ManageFabricSwitchesBase): ```python # List all switches - request = V1ManageFabricSwitchesGet() + request = EpManageFabricSwitchesGet() request.fabric_name = "MyFabric" path = request.path verb = request.verb # List with filtering - request = V1ManageFabricSwitchesGet() + request = EpManageFabricSwitchesGet() request.fabric_name = "MyFabric" request.endpoint_params.hostname = "leaf1" request.endpoint_params.max = 100 @@ -154,8 +154,8 @@ class V1ManageFabricSwitchesGet(_V1ManageFabricSwitchesBase): ``` """ - class_name: Literal["V1ManageFabricSwitchesGet"] = Field( - default="V1ManageFabricSwitchesGet", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricSwitchesGet"] = Field( + default="EpManageFabricSwitchesGet", description="Class name for backward compatibility" ) endpoint_params: FabricSwitchesGetEndpointParams = Field( default_factory=FabricSwitchesGetEndpointParams, description="Endpoint-specific query parameters" @@ -183,7 +183,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.GET -class V1ManageFabricSwitchesPost(_V1ManageFabricSwitchesBase): +class EpManageFabricSwitchesPost(_EpManageFabricSwitchesBase): """ # Summary @@ -211,13 +211,13 @@ class V1ManageFabricSwitchesPost(_V1ManageFabricSwitchesBase): ```python # Add switches - request = V1ManageFabricSwitchesPost() + request = EpManageFabricSwitchesPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Add switches with cluster and ticket - request = V1ManageFabricSwitchesPost() + request = EpManageFabricSwitchesPost() request.fabric_name = "MyFabric" request.endpoint_params.cluster_name = "cluster1" request.endpoint_params.ticket_id = "CHG12345" @@ -227,8 +227,8 @@ class V1ManageFabricSwitchesPost(_V1ManageFabricSwitchesBase): ``` """ - class_name: Literal["V1ManageFabricSwitchesPost"] = Field( - default="V1ManageFabricSwitchesPost", description="Class name for backward compatibility" + class_name: Literal["EpManageFabricSwitchesPost"] = Field( + default="EpManageFabricSwitchesPost", description="Class name for backward compatibility" ) endpoint_params: FabricSwitchesAddEndpointParams = Field( default_factory=FabricSwitchesAddEndpointParams, description="Endpoint-specific query parameters" diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index 1b67e18a..e8ecf087 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -58,22 +58,22 @@ build_poap_data_block, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switches import ( - V1ManageFabricSwitchesGet, - V1ManageFabricSwitchesPost, + EpManageFabricSwitchesGet, + EpManageFabricSwitchesPost, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_discovery import ( - V1ManageFabricShallowDiscoveryPost, + EpManageFabricShallowDiscoveryPost, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switch_actions import ( - V1ManageFabricSwitchProvisionRMAPost, - V1ManageFabricSwitchActionsImportBootstrapPost, - V1ManageFabricSwitchActionsPreProvisionPost, - V1ManageFabricSwitchActionsRemovePost, - V1ManageFabricSwitchActionsChangeRolesPost, - V1ManageFabricSwitchChangeSerialNumberPost, + EpManageFabricSwitchProvisionRMAPost, + EpManageFabricSwitchActionsImportBootstrapPost, + EpManageFabricSwitchActionsPreProvisionPost, + EpManageFabricSwitchActionsRemovePost, + EpManageFabricSwitchActionsChangeRolesPost, + EpManageFabricSwitchChangeSerialNumberPost, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.credentials import ( - V1ManageCredentialsSwitchesPost, + EpManageCredentialsSwitchesPost, ) @@ -429,7 +429,7 @@ def bulk_discover( log.debug("ENTER: bulk_discover()") log.debug(f"Discovering {len(switches)} switches in bulk") - endpoint = V1ManageFabricShallowDiscoveryPost() + endpoint = EpManageFabricShallowDiscoveryPost() endpoint.fabric_name = self.ctx.fabric seed_ips = [switch.seed_ip for switch in switches] @@ -641,7 +641,7 @@ def bulk_add( log.debug("ENTER: bulk_add()") log.debug(f"Adding {len(switches)} switches to fabric") - endpoint = V1ManageFabricSwitchesPost() + endpoint = EpManageFabricSwitchesPost() endpoint.fabric_name = self.ctx.fabric switch_discoveries = [] @@ -768,7 +768,7 @@ def bulk_delete( log.debug("EXIT: bulk_delete() - nothing to delete") return [] - endpoint = V1ManageFabricSwitchActionsRemovePost() + endpoint = EpManageFabricSwitchActionsRemovePost() endpoint.fabric_name = self.ctx.fabric payload = {"switchIds": serial_numbers} @@ -831,7 +831,7 @@ def bulk_save_credentials( log.debug("EXIT: bulk_save_credentials() - no credentials to save") return - endpoint = V1ManageCredentialsSwitchesPost() + endpoint = EpManageCredentialsSwitchesPost() for (username, password), serial_numbers in cred_groups.items(): creds_request = SwitchCredentialsRequestModel( @@ -904,7 +904,7 @@ def bulk_update_roles( log.debug("EXIT: bulk_update_roles() - no roles to update") return - endpoint = V1ManageFabricSwitchActionsChangeRolesPost() + endpoint = EpManageFabricSwitchActionsChangeRolesPost() endpoint.fabric_name = self.ctx.fabric payload = {"switchRoles": switch_roles} @@ -1412,7 +1412,7 @@ def _import_bootstrap_switches( log.debug("ENTER: _import_bootstrap_switches()") - endpoint = V1ManageFabricSwitchActionsImportBootstrapPost() + endpoint = EpManageFabricSwitchActionsImportBootstrapPost() endpoint.fabric_name = self.ctx.fabric request_model = ImportBootstrapSwitchesRequestModel(switches=models) @@ -1532,7 +1532,7 @@ def _preprovision_switches( log.debug("ENTER: _preprovision_switches()") - endpoint = V1ManageFabricSwitchActionsPreProvisionPost() + endpoint = EpManageFabricSwitchActionsPreProvisionPost() endpoint.fabric_name = self.ctx.fabric request_model = PreProvisionSwitchesRequestModel(switches=models) @@ -1665,7 +1665,7 @@ def _handle_poap_swap( f"{old_serial} → {new_serial}" ) - endpoint = V1ManageFabricSwitchChangeSerialNumberPost() + endpoint = EpManageFabricSwitchChangeSerialNumberPost() endpoint.fabric_name = fabric endpoint.switch_sn = old_serial @@ -2083,7 +2083,7 @@ def _provision_rma_switch( log.debug("ENTER: _provision_rma_switch()") - endpoint = V1ManageFabricSwitchProvisionRMAPost() + endpoint = EpManageFabricSwitchProvisionRMAPost() endpoint.fabric_name = self.ctx.fabric endpoint.switch_id = old_switch_id @@ -2824,7 +2824,7 @@ def _query_all_switches(self) -> List[Dict[str, Any]]: Returns: List of raw switch dictionaries returned by the controller. """ - endpoint = V1ManageFabricSwitchesGet() + endpoint = EpManageFabricSwitchesGet() endpoint.fabric_name = self.fabric self.log.debug(f"Querying all switches with endpoint: {endpoint.path}") self.log.debug(f"Query verb: {endpoint.verb}") diff --git a/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py b/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py index 89904696..b3e58c57 100644 --- a/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py +++ b/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py @@ -15,7 +15,7 @@ from typing import Any, Dict, List, Optional from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_bootstrap import ( - V1ManageFabricBootstrapGet, + EpManageFabricBootstrapGet, ) @@ -36,7 +36,7 @@ def query_bootstrap_switches( """ log.debug("ENTER: query_bootstrap_switches()") - endpoint = V1ManageFabricBootstrapGet() + endpoint = EpManageFabricBootstrapGet() endpoint.fabric_name = fabric log.debug(f"Bootstrap endpoint: {endpoint.path}") diff --git a/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py b/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py index 1fb8da7e..244f2b46 100644 --- a/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py +++ b/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py @@ -15,9 +15,9 @@ from typing import Any, Dict, Optional from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_config import ( - V1ManageFabricConfigDeployPost, - V1ManageFabricConfigSavePost, - V1ManageFabricGet, + EpManageFabricConfigDeployPost, + EpManageFabricConfigSavePost, + EpManageFabricGet, ) from .exceptions import SwitchOperationError @@ -44,13 +44,13 @@ def __init__( self.log = logger or logging.getLogger("nd.FabricUtils") # Pre-configure endpoints - self.ep_config_save = V1ManageFabricConfigSavePost() + self.ep_config_save = EpManageFabricConfigSavePost() self.ep_config_save.fabric_name = fabric - self.ep_config_deploy = V1ManageFabricConfigDeployPost() + self.ep_config_deploy = EpManageFabricConfigDeployPost() self.ep_config_deploy.fabric_name = fabric - self.ep_fabric_get = V1ManageFabricGet() + self.ep_fabric_get = EpManageFabricGet() self.ep_fabric_get.fabric_name = fabric # ----------------------------------------------------------------- diff --git a/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py b/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py index 31665ee7..bb81a673 100644 --- a/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py +++ b/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py @@ -15,13 +15,13 @@ from typing import Any, Dict, List, Optional from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_config import ( - V1ManageFabricInventoryDiscoverGet, + EpManageFabricInventoryDiscoverGet, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switches import ( - V1ManageFabricSwitchesGet, + EpManageFabricSwitchesGet, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switch_actions import ( - V1ManageFabricSwitchActionsRediscoverPost, + EpManageFabricSwitchActionsRediscoverPost, ) from .fabric_utils import FabricUtils @@ -94,13 +94,13 @@ def __init__( ) # Pre-configure endpoints - self.ep_switches_get = V1ManageFabricSwitchesGet() + self.ep_switches_get = EpManageFabricSwitchesGet() self.ep_switches_get.fabric_name = fabric - self.ep_inventory_discover = V1ManageFabricInventoryDiscoverGet() + self.ep_inventory_discover = EpManageFabricInventoryDiscoverGet() self.ep_inventory_discover.fabric_name = fabric - self.ep_rediscover = V1ManageFabricSwitchActionsRediscoverPost() + self.ep_rediscover = EpManageFabricSwitchActionsRediscoverPost() self.ep_rediscover.fabric_name = fabric # Cached greenfield flag From 095e474cef761379f58b2383fe4c80f0e7daf5d5 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Mon, 16 Mar 2026 14:01:13 +0530 Subject: [PATCH 09/27] Add NDOutput for displaying the result --- .../nd_manage_switches/config_models.py | 14 ++ .../nd_manage_switches/switch_data_models.py | 22 +++ plugins/module_utils/nd_switch_resources.py | 135 +++++++++++++++--- 3 files changed, 149 insertions(+), 22 deletions(-) diff --git a/plugins/module_utils/models/nd_manage_switches/config_models.py b/plugins/module_utils/models/nd_manage_switches/config_models.py index eb912e7e..267598c2 100644 --- a/plugins/module_utils/models/nd_manage_switches/config_models.py +++ b/plugins/module_utils/models/nd_manage_switches/config_models.py @@ -448,6 +448,20 @@ def operation_type(self) -> Literal["normal", "poap", "rma"]: description="Software version from inventory API" ) + def to_config_dict(self) -> Dict[str, Any]: + """Return the playbook config as a dict with all credentials stripped. + + Returns: + Dict of config fields with ``user_name``, ``password``, + ``discovery_username``, and ``discovery_password`` excluded. + """ + return self.to_config(exclude={ + "user_name": True, + "password": True, + "poap": {"__all__": {"discovery_username": True, "discovery_password": True}}, + "rma": {"__all__": {"discovery_username": True, "discovery_password": True}}, + }) + @model_validator(mode='before') @classmethod def reject_auth_proto_for_poap_rma(cls, data: Any) -> Any: diff --git a/plugins/module_utils/models/nd_manage_switches/switch_data_models.py b/plugins/module_utils/models/nd_manage_switches/switch_data_models.py index 08147ce4..ccfb571f 100644 --- a/plugins/module_utils/models/nd_manage_switches/switch_data_models.py +++ b/plugins/module_utils/models/nd_manage_switches/switch_data_models.py @@ -472,6 +472,28 @@ def from_response(cls, response: Dict[str, Any]) -> Self: 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", diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index e8ecf087..342b7dc1 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -23,6 +23,8 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType +from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput +from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches import ( SwitchRole, @@ -85,6 +87,43 @@ _DISCOVERY_MAX_HOPS: int = 0 +# ========================================================================= +# Output Collections +# ========================================================================= + +class SwitchOutputCollection(NDConfigCollection): + """Output collection for all output keys (previous, current, proposed, diff). + + Accepts ``SwitchDataModel``, ``SwitchConfigModel``, or ``_DiffRecord`` items + and serializes them via ``to_config_dict()``. + """ + + def __init__(self, model_class=None, items: Optional[List] = None): + # Store directly — skip add() type guard to support mixed-type diffs. + self._model_class = model_class + self._items: List = list(items) if items else [] + self._index: Dict = {} + + def to_ansible_config(self, **kwargs) -> List[Dict]: + return [item.to_config_dict() for item in self._items] + + def copy(self) -> "SwitchOutputCollection": + return SwitchOutputCollection( + model_class=self._model_class, + items=deepcopy(list(self._items)), + ) + + +@dataclass +class _DiffRecord: + """Wraps a plain dict as a diff entry, exposing ``to_config_dict()``.""" + + data: Dict[str, Any] + + def to_config_dict(self) -> Dict[str, Any]: + return self.data + + @dataclass class SwitchServiceContext: """Store shared dependencies used by service classes. @@ -2177,12 +2216,12 @@ def __init__( # Switch collections try: - self.proposed: List[SwitchDataModel] = [] - self.existing: List[SwitchDataModel] = [ - SwitchDataModel.model_validate(sw) - for sw in self._query_all_switches() - ] - self.previous: List[SwitchDataModel] = deepcopy(self.existing) + self.proposed: NDConfigCollection = NDConfigCollection(model_class=SwitchDataModel) + self.existing: SwitchOutputCollection = SwitchOutputCollection.from_api_response( + response_data=self._query_all_switches(), + model_class=SwitchDataModel, + ) + self.previous: SwitchOutputCollection = self.existing.copy() except Exception as e: msg = ( f"Failed to query fabric '{self.fabric}' inventory " @@ -2194,6 +2233,11 @@ def __init__( # Operation tracking self.nd_logs: List[Dict[str, Any]] = [] + # Output tracking — NDOutput serializes all collections via their + # overridden to_ansible_config() methods. + self.output = NDOutput(output_level=self.module.params.get("output_level", "normal")) + self.output.assign(before=self.previous, after=self.existing) + # Utility instances (SwitchWaitUtils / FabricUtils depend on self) self.fabric_utils = FabricUtils(self.nd, self.fabric, log) self.wait_utils = SwitchWaitUtils( @@ -2220,17 +2264,26 @@ def exit_json(self) -> None: self.results.build_final_result() final = self.results.final_result - final["logs"] = self.nd_logs - final["previous"] = ( - [sw.model_dump(by_alias=True) for sw in self.previous] - if self.previous - else [] - ) - final["current"] = ( - [sw.model_dump(by_alias=True) for sw in self.existing] - if self.existing - else [] - ) + # NDOutput owns serialization of before/after/proposed via their + # overridden to_ansible_config() methods. We only set _changed + # manually because self.existing is not re-queried after mutations + # so the auto-diff in NDOutput.format() would see no change. + self.output._changed = bool(final.get("changed", False)) + formatted = self.output.format() + + output_level = formatted["output_level"] + + # Rename before/after to previous/current for backward compatibility. + final["previous"] = formatted.pop("before", []) + final["current"] = formatted.pop("after", []) + final["output_level"] = output_level + final["diff"] = formatted.get("diff", []) + + if output_level in ("info", "debug"): + final["proposed"] = formatted.get("proposed", []) + if output_level == "debug": + # Override NDOutput's placeholder with real operation logs. + final["logs"] = self.nd_logs if True in self.results.failed: self.nd.module.fail_json(**final) @@ -2259,6 +2312,12 @@ def manage_state(self) -> None: if self.config else None ) + if proposed_config: + self.output.assign( + proposed=SwitchOutputCollection( + model_class=SwitchConfigModel, items=proposed_config + ) + ) if self.state == "deleted": return self._handle_deleted_state(proposed_config) return self._handle_query_state(proposed_config) @@ -2272,21 +2331,28 @@ def manage_state(self) -> None: proposed_config = SwitchDiffEngine.validate_configs( self.config, self.state, self.nd, self.log ) + # Register proposed config (credentials excluded via SwitchOutputCollection) + self.output.assign( + proposed=SwitchOutputCollection( + model_class=SwitchConfigModel, items=proposed_config + ) + ) self.operation_type = proposed_config[0].operation_type # POAP and RMA bypass normal discovery — delegate to handlers if self.operation_type == "poap": - return self.poap_handler.handle(proposed_config, self.existing) + return self.poap_handler.handle(proposed_config, list(self.existing)) if self.operation_type == "rma": - return self.rma_handler.handle(proposed_config, self.existing) + return self.rma_handler.handle(proposed_config, list(self.existing)) # Normal: discover → build proposed models → compute diff → delegate discovered_data = self.discovery.discover(proposed_config) - self.proposed = self.discovery.build_proposed( - proposed_config, discovered_data, self.existing + built = self.discovery.build_proposed( + proposed_config, discovered_data, list(self.existing) ) + self.proposed = NDConfigCollection(model_class=SwitchDataModel, items=built) diff = SwitchDiffEngine.compute_changes( - self.proposed, self.existing, self.log + list(self.proposed), list(self.existing), self.log ) state_handlers = { @@ -2436,6 +2502,7 @@ def _handle_merged_state( # Collect (serial_number, SwitchConfigModel) pairs for post-processing switch_actions: List[Tuple[str, SwitchConfigModel]] = [] + diff_items: List = [] # Phase 4: Bulk add new switches to fabric if switches_to_add and discovered_data: @@ -2479,6 +2546,17 @@ def _handle_merged_state( sn = disc.get("serialNumber") if sn: switch_actions.append((sn, cfg)) + # Discovery response has softwareVersion, hostname, + # model — richer than SwitchConfigModel fields. + diff_items.append(_DiffRecord({ + "seed_ip": cfg.seed_ip, + "serial_number": sn, + "hostname": disc.get("hostname"), + "model": disc.get("model"), + "role": cfg.role, + "software_version": disc.get("softwareVersion"), + "mode": None, + })) self._log_operation("add", cfg.seed_ip) # Phase 5: Collect migration switches for post-processing @@ -2487,6 +2565,9 @@ def _handle_merged_state( cfg = config_by_ip.get(mig_sw.fabric_management_ip) if cfg and mig_sw.switch_id: switch_actions.append((mig_sw.switch_id, cfg)) + # mig_sw is a SwitchDataModel — has all 7 fields including + # software_version and mode from the inventory API. + diff_items.append(mig_sw) self._log_operation("migrate", mig_sw.fabric_management_ip) if not switch_actions: @@ -2514,6 +2595,7 @@ def _handle_merged_state( all_preserve_config=all_preserve_config, update_roles=True, ) + self.output.assign(diff=SwitchOutputCollection(items=diff_items)) self.log.debug("EXIT: _handle_merged_state() - completed") @@ -2565,10 +2647,17 @@ def _merged_handle_role_changes( # Build (switch_id, SwitchConfigModel) pairs and apply role change role_actions: List[Tuple[str, SwitchConfigModel]] = [] + role_diff_items: List = [] for sw in role_change_switches: cfg = config_by_ip.get(sw.fabric_management_ip) if cfg and sw.switch_id: role_actions.append((sw.switch_id, cfg)) + # Use existing SwitchDataModel for software_version + mode; + # override role with the desired value from the playbook. + record = sw.to_config_dict() + if cfg.role is not None: + record["role"] = cfg.role + role_diff_items.append(_DiffRecord(record)) if role_actions: self.log.info( @@ -2576,6 +2665,7 @@ def _merged_handle_role_changes( ) self.fabric_ops.bulk_update_roles(role_actions) self.fabric_ops.finalize() + self.output.assign(diff=SwitchOutputCollection(items=role_diff_items)) def _merged_handle_idempotent( self, @@ -2812,6 +2902,7 @@ def _handle_deleted_state( f"Proceeding to delete {len(switches_to_delete)} switch(es) from fabric" ) self.fabric_ops.bulk_delete(switches_to_delete) + self.output.assign(diff=SwitchOutputCollection(items=switches_to_delete)) self.log.debug("EXIT: _handle_deleted_state()") # ===================================================================== From b0f3f3480d33ca9d3bba0519b58f27498df0ba23 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Mon, 16 Mar 2026 14:56:32 +0530 Subject: [PATCH 10/27] Update Results object API calls from Module + Operation Handling --- plugins/module_utils/nd_switch_resources.py | 59 +++++++++++++-------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index 342b7dc1..b9a35a3c 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -497,10 +497,11 @@ def bulk_discover( result = nd.rest_send.result_current results.action = "discover" + results.operation_type = OperationType.QUERY results.response_current = response results.result_current = result results.diff_current = payload - results.register_task_result() + results.register_api_call() # Extract discovered switches from response switches_data = [] @@ -748,10 +749,11 @@ def bulk_add( result = nd.rest_send.result_current results.action = "create" + results.operation_type = OperationType.CREATE results.response_current = response results.result_current = result results.diff_current = payload - results.register_task_result() + results.register_api_call() if not result.get("success"): msg = ( @@ -825,10 +827,11 @@ def bulk_delete( result = nd.rest_send.result_current results.action = "delete" + results.operation_type = OperationType.DELETE results.response_current = response results.result_current = result results.diff_current = {"deleted": serial_numbers} - results.register_task_result() + results.register_api_call() log.info(f"Bulk delete submitted for {len(serial_numbers)} switch(es)") log.debug("EXIT: bulk_delete()") @@ -895,13 +898,14 @@ def bulk_save_credentials( result = nd.rest_send.result_current results.action = "save_credentials" + results.operation_type = OperationType.UPDATE results.response_current = response results.result_current = result results.diff_current = { "switchIds": serial_numbers, "username": username, } - results.register_task_result() + results.register_api_call() log.info(f"Credentials saved for {len(serial_numbers)} switch(es)") except Exception as e: msg = ( @@ -958,10 +962,11 @@ def bulk_update_roles( result = nd.rest_send.result_current results.action = "update_role" + results.operation_type = OperationType.UPDATE results.response_current = response results.result_current = result results.diff_current = payload - results.register_task_result() + results.register_api_call() log.info(f"Roles updated for {len(switch_roles)} switch(es)") except Exception as e: msg = ( @@ -1110,12 +1115,13 @@ def handle( if nd.module.check_mode: log.info("Check mode: would run POAP bootstrap / pre-provision") results.action = "poap" + results.operation_type = OperationType.CREATE results.response_current = {"MESSAGE": "check mode — skipped"} results.result_current = {"success": True, "changed": True} results.diff_current = { "poap_switches": [pc.seed_ip for pc in proposed_config] } - results.register_task_result() + results.register_api_call() return # Classify entries @@ -1176,10 +1182,11 @@ def handle( if not bootstrap_entries and not preprov_entries and not swap_entries: log.warning("No POAP switch models built — nothing to process") results.action = "poap" + results.operation_type = OperationType.QUERY results.response_current = {"MESSAGE": "no switches to process"} results.result_current = {"success": True, "changed": False} results.diff_current = {} - results.register_task_result() + results.register_api_call() log.debug("EXIT: POAPHandler.handle()") @@ -1480,10 +1487,11 @@ def _import_bootstrap_switches( result = nd.rest_send.result_current results.action = "bootstrap" + results.operation_type = OperationType.CREATE results.response_current = response results.result_current = result results.diff_current = payload - results.register_task_result() + results.register_api_call() if not result.get("success"): msg = ( @@ -1600,10 +1608,11 @@ def _preprovision_switches( result = nd.rest_send.result_current results.action = "preprovision" + results.operation_type = OperationType.CREATE results.response_current = response results.result_current = result results.diff_current = payload - results.register_task_result() + results.register_api_call() if not result.get("success"): msg = ( @@ -1732,13 +1741,14 @@ def _handle_poap_swap( result = nd.rest_send.result_current results.action = "swap_serial" + results.operation_type = OperationType.UPDATE results.response_current = response results.result_current = result results.diff_current = { "old_serial": old_serial, "new_serial": new_serial, } - results.register_task_result() + results.register_api_call() if not result.get("success"): msg = ( @@ -1875,12 +1885,13 @@ def handle( if nd.module.check_mode: log.info("Check mode: would run RMA provision") results.action = "rma" + results.operation_type = OperationType.CREATE results.response_current = {"MESSAGE": "check mode — skipped"} results.result_current = {"success": True, "changed": True} results.diff_current = { "rma_switches": [pc.seed_ip for pc in proposed_config] } - results.register_task_result() + results.register_api_call() return # Collect (SwitchConfigModel, RMAConfigModel) pairs @@ -1897,10 +1908,11 @@ def handle( if not rma_entries: log.warning("No RMA entries found — nothing to process") results.action = "rma" + results.operation_type = OperationType.QUERY results.response_current = {"MESSAGE": "no switches to process"} results.result_current = {"success": True, "changed": False} results.diff_current = {} - results.register_task_result() + results.register_api_call() return log.info(f"Found {len(rma_entries)} RMA entry/entries to process") @@ -2146,13 +2158,14 @@ def _provision_rma_switch( result = nd.rest_send.result_current results.action = "rma" + results.operation_type = OperationType.CREATE results.response_current = response results.result_current = result results.diff_current = { "old_switch_id": old_switch_id, "new_switch_id": rma_model.new_switch_id, } - results.register_task_result() + results.register_api_call() if not result.get("success"): msg = ( @@ -2264,10 +2277,14 @@ def exit_json(self) -> None: self.results.build_final_result() final = self.results.final_result - # NDOutput owns serialization of before/after/proposed via their - # overridden to_ansible_config() methods. We only set _changed - # manually because self.existing is not re-queried after mutations - # so the auto-diff in NDOutput.format() would see no change. + # Re-query the fabric to get the actual post-operation inventory so + # that "current" reflects real state rather than the pre-op snapshot. + if True not in self.results.failed and not self.nd.module.check_mode: + self.existing = SwitchOutputCollection.from_api_response( + response_data=self._query_all_switches(), model_class=SwitchDataModel + ) + self.output.assign(after=self.existing) + self.output._changed = bool(final.get("changed", False)) formatted = self.output.format() @@ -2432,7 +2449,7 @@ def _handle_query_state( "success": True, } self.results.diff_current = {} - self.results.register_task_result() + self.results.register_api_call() self.log.debug(f"Returning {len(switch_data)} switches in results") self.log.debug("EXIT: _handle_query_state()") @@ -2497,7 +2514,7 @@ def _handle_merged_state( "to_add": [sw.fabric_management_ip for sw in switches_to_add], "migration_mode": [sw.fabric_management_ip for sw in migration_switches], } - self.results.register_task_result() + self.results.register_api_call() return # Collect (serial_number, SwitchConfigModel) pairs for post-processing @@ -2791,7 +2808,7 @@ def _handle_overridden_state( "to_add": n_add, "migration_mode": n_migrate, } - self.results.register_task_result() + self.results.register_api_call() return switches_to_delete: List[SwitchDataModel] = [] @@ -2895,7 +2912,7 @@ def _handle_deleted_state( self.results.diff_current = { "to_delete": [sw.fabric_management_ip for sw in switches_to_delete], } - self.results.register_task_result() + self.results.register_api_call() return self.log.info( From 47db2b11f71eae9219b3c05383716b5a3d45889a Mon Sep 17 00:00:00 2001 From: AKDRG Date: Mon, 16 Mar 2026 15:37:09 +0530 Subject: [PATCH 11/27] Fix ConfigSync Status Error --- plugins/module_utils/nd_switch_resources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index b9a35a3c..b9dc274a 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -2657,7 +2657,7 @@ def _merged_handle_role_changes( f"Role change not possible for switch " f"{sw.fabric_management_ip} ({sw.switch_id}). " f"configSyncStatus is " - f"'{status.value if status else 'unknown'}', " + f"'{getattr(status, 'value', status) if status else 'unknown'}', " f"expected '{ConfigSyncStatus.NOT_APPLICABLE.value}'." ) ) @@ -2717,7 +2717,7 @@ def _merged_handle_idempotent( self.log.info( f"Switch {sw.fabric_management_ip} ({sw.switch_id}) is " f"config-idempotent but configSyncStatus is " - f"'{status.value if status else 'unknown'}' — " + f"'{getattr(status, 'value', status) if status else 'unknown'}' — " f"will run config save and deploy" ) finalize_needed = True From 28703b54fbdb16eb073cc02c5fa7dceb0bd525c1 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Mon, 16 Mar 2026 17:22:43 +0530 Subject: [PATCH 12/27] Add duplicate ip validation in configs, fix api changes in module --- plugins/module_utils/nd_switch_resources.py | 22 +++++++++++++++++++-- plugins/modules/nd_manage_switches.py | 4 ++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index b9dc274a..ba5d3d41 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -221,6 +221,24 @@ def validate_configs( else: raise + # Duplicate seed_ip check + seen_ips: set = set() + duplicate_ips: set = set() + for cfg in validated_configs: + if cfg.seed_ip in seen_ips: + duplicate_ips.add(cfg.seed_ip) + seen_ips.add(cfg.seed_ip) + if duplicate_ips: + error_msg = ( + f"Duplicate seed_ip entries found in config: " + f"{sorted(duplicate_ips)}. Each switch must appear only once." + ) + log.error(error_msg) + if hasattr(nd, 'module'): + nd.module.fail_json(msg=error_msg) + else: + raise ValueError(error_msg) + operation_type = validated_configs[0].operation_type log.info( f"Successfully validated {len(validated_configs)} " @@ -320,10 +338,10 @@ def compute_changes( continue prop_dict = prop_sw.model_dump( - by_alias=True, exclude_none=True, include=compare_fields + by_alias=False, exclude_none=True, include=compare_fields ) existing_dict = existing_sw.model_dump( - by_alias=True, exclude_none=True, include=compare_fields + by_alias=False, exclude_none=True, include=compare_fields ) if prop_dict == existing_dict: diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index 559f1bd0..f3d14ca6 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -582,7 +582,7 @@ def main(): } results.diff_current = {} - results.register_task_result() + results.register_api_call() results.build_final_result() # Add error details if debug output is requested @@ -608,7 +608,7 @@ def main(): "found": False, } results.diff_current = {} - results.register_task_result() + results.register_api_call() results.build_final_result() if output_level == "debug": From 2372a741419124281de092deb032005e58d9ce1b Mon Sep 17 00:00:00 2001 From: AKDRG Date: Tue, 17 Mar 2026 00:28:00 +0530 Subject: [PATCH 13/27] RMA, POAP Bootstrap and Diff Fixes --- .../models/nd_manage_switches/__init__.py | 2 - .../nd_manage_switches/bootstrap_models.py | 10 + .../nd_manage_switches/config_models.py | 21 +- .../nd_manage_switches/preprovision_models.py | 29 +- .../models/nd_manage_switches/rma_models.py | 70 +-- plugins/module_utils/nd_switch_resources.py | 548 +++++++++--------- .../nd_manage_switches/switch_wait_utils.py | 93 ++- 7 files changed, 392 insertions(+), 381 deletions(-) diff --git a/plugins/module_utils/models/nd_manage_switches/__init__.py b/plugins/module_utils/models/nd_manage_switches/__init__.py index 6ddd6cd8..17415a32 100644 --- a/plugins/module_utils/models/nd_manage_switches/__init__.py +++ b/plugins/module_utils/models/nd_manage_switches/__init__.py @@ -73,7 +73,6 @@ # --- RMA models --- from .rma_models import ( # noqa: F401 - RMASpecificModel, RMASwitchModel, ) @@ -130,7 +129,6 @@ "PreProvisionSwitchesRequestModel", "PreProvisionSwitchModel", # RMA models - "RMASpecificModel", "RMASwitchModel", # Switch actions models "ChangeSwitchSerialNumberRequestModel", diff --git a/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py b/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py index 864a8e25..9825c0c3 100644 --- a/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py +++ b/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py @@ -272,6 +272,16 @@ class BootstrapImportSwitchModel(NDBaseModel): default=None, alias="discoveryPassword" ) + remote_credential_store: RemoteCredentialStore = Field( + default=RemoteCredentialStore.LOCAL, + alias="remoteCredentialStore", + description="Type of credential store for discovery credentials" + ) + remote_credential_store_key: Optional[str] = Field( + default=None, + alias="remoteCredentialStoreKey", + description="Remote credential store key for discovery credentials" + ) data: Optional[Dict[str, Any]] = Field( default=None, description="Bootstrap configuration data block (gatewayIpMask, models)" diff --git a/plugins/module_utils/models/nd_manage_switches/config_models.py b/plugins/module_utils/models/nd_manage_switches/config_models.py index 267598c2..4c22acec 100644 --- a/plugins/module_utils/models/nd_manage_switches/config_models.py +++ b/plugins/module_utils/models/nd_manage_switches/config_models.py @@ -268,15 +268,15 @@ class RMAConfigModel(NDNestedModel): min_length=1, description="Serial number of switch to be replaced by RMA" ) - model: str = Field( - ..., + model: Optional[str] = Field( + default=None, min_length=1, - description="Model of switch to Bootstrap for RMA" + description="Model of switch to Bootstrap for RMA. If omitted, sourced from bootstrap API." ) - version: str = Field( - ..., + version: Optional[str] = Field( + default=None, min_length=1, - description="Software version of switch to Bootstrap for RMA" + description="Software version of switch to Bootstrap for RMA. If omitted, sourced from bootstrap API." ) # Optional fields @@ -286,13 +286,14 @@ class RMAConfigModel(NDNestedModel): description="Name of the image policy to be applied on switch during Bootstrap for RMA" ) - # Required config data for RMA (models list + gateway) - config_data: ConfigDataModel = Field( - ..., + # Optional config data for RMA (models list + gateway); sourced from bootstrap API if omitted + config_data: Optional[ConfigDataModel] = Field( + default=None, alias="configData", description=( "Basic config data of switch to Bootstrap for RMA. " - "'models' (list of module models) and 'gateway' (IP with mask) are mandatory." + "'models' (list of module models) and 'gateway' (IP with mask) are mandatory " + "when provided. If omitted, sourced from bootstrap API." ), ) diff --git a/plugins/module_utils/models/nd_manage_switches/preprovision_models.py b/plugins/module_utils/models/nd_manage_switches/preprovision_models.py index 1cd8b8a0..9e34910a 100644 --- a/plugins/module_utils/models/nd_manage_switches/preprovision_models.py +++ b/plugins/module_utils/models/nd_manage_switches/preprovision_models.py @@ -14,7 +14,7 @@ __metaclass__ = type from ipaddress import ip_network -from pydantic import Field, field_validator, model_validator +from pydantic import Field, computed_field, field_validator from typing import Any, Dict, List, Optional, ClassVar, Literal from typing_extensions import Self @@ -110,14 +110,6 @@ class PreProvisionSwitchModel(NDBaseModel): ) # --- bootstrapCredential fields (optional) --- - use_new_credentials: bool = Field( - default=False, - alias="useNewCredentials", - description=( - "If True, use discoveryUsername and discoveryPassword for local " - "remoteCredentialStore or use remoteCredentialStoreKey for CyberArk" - ), - ) discovery_username: Optional[str] = Field( default=None, alias="discoveryUsername", @@ -128,11 +120,16 @@ class PreProvisionSwitchModel(NDBaseModel): alias="discoveryPassword", description="Password for switch discovery post pre-provision", ) - remote_credential_store: Optional[RemoteCredentialStore] = Field( - default=None, + remote_credential_store: RemoteCredentialStore = Field( + default=RemoteCredentialStore.LOCAL, alias="remoteCredentialStore", description="Type of credential store for discovery credentials", ) + remote_credential_store_key: Optional[str] = Field( + default=None, + alias="remoteCredentialStoreKey", + description="Remote credential store key for discovery credentials", + ) # --- Validators --- @@ -175,11 +172,11 @@ def validate_gateway(cls, v: str) -> str: raise ValueError(f"Invalid gatewayIpMask: {v}") from exc return v - @model_validator(mode='after') - def derive_use_new_credentials(self) -> Self: - """Auto-set useNewCredentials when both discoveryUsername and discoveryPassword are provided.""" - self.use_new_credentials = bool(self.discovery_username and self.discovery_password) - return self + @computed_field(alias="useNewCredentials") + @property + def use_new_credentials(self) -> bool: + """Derive useNewCredentials from discoveryUsername and discoveryPassword.""" + return bool(self.discovery_username and self.discovery_password) def to_payload(self) -> Dict[str, Any]: """Convert to API payload format matching preProvision spec.""" diff --git a/plugins/module_utils/models/nd_manage_switches/rma_models.py b/plugins/module_utils/models/nd_manage_switches/rma_models.py index 1f5be8b5..12f6c891 100644 --- a/plugins/module_utils/models/nd_manage_switches/rma_models.py +++ b/plugins/module_utils/models/nd_manage_switches/rma_models.py @@ -26,71 +26,6 @@ ) from .validators import SwitchValidators - -class RMASpecificModel(NDBaseModel): - """ - Replacement-switch-specific fields used in an RMA bootstrap operation. - """ - identifiers: ClassVar[List[str]] = [] - identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "singleton" - hostname: str = Field( - ..., - description="Hostname of the switch" - ) - ip: str = Field( - ..., - description="IP address of the switch" - ) - new_switch_id: str = Field( - ..., - alias="newSwitchId", - description="SwitchId (serial number) of the switch" - ) - public_key: str = Field( - ..., - alias="publicKey", - description="Public Key" - ) - finger_print: str = Field( - ..., - alias="fingerPrint", - description="Fingerprint" - ) - dhcp_bootstrap_ip: Optional[str] = Field( - default=None, - alias="dhcpBootstrapIp", - description="This is used for device day-0 bring-up when using inband reachability" - ) - seed_switch: bool = Field( - default=False, - alias="seedSwitch", - description="Use as seed switch" - ) - - @field_validator('hostname', mode='before') - @classmethod - def validate_host(cls, v: str) -> str: - result = SwitchValidators.validate_hostname(v) - if result is None: - raise ValueError("hostname cannot be empty") - return result - - @field_validator('ip', 'dhcp_bootstrap_ip', mode='before') - @classmethod - def validate_ip(cls, v: Optional[str]) -> Optional[str]: - if v is None: - return None - return SwitchValidators.validate_ip_address(v) - - @field_validator('new_switch_id', mode='before') - @classmethod - def validate_serial(cls, v: str) -> str: - result = SwitchValidators.validate_serial_number(v) - if result is None: - raise ValueError("new_switch_id cannot be empty") - return result - - class RMASwitchModel(NDBaseModel): """ Request payload for provisioning a replacement (RMA) switch via bootstrap. @@ -183,6 +118,10 @@ class RMASwitchModel(NDBaseModel): default=False, alias="seedSwitch" ) + data: Optional[Dict[str, Any]] = Field( + default=None, + description="Bootstrap configuration data block (gatewayIpMask, models)" + ) @field_validator('gateway_ip_mask', mode='before') @classmethod @@ -253,6 +192,5 @@ def from_response(cls, response: Dict[str, Any]) -> Self: __all__ = [ - "RMASpecificModel", "RMASwitchModel", ] diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index ba5d3d41..e0fa6ef0 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -142,6 +142,7 @@ class SwitchServiceContext: log: logging.Logger save_config: bool = True deploy_config: bool = True + output: Optional[NDOutput] = None # ========================================================================= @@ -298,7 +299,6 @@ def compute_changes( changes: Dict[str, list] = { "to_add": [], "to_update": [], - "role_change": [], "to_delete": [], "migration_mode": [], "idempotent": [], @@ -348,28 +348,16 @@ def compute_changes( log.debug(f"Switch {ip} is idempotent — no changes needed") changes["idempotent"].append(prop_sw) else: - diff_keys = { - k for k in set(prop_dict) | set(existing_dict) - if prop_dict.get(k) != existing_dict.get(k) - } - if diff_keys == {"switch_role"}: - log.info( - f"Switch {ip} has role-only difference — marking role_change. " - f"proposed: {prop_dict.get('switch_role')}, " - f"existing: {existing_dict.get('switch_role')}" - ) - changes["role_change"].append(prop_sw) - else: - log.info( - f"Switch {ip} has differences — marking to_update. " - f"Changed fields: {diff_keys}" - ) - log.debug( - f"Switch {ip} diff detail — " - f"proposed: { {k: prop_dict.get(k) for k in diff_keys} }, " - f"existing: { {k: existing_dict.get(k) for k in diff_keys} }" - ) - changes["to_update"].append(prop_sw) + log.info( + f"Switch {ip} has differences — marking to_update. " + f"Changed fields: {diff_keys}" + ) + log.debug( + f"Switch {ip} diff detail — " + f"proposed: { {k: prop_dict.get(k) for k in diff_keys} }, " + f"existing: { {k: existing_dict.get(k) for k in diff_keys} }" + ) + changes["to_update"].append(prop_sw) # Switches in existing but not in proposed (for overridden state) proposed_ids = {sw.switch_id for sw in proposed} @@ -385,7 +373,6 @@ def compute_changes( f"Compute changes summary: " f"to_add={len(changes['to_add'])}, " f"to_update={len(changes['to_update'])}, " - f"role_change={len(changes['role_change'])}, " f"to_delete={len(changes['to_delete'])}, " f"migration_mode={len(changes['migration_mode'])}, " f"idempotent={len(changes['idempotent'])}" @@ -393,6 +380,106 @@ def compute_changes( log.debug("EXIT: compute_changes()") return changes + @staticmethod + def validate_switch_api_fields( + nd: NDModule, + serial: str, + model: Optional[str], + version: Optional[str], + config_data, + bootstrap_data: Dict[str, Any], + log: logging.Logger, + context: str, + hostname: Optional[str] = None, + ) -> None: + """Validate user-supplied switch fields against the bootstrap API response. + + Only fields that are provided (non-None) are validated against the API. + Fields that are omitted are silently filled in from the API at build + time — no error is raised for those. Any omitted fields are logged at + INFO level so the operator can see what was sourced from the API. + + Args: + nd: ND module wrapper used for failure handling. + serial: Serial number of the switch being processed. + model: User-provided switch model, or None if omitted. + version: User-provided software version, or None if omitted. + config_data: User-provided ``ConfigDataModel``, or None if omitted. + bootstrap_data: Matching entry from the bootstrap GET API. + log: Logger instance. + context: Label used in error messages (e.g. ``"Bootstrap"`` or ``"RMA"``). + hostname: User-provided hostname, or None if omitted (bootstrap only). + + Returns: + None. + """ + bs_data = bootstrap_data.get("data") or {} + mismatches: List[str] = [] + + if model is not None and model != bootstrap_data.get("model"): + mismatches.append( + f"model: provided '{model}', " + f"bootstrap reports '{bootstrap_data.get('model')}'" + ) + + if version is not None and version != bootstrap_data.get("softwareVersion"): + mismatches.append( + f"version: provided '{version}', " + f"bootstrap reports '{bootstrap_data.get('softwareVersion')}'" + ) + + if config_data is not None: + bs_gateway = ( + bootstrap_data.get("gatewayIpMask") + or bs_data.get("gatewayIpMask") + ) + if config_data.gateway is not None and config_data.gateway != bs_gateway: + mismatches.append( + f"config_data.gateway: provided '{config_data.gateway}', " + f"bootstrap reports '{bs_gateway}'" + ) + + bs_models = bs_data.get("models", []) + if ( + config_data.models + and sorted(config_data.models) != sorted(bs_models) + ): + mismatches.append( + f"config_data.models: provided {config_data.models}, " + f"bootstrap reports {bs_models}" + ) + + if mismatches: + nd.module.fail_json( + msg=( + f"{context} field mismatch for serial '{serial}'. " + f"The following provided values do not match the " + f"bootstrap API data:\n" + + "\n".join(f" - {m}" for m in mismatches) + ) + ) + + # Log any fields that were omitted and will be sourced from the API + pulled: List[str] = [] + if model is None: + pulled.append("model") + if version is None: + pulled.append("version") + if hostname is None: + pulled.append("hostname") + if config_data is None: + pulled.append("config_data (gateway + models)") + if pulled: + log.info( + f"{context} serial '{serial}': the following fields were not " + f"provided and will be sourced from the bootstrap API: " + f"{', '.join(pulled)}" + ) + else: + log.debug( + f"{context} field validation passed for serial '{serial}'" + ) + # ========================================================================= # Switch Discovery Service @@ -1196,6 +1283,20 @@ def handle( if preprov_models: self._preprovision_switches(preprov_models) + if self.ctx.output: + diff_items = [ + _DiffRecord({ + "serial_number": m.serial_number, + "hostname": m.hostname, + "ip": m.ip, + "model": m.model, + "software_version": m.software_version, + "role": m.switch_role, + }) + for m in preprov_models + ] + self.ctx.output.assign(diff=SwitchOutputCollection(items=diff_items)) + # Edge case: nothing actionable if not bootstrap_entries and not preprov_entries and not swap_entries: log.warning("No POAP switch models built — nothing to process") @@ -1251,7 +1352,17 @@ def _handle_poap_bootstrap( # Validate user-supplied fields against bootstrap data (if provided) # and warn about any fields that will be pulled from the API. - self._validate_bootstrap_fields(poap_cfg, bootstrap_data, log) + SwitchDiffEngine.validate_switch_api_fields( + nd=nd, + serial=poap_cfg.serial_number, + model=poap_cfg.model, + version=poap_cfg.version, + config_data=poap_cfg.config_data, + bootstrap_data=bootstrap_data, + log=log, + context="Bootstrap", + hostname=poap_cfg.hostname, + ) model = self._build_bootstrap_import_model( switch_cfg, poap_cfg, bootstrap_data @@ -1281,91 +1392,22 @@ def _handle_poap_bootstrap( skip_greenfield_check=True, ) - log.debug("EXIT: _handle_poap_bootstrap()") + if self.ctx.output: + import_by_serial = {m.serial_number: m for m in import_models} + diff_items = [ + _DiffRecord({ + "seed_ip": switch_cfg.seed_ip, + "serial_number": serial, + "hostname": import_by_serial[serial].hostname if serial in import_by_serial else None, + "model": import_by_serial[serial].model if serial in import_by_serial else None, + "software_version": import_by_serial[serial].version if serial in import_by_serial else None, + "role": switch_cfg.role, + }) + for serial, switch_cfg in switch_actions + ] + self.ctx.output.assign(diff=SwitchOutputCollection(items=diff_items)) - def _validate_bootstrap_fields( - self, - poap_cfg: POAPConfigModel, - bootstrap_data: Dict[str, Any], - log: logging.Logger, - ) -> None: - """Validate user-supplied bootstrap fields against the bootstrap API response. - - If a field is provided in the playbook config, it must match what the - bootstrap API reports. Fields that are omitted are silently filled in - from the API at import time — no error is raised for those. - - Args: - poap_cfg: POAP config entry from the playbook. - bootstrap_data: Matching entry from the bootstrap GET API. - log: Logger instance. - - Returns: - None. - """ - serial = poap_cfg.serial_number - bs_data = bootstrap_data.get("data") or {} - mismatches: List[str] = [] - - if poap_cfg.model and poap_cfg.model != bootstrap_data.get("model"): - mismatches.append( - f"model: provided '{poap_cfg.model}', " - f"bootstrap reports '{bootstrap_data.get('model')}'" - ) - - if poap_cfg.version and poap_cfg.version != bootstrap_data.get("softwareVersion"): - mismatches.append( - f"version: provided '{poap_cfg.version}', " - f"bootstrap reports '{bootstrap_data.get('softwareVersion')}'" - ) - - if poap_cfg.config_data: - bs_gateway = ( - bootstrap_data.get("gatewayIpMask") - or bs_data.get("gatewayIpMask") - ) - if poap_cfg.config_data.gateway and poap_cfg.config_data.gateway != bs_gateway: - mismatches.append( - f"config_data.gateway: provided '{poap_cfg.config_data.gateway}', " - f"bootstrap reports '{bs_gateway}'" - ) - - bs_models = bs_data.get("models", []) - if ( - poap_cfg.config_data.models - and sorted(poap_cfg.config_data.models) != sorted(bs_models) - ): - mismatches.append( - f"config_data.models: provided {poap_cfg.config_data.models}, " - f"bootstrap reports {bs_models}" - ) - - if mismatches: - self.ctx.nd.module.fail_json( - msg=( - f"Bootstrap field mismatch for serial '{serial}'. " - f"The following provided values do not match the " - f"bootstrap API data:\n" - + "\n".join(f" - {m}" for m in mismatches) - ) - ) - - # Log which fields will be sourced from the bootstrap API - pulled: List[str] = [] - if not poap_cfg.model: - pulled.append("model") - if not poap_cfg.version: - pulled.append("version") - if not poap_cfg.hostname: - pulled.append("hostname") - if not poap_cfg.config_data: - pulled.append("config_data (gateway + models)") - if pulled: - log.info( - f"Bootstrap serial '{serial}': the following fields were not " - f"provided and will be sourced from the bootstrap API: " - f"{', '.join(pulled)}" - ) + log.debug("EXIT: _handle_poap_bootstrap()") def _build_bootstrap_import_model( self, @@ -1948,6 +1990,7 @@ def handle( # Build and submit each RMA request switch_actions: List[Tuple[str, SwitchConfigModel]] = [] + rma_diff_data: List[Tuple[str, str, SwitchConfigModel]] = [] # (new_serial, old_serial, switch_cfg) for switch_cfg, rma_cfg in rma_entries: new_serial = rma_cfg.serial_number bootstrap_data = bootstrap_idx.get(new_serial) @@ -1962,6 +2005,17 @@ def handle( log.error(msg) nd.module.fail_json(msg=msg) + SwitchDiffEngine.validate_switch_api_fields( + nd=nd, + serial=rma_cfg.serial_number, + model=rma_cfg.model, + version=rma_cfg.version, + config_data=rma_cfg.config_data, + bootstrap_data=bootstrap_data, + log=log, + context="RMA", + ) + rma_model = self._build_rma_model( switch_cfg, rma_cfg, bootstrap_data, old_switch_info[rma_cfg.old_serial], @@ -1973,14 +2027,53 @@ def handle( self._provision_rma_switch(rma_cfg.old_serial, rma_model) switch_actions.append((rma_model.new_switch_id, switch_cfg)) - - # Post-processing: wait, save credentials, finalize - self.fabric_ops.post_add_processing( - switch_actions, - wait_utils=self.wait_utils, - context="RMA", - skip_greenfield_check=True, + rma_diff_data.append((rma_model.new_switch_id, rma_cfg.old_serial, switch_cfg)) + + # Post-processing: wait for RMA switches to become ready, then + # save credentials and finalize. RMA switches come up via POAP + # bootstrap and never enter migration mode, so we use the + # RMA-specific wait (unreachable → ok) instead of the generic + # wait_for_switch_manageable which would time out on the + # migration-mode phase. + all_new_serials = [sn for sn, _ in switch_actions] + log.info( + f"Waiting for {len(all_new_serials)} RMA replacement " + f"switch(es) to become ready: {all_new_serials}" ) + success = self.wait_utils.wait_for_rma_switch_ready(all_new_serials) + if not success: + msg = ( + f"One or more RMA replacement switches failed to become " + f"discoverable in fabric '{self.ctx.fabric}'. " + f"Switches: {all_new_serials}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + if self.ctx.output: + diff_items = [ + _DiffRecord({ + "seed_ip": switch_cfg.seed_ip, + "old_serial_number": old_serial, + "new_serial_number": new_serial, + "hostname": old_switch_info[old_serial]["hostname"], + "role": switch_cfg.role, + }) + for new_serial, old_serial, switch_cfg in rma_diff_data + ] + self.ctx.output.assign(diff=SwitchOutputCollection(items=diff_items)) + + self.fabric_ops.bulk_save_credentials(switch_actions) + + try: + self.fabric_ops.finalize() + except Exception as e: + msg = ( + f"Failed to finalize (config-save/deploy) for RMA " + f"switches {all_new_serials}: {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) log.debug("EXIT: RMAHandler.handle()") @@ -2022,6 +2115,20 @@ def _validate_prerequisites( ) ) + # Verify the seed_ip in config matches the IP of the switch + # identified by old_serial in the fabric inventory. + seed_ip = switch_cfg.seed_ip + inventory_ip = old_switch.fabric_management_ip + if seed_ip != inventory_ip: + nd.module.fail_json( + msg=( + f"RMA: seed_ip '{seed_ip}' does not match the " + f"fabric management IP '{inventory_ip}' of switch " + f"with serial '{old_serial}'. Verify that seed_ip " + f"and old_serial refer to the same switch." + ) + ) + ad = old_switch.additional_data if ad is None: nd.module.fail_json( @@ -2036,7 +2143,7 @@ def _validate_prerequisites( nd.module.fail_json( msg=( f"RMA: Switch '{old_serial}' has discovery status " - f"'{ad.discovery_status.value if ad.discovery_status else 'unknown'}', " + f"'{getattr(ad.discovery_status, 'value', ad.discovery_status) if ad.discovery_status else 'unknown'}', " f"expected 'unreachable'. The old switch must be " f"unreachable before RMA can proceed." ) @@ -2046,7 +2153,7 @@ def _validate_prerequisites( nd.module.fail_json( msg=( f"RMA: Switch '{old_serial}' is in " - f"'{ad.system_mode.value if ad.system_mode else 'unknown'}' " + f"'{getattr(ad.system_mode, 'value', ad.system_mode) if ad.system_mode else 'unknown'}' " f"mode, expected 'maintenance'. Put the switch in " f"maintenance mode before initiating RMA." ) @@ -2093,10 +2200,7 @@ def _build_rma_model( new_switch_id = rma_cfg.serial_number hostname = old_switch_info.get("hostname", "") ip = switch_cfg.seed_ip - model_name = rma_cfg.model - version = rma_cfg.version image_policy = rma_cfg.image_policy - gateway_ip_mask = rma_cfg.config_data.gateway switch_role = switch_cfg.role password = switch_cfg.password auth_proto = SnmpV3AuthProtocol.MD5 # RMA always uses MD5 @@ -2109,6 +2213,20 @@ def _build_rma_model( finger_print = bootstrap_data.get( "fingerPrint", bootstrap_data.get("fingerprint", "") ) + bs_data = bootstrap_data.get("data") or {} + + # Use user-provided values when available; fall back to bootstrap API data. + model_name = rma_cfg.model or bootstrap_data.get("model", "") + version = rma_cfg.version or bootstrap_data.get("softwareVersion", "") + gateway_ip_mask = ( + (rma_cfg.config_data.gateway if rma_cfg.config_data else None) + or bootstrap_data.get("gatewayIpMask") + or bs_data.get("gatewayIpMask") + ) + data_models = ( + (rma_cfg.config_data.models if rma_cfg.config_data else None) + or bs_data.get("models", []) + ) rma_model = RMASwitchModel( gatewayIpMask=gateway_ip_mask, @@ -2125,6 +2243,7 @@ def _build_rma_model( newSwitchId=new_switch_id, publicKey=public_key, fingerPrint=finger_print, + data={"gatewayIpMask": gateway_ip_mask, "models": data_models} if (gateway_ip_mask or data_models) else None, ) log.debug( @@ -2154,7 +2273,7 @@ def _provision_rma_switch( endpoint = EpManageFabricSwitchProvisionRMAPost() endpoint.fabric_name = self.ctx.fabric - endpoint.switch_id = old_switch_id + endpoint.switch_sn = old_switch_id payload = rma_model.to_payload() @@ -2241,8 +2360,8 @@ def __init__( results=results, fabric=self.fabric, log=log, - save_config=self.module.params.get("save", True), - deploy_config=self.module.params.get("deploy", True), + save_config=self.module.params.get("save"), + deploy_config=self.module.params.get("deploy"), ) # Switch collections @@ -2268,6 +2387,7 @@ def __init__( # overridden to_ansible_config() methods. self.output = NDOutput(output_level=self.module.params.get("output_level", "normal")) self.output.assign(before=self.previous, after=self.existing) + self.ctx.output = self.output # Utility instances (SwitchWaitUtils / FabricUtils depend on self) self.fabric_utils = FabricUtils(self.nd, self.fabric, log) @@ -2340,8 +2460,8 @@ def manage_state(self) -> None: """ self.log.info(f"Managing state: {self.state}") - # query / deleted — config is optional - if self.state in ("query", "deleted"): + # deleted — config is optional + if self.state == "deleted": proposed_config = ( SwitchDiffEngine.validate_configs(self.config, self.state, self.nd, self.log) if self.config @@ -2353,9 +2473,7 @@ def manage_state(self) -> None: model_class=SwitchConfigModel, items=proposed_config ) ) - if self.state == "deleted": - return self._handle_deleted_state(proposed_config) - return self._handle_query_state(proposed_config) + return self._handle_deleted_state(proposed_config) # merged / overridden — config is required if not self.config: @@ -2403,75 +2521,6 @@ def manage_state(self) -> None: # State Handlers (orchestration only — delegate to services) # ===================================================================== - def _handle_query_state( - self, - proposed_config: Optional[List[SwitchConfigModel]] = None, - ) -> None: - """Return inventory switches matching the optional proposed config. - - Args: - proposed_config: Optional filter config list for matching switches. - - Returns: - None. - """ - self.log.debug("ENTER: _handle_query_state()") - self.log.info("Handling query state") - self.log.debug(f"Found {len(self.existing)} existing switches") - - if proposed_config is None: - matched_switches = list(self.existing) - self.log.info("No proposed config — returning all existing switches") - else: - matched_switches: List[SwitchDataModel] = [] - for cfg in proposed_config: - match = next( - ( - sw for sw in self.existing - if sw.fabric_management_ip == cfg.seed_ip - ), - None, - ) - if match is None: - self.log.info(f"Switch {cfg.seed_ip} not found in fabric") - continue - - if cfg.role is not None and match.switch_role != cfg.role: - self.log.info( - f"Switch {cfg.seed_ip} found but role mismatch: " - f"expected {cfg.role.value}, got " - f"{match.switch_role.value if match.switch_role else 'None'}" - ) - continue - - matched_switches.append(match) - - self.log.info( - f"Matched {len(matched_switches)}/{len(proposed_config)} " - f"switch(es) from proposed config" - ) - - switch_data = [sw.model_dump(by_alias=True) for sw in matched_switches] - - self.results.action = "query" - self.results.state = self.state - self.results.check_mode = self.nd.module.check_mode - self.results.operation_type = OperationType.QUERY - self.results.response_current = { - "RETURN_CODE": 200, - "MESSAGE": "OK", - "DATA": switch_data, - } - self.results.result_current = { - "found": len(matched_switches) > 0, - "success": True, - } - self.results.diff_current = {} - self.results.register_api_call() - - self.log.debug(f"Returning {len(switch_data)} switches in results") - self.log.debug("EXIT: _handle_query_state()") - def _handle_merged_state( self, diff: Dict[str, List[SwitchDataModel]], @@ -2501,19 +2550,16 @@ def _handle_merged_state( config_by_ip = {sw.seed_ip: sw for sw in proposed_config} existing_by_ip = {sw.fabric_management_ip: sw for sw in self.existing} - # Phase 1: Handle role-change switches - self._merged_handle_role_changes(diff, config_by_ip, existing_by_ip) - - # Phase 2: Handle idempotent switches that may need config sync - self._merged_handle_idempotent(diff, existing_by_ip) + # Phase 1: Handle idempotent switches that may need config sync + idempotent_save_req = self._merged_handle_idempotent(diff, existing_by_ip) - # Phase 3: Fail on to_update (merged state doesn't support updates) + # Phase 2: Fail on to_update (merged state doesn't support updates) self._merged_handle_to_update(diff) switches_to_add = diff.get("to_add", []) migration_switches = diff.get("migration_mode", []) - if not switches_to_add and not migration_switches: + if not switches_to_add and not migration_switches and not idempotent_save_req: self.log.info("No switches need adding or migration processing") return @@ -2596,6 +2642,11 @@ def _handle_merged_state( # Phase 5: Collect migration switches for post-processing # Migration mode switches get role updates during post-add processing. + + have_migration_switches = False + if migration_switches: + have_migration_switches = True + for mig_sw in migration_switches: cfg = config_by_ip.get(mig_sw.fabric_management_ip) if cfg and mig_sw.switch_id: @@ -2628,7 +2679,7 @@ def _handle_merged_state( wait_utils=self.wait_utils, context="merged", all_preserve_config=all_preserve_config, - update_roles=True, + update_roles=have_migration_switches, ) self.output.assign(diff=SwitchOutputCollection(items=diff_items)) @@ -2638,75 +2689,11 @@ def _handle_merged_state( # Merged-state sub-handlers (modular phases) # ----------------------------------------------------------------- - def _merged_handle_role_changes( - self, - diff: Dict[str, List[SwitchDataModel]], - config_by_ip: Dict[str, SwitchConfigModel], - existing_by_ip: Dict[str, SwitchDataModel], - ) -> None: - """Handle role-change switches in merged state. - - Role changes are only allowed when configSyncStatus is notApplicable. - Any other status fails the module. - - Args: - diff: Categorized switch diff output. - config_by_ip: Config lookup by seed IP. - existing_by_ip: Existing switch lookup by management IP. - - Returns: - None. - """ - role_change_switches = diff.get("role_change", []) - if not role_change_switches: - return - - # Validate configSyncStatus for every role-change switch - for sw in role_change_switches: - existing_sw = existing_by_ip.get(sw.fabric_management_ip) - status = ( - existing_sw.additional_data.config_sync_status - if existing_sw and existing_sw.additional_data - else None - ) - if status != ConfigSyncStatus.NOT_APPLICABLE: - self.nd.module.fail_json( - msg=( - f"Role change not possible for switch " - f"{sw.fabric_management_ip} ({sw.switch_id}). " - f"configSyncStatus is " - f"'{getattr(status, 'value', status) if status else 'unknown'}', " - f"expected '{ConfigSyncStatus.NOT_APPLICABLE.value}'." - ) - ) - - # Build (switch_id, SwitchConfigModel) pairs and apply role change - role_actions: List[Tuple[str, SwitchConfigModel]] = [] - role_diff_items: List = [] - for sw in role_change_switches: - cfg = config_by_ip.get(sw.fabric_management_ip) - if cfg and sw.switch_id: - role_actions.append((sw.switch_id, cfg)) - # Use existing SwitchDataModel for software_version + mode; - # override role with the desired value from the playbook. - record = sw.to_config_dict() - if cfg.role is not None: - record["role"] = cfg.role - role_diff_items.append(_DiffRecord(record)) - - if role_actions: - self.log.info( - f"Performing role change for {len(role_actions)} switch(es)" - ) - self.fabric_ops.bulk_update_roles(role_actions) - self.fabric_ops.finalize() - self.output.assign(diff=SwitchOutputCollection(items=role_diff_items)) - def _merged_handle_idempotent( self, diff: Dict[str, List[SwitchDataModel]], existing_by_ip: Dict[str, SwitchDataModel], - ) -> None: + ) -> bool: """Handle idempotent switches that may need config save and deploy. If configSyncStatus is anything other than inSync, run config save @@ -2717,13 +2704,12 @@ def _merged_handle_idempotent( existing_by_ip: Existing switch lookup by management IP. Returns: - None. + bool: True if any idempotent switches require config save and deploy, False otherwise. """ idempotent_switches = diff.get("idempotent", []) if not idempotent_switches: - return + return False - finalize_needed = False for sw in idempotent_switches: existing_sw = existing_by_ip.get(sw.fabric_management_ip) status = ( @@ -2738,15 +2724,9 @@ def _merged_handle_idempotent( f"'{getattr(status, 'value', status) if status else 'unknown'}' — " f"will run config save and deploy" ) - finalize_needed = True - else: - self.log.info( - f"Switch {sw.fabric_management_ip} ({sw.switch_id}) " - f"is idempotent — no changes needed" - ) + return True - if finalize_needed: - self.fabric_ops.finalize() + return False def _merged_handle_to_update( self, @@ -2799,10 +2779,6 @@ def _handle_overridden_state( self.log.warning("No configurations provided for overridden state") return - # Merge role_change into to_update — overridden uses delete-and-re-add - diff["to_update"].extend(diff.get("role_change", [])) - diff["role_change"] = [] - # Check mode — preview only if self.nd.module.check_mode: n_delete = len(diff.get("to_delete", [])) diff --git a/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py b/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py index bb81a673..dda4c712 100644 --- a/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py +++ b/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py @@ -36,7 +36,7 @@ class SwitchWaitUtils: # Default wait parameters DEFAULT_MAX_ATTEMPTS: int = 300 - DEFAULT_WAIT_INTERVAL: int = 5 # seconds + DEFAULT_WAIT_INTERVAL: int = 10 # seconds # Status values indicating the switch is ready MANAGEABLE_STATUSES = frozenset({"ok", "manageable"}) @@ -178,6 +178,39 @@ def wait_for_switch_manageable( serial_numbers, "ok" ) + def wait_for_rma_switch_ready( + self, + serial_numbers: List[str], + ) -> bool: + """Wait for RMA replacement switches to become manageable. + + RMA replacement switches come up via POAP bootstrap and never enter + migration mode. Three phases are run in order: + + 1. Wait for each new serial to appear in the fabric inventory. + The controller registers the switch after ``provisionRMA`` + completes, but it may take a few polling cycles. + 2. Wait for discovery status ``ok``. + + Args: + serial_numbers: New (replacement) switch serial numbers to monitor. + + Returns: + ``True`` if all switches reach ``ok`` status, ``False`` on timeout. + """ + self.log.info( + f"Waiting for RMA replacement switch(es) to become ready " + f"(skipping migration-mode phase): {serial_numbers}" + ) + + # Phase 1: wait until all new serials appear in the fabric inventory. + # Rediscovery triggers will 400 until the switch is registered. + if not self._wait_for_switches_in_fabric(serial_numbers): + return False + + # Phase 2: wait for ok discovery status. + return self._wait_for_discovery_state(serial_numbers, "ok") + def wait_for_discovery( self, seed_ip: str, @@ -472,6 +505,64 @@ def _wait_for_discovery_state( # API Helpers # ===================================================================== + def _wait_for_switches_in_fabric( + self, + serial_numbers: List[str], + ) -> bool: + """Poll until all serial numbers appear in the fabric inventory. + + After ``provisionRMA`` the controller registers the new switch + asynchronously. Rediscovery requests will fail with 400 + "Switch not found" until the switch is registered, so we must + wait for it to appear before triggering any rediscovery. + + Args: + serial_numbers: Switch serial numbers to wait for. + + Returns: + ``True`` when all serials are present, ``False`` on timeout. + """ + pending = list(serial_numbers) + self.log.info( + f"Waiting for {len(pending)} switch(es) to appear in " + f"fabric inventory: {pending}" + ) + + for attempt in range(1, self.max_attempts + 1): + if not pending: + return True + + switch_data = self._fetch_switch_data() + if switch_data is None: + # API error — keep waiting + time.sleep(self.wait_interval) + continue + + known_serials = { + sw.get("serialNumber") for sw in switch_data + } + pending = [ + sn for sn in pending if sn not in known_serials + ] + + if not pending: + self.log.info( + f"All RMA switch(es) now visible in fabric inventory " + f"(attempt {attempt})" + ) + return True + + self.log.debug( + f"Attempt {attempt}/{self.max_attempts}: " + f"{len(pending)} switch(es) not yet in fabric: {pending}" + ) + time.sleep(self.wait_interval) + + self.log.warning( + f"Timeout waiting for switches to appear in fabric: {pending}" + ) + return False + def _fetch_switch_data( self, ) -> Optional[List[Dict[str, Any]]]: From 3bfc072605f57fc961b933f0c6e3aa07def07efd Mon Sep 17 00:00:00 2001 From: AKDRG Date: Wed, 18 Mar 2026 16:00:37 +0530 Subject: [PATCH 14/27] Fix Module and Models Parameters, Imports, Docstrings. Add Idempotence Handling for POAP, Preprovision Skip Discovery for Existing Switches --- .../module_utils/endpoints/query_params.py | 10 +- .../manage/nd_manage_switches/credentials.py | 2 +- .../nd_manage_switches/fabric_bootstrap.py | 2 +- .../nd_manage_switches/fabric_config.py | 8 +- .../nd_manage_switches/fabric_discovery.py | 2 +- .../fabric_switch_actions.py | 14 +- .../nd_manage_switches/fabric_switches.py | 4 +- .../models/nd_manage_switches/__init__.py | 141 ------------------ .../nd_manage_switches/bootstrap_models.py | 34 ++--- .../nd_manage_switches/config_models.py | 7 +- .../nd_manage_switches/discovery_models.py | 19 +-- .../models/nd_manage_switches/enums.py | 42 +++++- .../nd_manage_switches/preprovision_models.py | 8 +- .../models/nd_manage_switches/rma_models.py | 8 +- .../switch_actions_models.py | 6 +- .../nd_manage_switches/switch_data_models.py | 6 +- .../models/nd_manage_switches/validators.py | 2 +- plugins/module_utils/nd_switch_resources.py | 62 +++++++- plugins/modules/nd_manage_switches.py | 54 ++----- 19 files changed, 174 insertions(+), 257 deletions(-) delete mode 100644 plugins/module_utils/models/nd_manage_switches/__init__.py diff --git a/plugins/module_utils/endpoints/query_params.py b/plugins/module_utils/endpoints/query_params.py index 5bf8ff08..0d2c112e 100644 --- a/plugins/module_utils/endpoints/query_params.py +++ b/plugins/module_utils/endpoints/query_params.py @@ -211,8 +211,14 @@ def to_query_string(self, url_encode: bool = True) -> str: params = [] for field_name, field_value in self.model_dump(exclude_none=True).items(): if field_value is not None: - # URL-encode the value if requested - encoded_value = quote(str(field_value), safe="") if url_encode else str(field_value) + # URL-encode the value if requested. + # Lucene filter expressions require ':' and ' ' to remain unencoded + # so the server-side parser can recognise the field:value syntax. + if url_encode: + safe_chars = ": " if field_name == "filter" else "" + encoded_value = quote(str(field_value), safe=safe_chars) + else: + encoded_value = str(field_value) params.append(f"{field_name}={encoded_value}") return "&".join(params) diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py index 9ca94d09..ae40a17a 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py @@ -118,7 +118,7 @@ class EpManageCredentialsSwitchesPost(_EpManageCredentialsSwitchesBase): """ class_name: Literal["EpManageCredentialsSwitchesPost"] = Field( - default="EpManageCredentialsSwitchesPost", description="Class name for backward compatibility" + default="EpManageCredentialsSwitchesPost", frozen=True, description="Class name for backward compatibility" ) endpoint_params: CredentialsSwitchesEndpointParams = Field( default_factory=CredentialsSwitchesEndpointParams, description="Endpoint-specific query parameters" diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py index 25432637..5bef2ff5 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py @@ -127,7 +127,7 @@ class EpManageFabricBootstrapGet(_EpManageFabricBootstrapBase): """ class_name: Literal["EpManageFabricBootstrapGet"] = Field( - default="EpManageFabricBootstrapGet", description="Class name for backward compatibility" + default="EpManageFabricBootstrapGet", frozen=True, description="Class name for backward compatibility" ) endpoint_params: FabricBootstrapEndpointParams = Field( default_factory=FabricBootstrapEndpointParams, description="Endpoint-specific query parameters" diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py index 5ab75028..b8a5d906 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py @@ -112,7 +112,7 @@ class EpManageFabricConfigSavePost(_EpManageFabricConfigBase): """ class_name: Literal["EpManageFabricConfigSavePost"] = Field( - default="EpManageFabricConfigSavePost", description="Class name for backward compatibility" + default="EpManageFabricConfigSavePost", frozen=True, description="Class name for backward compatibility" ) @property @@ -170,7 +170,7 @@ class EpManageFabricConfigDeployPost(_EpManageFabricConfigBase): """ class_name: Literal["EpManageFabricConfigDeployPost"] = Field( - default="EpManageFabricConfigDeployPost", description="Class name for backward compatibility" + default="EpManageFabricConfigDeployPost", frozen=True, description="Class name for backward compatibility" ) endpoint_params: FabricConfigDeployEndpointParams = Field( default_factory=FabricConfigDeployEndpointParams, description="Endpoint-specific query parameters" @@ -228,7 +228,7 @@ class EpManageFabricGet(_EpManageFabricConfigBase): """ class_name: Literal["EpManageFabricGet"] = Field( - default="EpManageFabricGet", description="Class name for backward compatibility" + default="EpManageFabricGet", frozen=True, description="Class name for backward compatibility" ) @property @@ -271,7 +271,7 @@ class EpManageFabricInventoryDiscoverGet(_EpManageFabricConfigBase): """ class_name: Literal["EpManageFabricInventoryDiscoverGet"] = Field( - default="EpManageFabricInventoryDiscoverGet", description="Class name for backward compatibility" + default="EpManageFabricInventoryDiscoverGet", frozen=True, description="Class name for backward compatibility" ) @property diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py index e2416f98..8bfc45d9 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py @@ -82,7 +82,7 @@ class EpManageFabricShallowDiscoveryPost(_EpManageFabricDiscoveryBase): """ class_name: Literal["EpManageFabricShallowDiscoveryPost"] = Field( - default="EpManageFabricShallowDiscoveryPost", description="Class name for backward compatibility" + default="EpManageFabricShallowDiscoveryPost", frozen=True, description="Class name for backward compatibility" ) @property diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py index 40ea5808..5a455091 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py @@ -183,7 +183,7 @@ class EpManageFabricSwitchActionsRemovePost(_EpManageFabricSwitchActionsBase): """ class_name: Literal["EpManageFabricSwitchActionsRemovePost"] = Field( - default="EpManageFabricSwitchActionsRemovePost", description="Class name for backward compatibility" + default="EpManageFabricSwitchActionsRemovePost", frozen=True, description="Class name for backward compatibility" ) endpoint_params: SwitchActionsRemoveEndpointParams = Field( default_factory=SwitchActionsRemoveEndpointParams, description="Endpoint-specific query parameters" @@ -255,7 +255,7 @@ class EpManageFabricSwitchActionsChangeRolesPost(_EpManageFabricSwitchActionsBas """ class_name: Literal["EpManageFabricSwitchActionsChangeRolesPost"] = Field( - default="EpManageFabricSwitchActionsChangeRolesPost", + default="EpManageFabricSwitchActionsChangeRolesPost", frozen=True, description="Class name for backward compatibility", ) endpoint_params: SwitchActionsTicketEndpointParams = Field( @@ -330,7 +330,7 @@ class EpManageFabricSwitchActionsImportBootstrapPost(_EpManageFabricSwitchAction """ class_name: Literal["EpManageFabricSwitchActionsImportBootstrapPost"] = Field( - default="EpManageFabricSwitchActionsImportBootstrapPost", description="Class name for backward compatibility" + default="EpManageFabricSwitchActionsImportBootstrapPost", frozen=True, description="Class name for backward compatibility" ) endpoint_params: SwitchActionsImportEndpointParams = Field( default_factory=SwitchActionsImportEndpointParams, description="Endpoint-specific query parameters" @@ -412,7 +412,7 @@ class EpManageFabricSwitchActionsPreProvisionPost(_EpManageFabricSwitchActionsBa """ class_name: Literal["EpManageFabricSwitchActionsPreProvisionPost"] = Field( - default="EpManageFabricSwitchActionsPreProvisionPost", + default="EpManageFabricSwitchActionsPreProvisionPost", frozen=True, description="Class name for backward compatibility", ) endpoint_params: SwitchActionsImportEndpointParams = Field( @@ -510,7 +510,7 @@ class EpManageFabricSwitchProvisionRMAPost(_EpManageFabricSwitchActionsPerSwitch """ class_name: Literal["EpManageFabricSwitchProvisionRMAPost"] = Field( - default="EpManageFabricSwitchProvisionRMAPost", description="Class name for backward compatibility" + default="EpManageFabricSwitchProvisionRMAPost", frozen=True, description="Class name for backward compatibility" ) endpoint_params: SwitchActionsTicketEndpointParams = Field( default_factory=SwitchActionsTicketEndpointParams, description="Endpoint-specific query parameters" @@ -609,7 +609,7 @@ class EpManageFabricSwitchChangeSerialNumberPost(_EpManageFabricSwitchActionsPer """ class_name: Literal["EpManageFabricSwitchChangeSerialNumberPost"] = Field( - default="EpManageFabricSwitchChangeSerialNumberPost", description="Class name for backward compatibility" + default="EpManageFabricSwitchChangeSerialNumberPost", frozen=True, description="Class name for backward compatibility" ) endpoint_params: SwitchActionsClusterEndpointParams = Field( default_factory=SwitchActionsClusterEndpointParams, description="Endpoint-specific query parameters" @@ -686,7 +686,7 @@ class EpManageFabricSwitchActionsRediscoverPost(_EpManageFabricSwitchActionsBase """ class_name: Literal["EpManageFabricSwitchActionsRediscoverPost"] = Field( - default="EpManageFabricSwitchActionsRediscoverPost", + default="EpManageFabricSwitchActionsRediscoverPost", frozen=True, description="Class name for backward compatibility", ) endpoint_params: SwitchActionsTicketEndpointParams = Field( diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py index 2334e98c..a1498cb6 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py +++ b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py @@ -155,7 +155,7 @@ class EpManageFabricSwitchesGet(_EpManageFabricSwitchesBase): """ class_name: Literal["EpManageFabricSwitchesGet"] = Field( - default="EpManageFabricSwitchesGet", description="Class name for backward compatibility" + default="EpManageFabricSwitchesGet", frozen=True, description="Class name for backward compatibility" ) endpoint_params: FabricSwitchesGetEndpointParams = Field( default_factory=FabricSwitchesGetEndpointParams, description="Endpoint-specific query parameters" @@ -228,7 +228,7 @@ class EpManageFabricSwitchesPost(_EpManageFabricSwitchesBase): """ class_name: Literal["EpManageFabricSwitchesPost"] = Field( - default="EpManageFabricSwitchesPost", description="Class name for backward compatibility" + default="EpManageFabricSwitchesPost", frozen=True, description="Class name for backward compatibility" ) endpoint_params: FabricSwitchesAddEndpointParams = Field( default_factory=FabricSwitchesAddEndpointParams, description="Endpoint-specific query parameters" diff --git a/plugins/module_utils/models/nd_manage_switches/__init__.py b/plugins/module_utils/models/nd_manage_switches/__init__.py deleted file mode 100644 index 17415a32..00000000 --- a/plugins/module_utils/models/nd_manage_switches/__init__.py +++ /dev/null @@ -1,141 +0,0 @@ -# -*- 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_switches models package. - -Re-exports all model classes, enums, and validators from their individual -modules so that consumers can import directly from the package: - - from .models.nd_manage_switches import SwitchConfigModel, SwitchRole, ... -""" - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -# --- Enums --- -from .enums import ( # noqa: F401 - AdvisoryLevel, - AnomalyLevel, - ConfigSyncStatus, - DiscoveryStatus, - PlatformType, - RemoteCredentialStore, - SnmpV3AuthProtocol, - SwitchRole, - SystemMode, - VpcRole, -) - -# --- Validators --- -from .validators import SwitchValidators # noqa: F401 - -# --- Nested / shared models --- -from .switch_data_models import ( # noqa: F401 - AdditionalAciSwitchData, - AdditionalSwitchData, - Metadata, - SwitchMetadata, - TelemetryIpCollection, - VpcData, -) - -# --- Discovery models --- -from .discovery_models import ( # noqa: F401 - AddSwitchesRequestModel, - ShallowDiscoveryRequestModel, - SwitchDiscoveryModel, -) - -# --- Switch data models --- -from .switch_data_models import ( # noqa: F401 - SwitchDataModel, -) - -# --- Bootstrap models --- -from .bootstrap_models import ( # noqa: F401 - BootstrapBaseData, - BootstrapBaseModel, - BootstrapCredentialModel, - BootstrapImportSpecificModel, - BootstrapImportSwitchModel, - ImportBootstrapSwitchesRequestModel, -) - -# --- Preprovision models --- -from .preprovision_models import ( # noqa: F401 - PreProvisionSwitchesRequestModel, - PreProvisionSwitchModel, -) - -# --- RMA models --- -from .rma_models import ( # noqa: F401 - RMASwitchModel, -) - -# --- Switch actions models --- -from .switch_actions_models import ( # noqa: F401 - ChangeSwitchSerialNumberRequestModel, - SwitchCredentialsRequestModel, -) - -# --- Config / playbook models --- -from .config_models import ( # noqa: F401 - ConfigDataModel, - POAPConfigModel, - RMAConfigModel, - SwitchConfigModel, -) - - -__all__ = [ - # Enums - "AdvisoryLevel", - "AnomalyLevel", - "ConfigSyncStatus", - "DiscoveryStatus", - "PlatformType", - "RemoteCredentialStore", - "SnmpV3AuthProtocol", - "SwitchRole", - "SystemMode", - "VpcRole", - # Validators - "SwitchValidators", - # Nested models - "AdditionalAciSwitchData", - "AdditionalSwitchData", - "Metadata", - "SwitchMetadata", - "TelemetryIpCollection", - "VpcData", - # Discovery models - "AddSwitchesRequestModel", - "ShallowDiscoveryRequestModel", - "SwitchDiscoveryModel", - # Switch data models - "SwitchDataModel", - # Bootstrap models - "BootstrapBaseData", - "BootstrapBaseModel", - "BootstrapCredentialModel", - "BootstrapImportSpecificModel", - "BootstrapImportSwitchModel", - "ImportBootstrapSwitchesRequestModel", - # Preprovision models - "PreProvisionSwitchesRequestModel", - "PreProvisionSwitchModel", - # RMA models - "RMASwitchModel", - # Switch actions models - "ChangeSwitchSerialNumberRequestModel", - "SwitchCredentialsRequestModel", - # Config models - "ConfigDataModel", - "POAPConfigModel", - "RMAConfigModel", - "SwitchConfigModel", -] diff --git a/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py b/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py index 9825c0c3..62dbcfd4 100644 --- a/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py +++ b/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat C S (@achengam) +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """Bootstrap (POAP) switch models for import operations. -Based on OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +Based on OpenAPI schema for Nexus Dashboard Manage APIs v1.1.332. """ from __future__ import absolute_import, division, print_function @@ -20,12 +20,12 @@ 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 .enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.enums import ( RemoteCredentialStore, SnmpV3AuthProtocol, SwitchRole, ) -from .validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators class BootstrapBaseData(NDNestedModel): @@ -243,17 +243,17 @@ class BootstrapImportSwitchModel(NDBaseModel): ..., description="Model of the bootstrap switch" ) - version: str = Field( + software_version: str = Field( ..., + alias="softwareVersion", description="Software version of the bootstrap switch" ) hostname: str = Field( ..., description="Hostname of the bootstrap switch" ) - ip_address: str = Field( + ip: str = Field( ..., - alias="ipAddress", description="IP address of the bootstrap switch" ) password: str = Field( @@ -288,6 +288,7 @@ class BootstrapImportSwitchModel(NDBaseModel): ) fingerprint: str = Field( default="", + alias="fingerPrint", description="SSH fingerprint from bootstrap GET API" ) public_key: str = Field( @@ -298,7 +299,7 @@ class BootstrapImportSwitchModel(NDBaseModel): re_add: bool = Field( default=False, alias="reAdd", - description="Re-add flag from bootstrap GET API" + description="Whether to re-add an already-seen switch" ) in_inventory: bool = Field( default=False, @@ -313,24 +314,15 @@ class BootstrapImportSwitchModel(NDBaseModel): default=None, alias="switchRole" ) - ip: Optional[str] = Field( - default=None, - description="IP address (duplicate of ipAddress for API compatibility)" - ) - software_version: Optional[str] = Field( - default=None, - alias="softwareVersion", - description="Software version (duplicate of version for API compatibility)" - ) - gateway_ip_mask: Optional[str] = Field( - default=None, + gateway_ip_mask: str = Field( + ..., alias="gatewayIpMask", description="Gateway IP address with mask" ) - @field_validator('ip_address', mode='before') + @field_validator('ip', mode='before') @classmethod - def validate_ip_address(cls, v: str) -> str: + def validate_ip_field(cls, v: str) -> str: result = SwitchValidators.validate_ip_address(v) if result is None: raise ValueError(f"Invalid IP address: {v}") diff --git a/plugins/module_utils/models/nd_manage_switches/config_models.py b/plugins/module_utils/models/nd_manage_switches/config_models.py index 4c22acec..40ab587b 100644 --- a/plugins/module_utils/models/nd_manage_switches/config_models.py +++ b/plugins/module_utils/models/nd_manage_switches/config_models.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat C S (@achengam) +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -9,7 +9,6 @@ These models represent the user-facing configuration schema used in Ansible playbooks for normal switch addition, POAP, and RMA operations. -Based on: dcnm_inventory.py config suboptions """ from __future__ import absolute_import, division, print_function @@ -25,12 +24,12 @@ 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 .enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.enums import ( PlatformType, SnmpV3AuthProtocol, SwitchRole, ) -from .validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators class ConfigDataModel(NDNestedModel): diff --git a/plugins/module_utils/models/nd_manage_switches/discovery_models.py b/plugins/module_utils/models/nd_manage_switches/discovery_models.py index 4e6fb667..df79aaf8 100644 --- a/plugins/module_utils/models/nd_manage_switches/discovery_models.py +++ b/plugins/module_utils/models/nd_manage_switches/discovery_models.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat C S (@achengam) +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """Switch discovery models for shallow discovery and fabric add operations. -Based on OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +Based on OpenAPI schema for Nexus Dashboard Manage APIs v1.1.332. """ from __future__ import absolute_import, division, print_function @@ -19,13 +19,14 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from .enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.enums import ( PlatformType, RemoteCredentialStore, + ShallowDiscoveryPlatformType, SnmpV3AuthProtocol, SwitchRole, ) -from .validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators class ShallowDiscoveryRequestModel(NDBaseModel): @@ -50,10 +51,10 @@ class ShallowDiscoveryRequestModel(NDBaseModel): le=7, description="Max hop" ) - platform_type: PlatformType = Field( - default=PlatformType.NX_OS, + platform_type: ShallowDiscoveryPlatformType = Field( + default=ShallowDiscoveryPlatformType.NX_OS, alias="platformType", - description="Switch platform type" + description="Switch platform type (apic is not supported for shallow discovery)" ) snmp_v3_auth_protocol: SnmpV3AuthProtocol = Field( default=SnmpV3AuthProtocol.MD5, @@ -102,9 +103,9 @@ def normalize_snmp_auth(cls, v: Union[str, SnmpV3AuthProtocol, None]) -> SnmpV3A @field_validator('platform_type', mode='before') @classmethod - def normalize_platform(cls, v: Union[str, PlatformType, None]) -> PlatformType: + def normalize_platform(cls, v: Union[str, ShallowDiscoveryPlatformType, None]) -> ShallowDiscoveryPlatformType: """Normalize platform type (case-insensitive).""" - return PlatformType.normalize(v) + return ShallowDiscoveryPlatformType.normalize(v) class SwitchDiscoveryModel(NDBaseModel): diff --git a/plugins/module_utils/models/nd_manage_switches/enums.py b/plugins/module_utils/models/nd_manage_switches/enums.py index 93f93083..b88216ad 100644 --- a/plugins/module_utils/models/nd_manage_switches/enums.py +++ b/plugins/module_utils/models/nd_manage_switches/enums.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat C S (@achengam) +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -120,7 +120,9 @@ class PlatformType(str, Enum): """ Switch platform type enumeration. - Based on: components/schemas (multiple references) + 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" @@ -150,6 +152,41 @@ def normalize(cls, value: Union[str, "PlatformType", None]) -> "PlatformType": return pt raise ValueError(f"Invalid PlatformType: {value}. Valid: {cls.choices()}") +class ShallowDiscoveryPlatformType(str, Enum): + """ + Platform type for shallow discovery. + + Used for POST /fabrics/{fabricName}/actions/shallowDiscovery only. + Excludes 'apic' which is not supported by the shallowDiscovery endpoint. + Based on: components/schemas/shallowDiscoveryRequest.platformType + """ + NX_OS = "nx-os" + OTHER = "other" + IOS_XE = "ios-xe" + IOS_XR = "ios-xr" + SONIC = "sonic" + + @classmethod + def choices(cls) -> List[str]: + return [e.value for e in cls] + + @classmethod + def normalize(cls, value: Union[str, "ShallowDiscoveryPlatformType", None]) -> "ShallowDiscoveryPlatformType": + """ + Normalize input to enum value (case-insensitive). + Accepts: NX_OS, nx-os, NX-OS, ios_xe, ios-xe, etc. + """ + if value is None: + return cls.NX_OS + if isinstance(value, cls): + return value + if isinstance(value, str): + v_normalized = value.lower().replace('_', '-') + for pt in cls: + if pt.value == v_normalized: + return pt + raise ValueError(f"Invalid ShallowDiscoveryPlatformType: {value}. Valid: {cls.choices()}") + class SnmpV3AuthProtocol(str, Enum): """ @@ -310,6 +347,7 @@ def choices(cls) -> List[str]: "SwitchRole", "SystemMode", "PlatformType", + "ShallowDiscoveryPlatformType", "SnmpV3AuthProtocol", "DiscoveryStatus", "ConfigSyncStatus", diff --git a/plugins/module_utils/models/nd_manage_switches/preprovision_models.py b/plugins/module_utils/models/nd_manage_switches/preprovision_models.py index 9e34910a..d42daa2d 100644 --- a/plugins/module_utils/models/nd_manage_switches/preprovision_models.py +++ b/plugins/module_utils/models/nd_manage_switches/preprovision_models.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat C S (@achengam) +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """Pre-provision switch models. -Based on OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +Based on OpenAPI schema for Nexus Dashboard Manage APIs v1.1.332. """ from __future__ import absolute_import, division, print_function @@ -20,12 +20,12 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from .enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.enums import ( RemoteCredentialStore, SnmpV3AuthProtocol, SwitchRole, ) -from .validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators class PreProvisionSwitchModel(NDBaseModel): diff --git a/plugins/module_utils/models/nd_manage_switches/rma_models.py b/plugins/module_utils/models/nd_manage_switches/rma_models.py index 12f6c891..f1f692fa 100644 --- a/plugins/module_utils/models/nd_manage_switches/rma_models.py +++ b/plugins/module_utils/models/nd_manage_switches/rma_models.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat C S (@achengam) +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """RMA (Return Material Authorization) switch models. -Based on OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +Based on OpenAPI schema for Nexus Dashboard Manage APIs v1.1.332. """ from __future__ import absolute_import, division, print_function @@ -19,12 +19,12 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from .enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.enums import ( RemoteCredentialStore, SnmpV3AuthProtocol, SwitchRole, ) -from .validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators class RMASwitchModel(NDBaseModel): """ diff --git a/plugins/module_utils/models/nd_manage_switches/switch_actions_models.py b/plugins/module_utils/models/nd_manage_switches/switch_actions_models.py index 76b207da..18113445 100644 --- a/plugins/module_utils/models/nd_manage_switches/switch_actions_models.py +++ b/plugins/module_utils/models/nd_manage_switches/switch_actions_models.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat C S (@achengam) +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) """Switch action models (serial number change, IDs list, credentials). -Based on OpenAPI schema (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +Based on OpenAPI schema for Nexus Dashboard Manage APIs v1.1.332. """ from __future__ import absolute_import, division, print_function @@ -19,7 +19,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from .validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators class SwitchCredentialsRequestModel(NDBaseModel): diff --git a/plugins/module_utils/models/nd_manage_switches/switch_data_models.py b/plugins/module_utils/models/nd_manage_switches/switch_data_models.py index ccfb571f..484815d8 100644 --- a/plugins/module_utils/models/nd_manage_switches/switch_data_models.py +++ b/plugins/module_utils/models/nd_manage_switches/switch_data_models.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat C S (@achengam) +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@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 (manage.json) for Nexus Dashboard Manage APIs v1.1.332. +Based on OpenAPI schema for Nexus Dashboard Manage APIs v1.1.332. """ from __future__ import absolute_import, division, print_function @@ -20,7 +20,7 @@ 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 .enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.enums import ( AdvisoryLevel, AnomalyLevel, ConfigSyncStatus, diff --git a/plugins/module_utils/models/nd_manage_switches/validators.py b/plugins/module_utils/models/nd_manage_switches/validators.py index b2e3a704..e3ceb3a6 100644 --- a/plugins/module_utils/models/nd_manage_switches/validators.py +++ b/plugins/module_utils/models/nd_manage_switches/validators.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat C S (@achengam) +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index e0fa6ef0..59d4e9ca 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat C S (@achengam) +# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -721,9 +721,9 @@ def build_proposed( ) if existing_match: proposed.append(existing_match) - log.warning( - f"Switch {seed_ip} not discovered but found in existing " - f"inventory — using existing record for comparison" + log.debug( + f"Switch {seed_ip} already in fabric inventory — " + f"using existing record (discovery skipped)" ) continue @@ -1260,6 +1260,43 @@ def handle( f"{len(swap_entries)} swap" ) + # Idempotency: skip entries whose target serial is already in the fabric. + # Build lookup structures for idempotency checks. + # Bootstrap: idempotent when both IP address AND serial number match. + # PreProvision: idempotent when IP address alone matches. + existing_by_ip = { + sw.fabric_management_ip: sw + for sw in existing + if sw.fabric_management_ip + } + + active_bootstrap = [] + for switch_cfg, poap_cfg in bootstrap_entries: + existing_sw = existing_by_ip.get(switch_cfg.seed_ip) + if existing_sw and poap_cfg.serial_number in ( + existing_sw.serial_number, + existing_sw.switch_id, + ): + log.info( + f"Bootstrap: IP '{switch_cfg.seed_ip}' with serial " + f"'{poap_cfg.serial_number}' already in fabric " + f"— idempotent, skipping" + ) + else: + active_bootstrap.append((switch_cfg, poap_cfg)) + bootstrap_entries = active_bootstrap + + active_preprov = [] + for switch_cfg, poap_cfg in preprov_entries: + if switch_cfg.seed_ip in existing_by_ip: + log.info( + f"PreProvision: IP '{switch_cfg.seed_ip}' already in fabric " + f"— idempotent, skipping" + ) + else: + active_preprov.append((switch_cfg, poap_cfg)) + preprov_entries = active_preprov + # Handle swap entries (change serial number on pre-provisioned switches) if swap_entries: self._handle_poap_swap(swap_entries, existing or []) @@ -1476,9 +1513,8 @@ def _build_bootstrap_import_model( bootstrap_model = BootstrapImportSwitchModel( serialNumber=serial_number, model=model, - version=version, hostname=hostname, - ipAddress=ip, + ip=ip, password=password, discoveryAuthProtocol=auth_proto, discoveryUsername=discovery_username, @@ -1490,7 +1526,6 @@ def _build_bootstrap_import_model( inInventory=in_inventory, imagePolicy=image_policy or "", switchRole=switch_role, - ip=ip, softwareVersion=version, gatewayIpMask=gateway_ip_mask, ) @@ -2499,7 +2534,18 @@ def manage_state(self) -> None: return self.rma_handler.handle(proposed_config, list(self.existing)) # Normal: discover → build proposed models → compute diff → delegate - discovered_data = self.discovery.discover(proposed_config) + # Skip discovery for switches already in the fabric. + existing_ips = {sw.fabric_management_ip for sw in self.existing} + configs_to_discover = [cfg for cfg in proposed_config if cfg.seed_ip not in existing_ips] + if configs_to_discover: + self.log.info( + f"Discovery needed for {len(configs_to_discover)}/{len(proposed_config)} " + f"switch(es) — {len(proposed_config) - len(configs_to_discover)} already in fabric" + ) + discovered_data = self.discovery.discover(configs_to_discover) + else: + self.log.info("All proposed switches already in fabric — skipping discovery") + discovered_data = {} built = self.discovery.build_proposed( proposed_config, discovered_data, list(self.existing) ) diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index f3d14ca6..6b86a9b7 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -196,12 +196,10 @@ description: - Model of switch to Bootstrap for RMA. type: str - required: true version: description: - Software version of switch to Bootstrap for RMA. type: str - required: true image_policy: description: - Name of the image policy to be applied on switch during Bootstrap for RMA. @@ -213,7 +211,6 @@ - C(models) is list of model of modules in switch to Bootstrap for RMA. - C(gateway) is the gateway IP with mask for the switch to Bootstrap for RMA. type: dict - required: true suboptions: models: description: @@ -229,48 +226,27 @@ - Serial number of new replacement switch. type: str required: true - model: - description: - - Model of new switch. - type: str - required: true - version: - description: - - Software version of new switch. - type: str - required: true - hostname: - description: - - Hostname for the replacement switch. - type: str - required: true - image_policy: - description: - - Image policy to apply. - type: str - required: true - ip: - description: - - IP address of the replacement switch. - type: str - required: true - gateway_ip: - description: - - Gateway IP with subnet mask. - type: str - required: true - discovery_password: - description: - - Password for device discovery during RMA. - type: str - required: true + extends_documentation_fragment: - cisco.nd.modules - cisco.nd.check_mode notes: -- This module requires NDFC 12.x or higher. +- This module requires ND 12.x or higher. - POAP operations require POAP and DHCP to be enabled in fabric settings. - RMA operations require the old switch to be in a replaceable state. +- Idempotence for B(Bootstrap) - A bootstrap entry is considered idempotent when + the C(seed_ip) already exists in the fabric inventory B(and) the C(serial_number) + in the POAP config matches the serial number recorded for that IP in inventory. + Both conditions must be true; a matching IP with a different serial is not + treated as idempotent and will attempt the bootstrap again. +- Idempotence for B(Pre-provision) - A pre-provision entry is considered idempotent + when the C(seed_ip) already exists in the fabric inventory, regardless of the + C(preprovision_serial) value. Because the pre-provision serial is a placeholder + that may differ from the real hardware serial, only the IP address is used as + the stable identity for idempotency checks. +- Idempotence for B(normal discovery) - A switch is considered idempotent when + its C(seed_ip) already exists in the fabric inventory with no configuration + drift (same role). """ EXAMPLES = """ From 1569e417f089608bc54b8facc1f099628116784c Mon Sep 17 00:00:00 2001 From: AKDRG Date: Thu, 19 Mar 2026 12:07:59 +0530 Subject: [PATCH 15/27] Change folder structure for models, remove query handling and allow RMA, POAP, Normal Discovery handling in same task. --- .../bootstrap_models.py | 4 +- .../config_models.py | 117 ++---------------- .../discovery_models.py | 4 +- .../enums.py | 0 .../preprovision_models.py | 4 +- .../rma_models.py | 4 +- .../switch_actions_models.py | 2 +- .../switch_data_models.py | 2 +- .../validators.py | 0 plugins/module_utils/nd_switch_resources.py | 106 ++++++++-------- plugins/modules/nd_manage_switches.py | 16 +-- 11 files changed, 77 insertions(+), 182 deletions(-) rename plugins/module_utils/models/{nd_manage_switches => manage_switches}/bootstrap_models.py (98%) rename plugins/module_utils/models/{nd_manage_switches => manage_switches}/config_models.py (84%) rename plugins/module_utils/models/{nd_manage_switches => manage_switches}/discovery_models.py (97%) rename plugins/module_utils/models/{nd_manage_switches => manage_switches}/enums.py (100%) rename plugins/module_utils/models/{nd_manage_switches => manage_switches}/preprovision_models.py (97%) rename plugins/module_utils/models/{nd_manage_switches => manage_switches}/rma_models.py (96%) rename plugins/module_utils/models/{nd_manage_switches => manage_switches}/switch_actions_models.py (97%) rename plugins/module_utils/models/{nd_manage_switches => manage_switches}/switch_data_models.py (99%) rename plugins/module_utils/models/{nd_manage_switches => manage_switches}/validators.py (100%) diff --git a/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py b/plugins/module_utils/models/manage_switches/bootstrap_models.py similarity index 98% rename from plugins/module_utils/models/nd_manage_switches/bootstrap_models.py rename to plugins/module_utils/models/manage_switches/bootstrap_models.py index 62dbcfd4..0d72ebed 100644 --- a/plugins/module_utils/models/nd_manage_switches/bootstrap_models.py +++ b/plugins/module_utils/models/manage_switches/bootstrap_models.py @@ -20,12 +20,12 @@ 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.nd_manage_switches.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.enums import ( RemoteCredentialStore, SnmpV3AuthProtocol, SwitchRole, ) -from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.validators import SwitchValidators class BootstrapBaseData(NDNestedModel): diff --git a/plugins/module_utils/models/nd_manage_switches/config_models.py b/plugins/module_utils/models/manage_switches/config_models.py similarity index 84% rename from plugins/module_utils/models/nd_manage_switches/config_models.py rename to plugins/module_utils/models/manage_switches/config_models.py index 40ab587b..b596ca6f 100644 --- a/plugins/module_utils/models/nd_manage_switches/config_models.py +++ b/plugins/module_utils/models/manage_switches/config_models.py @@ -24,12 +24,12 @@ 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.nd_manage_switches.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.enums import ( PlatformType, SnmpV3AuthProtocol, SwitchRole, ) -from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.validators import SwitchValidators class ConfigDataModel(NDNestedModel): @@ -337,11 +337,9 @@ class SwitchConfigModel(NDBaseModel): # Fields excluded from diff — only seed_ip + role are compared exclude_from_diff: ClassVar[List[str]] = [ - "user_name", "password", "auth_proto", "max_hops", + "user_name", "password", "auth_proto", "preserve_config", "platform_type", "poap", "rma", "operation_type", - "switch_id", "serial_number", "mode", "hostname", - "model", "software_version", ] # Required fields @@ -362,20 +360,12 @@ class SwitchConfigModel(NDBaseModel): default=None, description="Login password to the switch (required for merged/overridden states)" ) - # Optional fields with defaults auth_proto: SnmpV3AuthProtocol = Field( default=SnmpV3AuthProtocol.MD5, alias="authProto", description="Authentication protocol to use" ) - max_hops: int = Field( - default=0, - alias="maxHops", - ge=0, - le=7, - description="Maximum hops to reach the switch (deprecated, defaults to 0)" - ) role: Optional[SwitchRole] = Field( default=None, description="Role to assign to the switch. None means not specified (uses controller default)." @@ -419,35 +409,6 @@ def operation_type(self) -> Literal["normal", "poap", "rma"]: return "rma" return "normal" - # API-derived fields (populated by from_response, never set by users) - switch_id: Optional[str] = Field( - default=None, - alias="switchId", - description="Serial number / switch ID from inventory API" - ) - serial_number: Optional[str] = Field( - default=None, - alias="serialNumber", - description="Serial number from inventory API" - ) - mode: Optional[str] = Field( - default=None, - description="Switch mode from inventory API (Normal, Migration, etc.)" - ) - hostname: Optional[str] = Field( - default=None, - description="Switch hostname from inventory API" - ) - model: Optional[str] = Field( - default=None, - description="Switch model from inventory API" - ) - software_version: Optional[str] = Field( - default=None, - alias="softwareVersion", - description="Software version from inventory API" - ) - def to_config_dict(self) -> Dict[str, Any]: """Return the playbook config as a dict with all credentials stripped. @@ -534,10 +495,10 @@ def apply_state_defaults(self, info: ValidationInfo) -> Self: """ state = (info.context or {}).get("state") if info else None - # POAP only allowed with merged or query - if self.poap and state not in (None, "merged", "query"): + # POAP only allowed with merged + if self.poap and state not in (None, "merged"): raise ValueError( - f"POAP operations require 'merged' or 'query' state, " + f"POAP operations require 'merged' state, " f"got '{state}' (switch: {self.seed_ip})" ) @@ -623,76 +584,12 @@ def normalize_platform_type(cls, v: Union[str, PlatformType, None]) -> PlatformT """Normalize platform_type for case-insensitive matching (NX_OS, nx-os, etc.).""" return PlatformType.normalize(v) - @classmethod - def validate_no_mixed_operations( - cls, configs: List["SwitchConfigModel"] - ) -> None: - """Validate that a list of configs does not mix operation types. - - POAP, RMA, and normal switch operations cannot be combined - in the same Ansible task. Call this after validating all - individual configs. - - Args: - configs: List of validated SwitchConfigModel instances. - - Raises: - ValueError: If more than one operation type is present. - """ - op_types = {cfg.operation_type for cfg in configs} - if len(op_types) > 1: - raise ValueError( - "Mixed operation types detected: " - f"{', '.join(sorted(op_types))}. " - "POAP, RMA, and normal switch operations " - "cannot be mixed in the same task. " - "Please separate them into different tasks." - ) - def to_payload(self) -> Dict[str, Any]: - """Convert to API payload format. - - Excludes API-derived fields that are not part of the user config. - """ + """Convert to API payload format.""" return self.model_dump( by_alias=True, exclude_none=True, - exclude={ - "switch_id", "serial_number", "mode", - "hostname", "model", "software_version", - }, - ) - - @classmethod - def from_response(cls, response: Dict[str, Any]) -> Self: - """Create model instance from inventory or discovery API response. - - Handles two formats: - 1. Inventory API: {switchId, fabricManagementIp, switchRole, ...} - 2. Discovery API: {serialNumber, ip, hostname, ...} - """ - mapped: Dict[str, Any] = {} - - # seed_ip from fabricManagementIp (inventory) or ip (discovery) - ip = response.get("fabricManagementIp") or response.get("ip") - if ip: - mapped["seedIp"] = ip - - # role from switchRole - role = response.get("switchRole") - if role: - mapped["role"] = role - - # Direct API fields - direct_fields = ( - "switchId", "serialNumber", "softwareVersion", - "mode", "hostname", "model", ) - for key in direct_fields: - if key in response and response[key] is not None: - mapped[key] = response[key] - - return cls.model_validate(mapped) __all__ = [ diff --git a/plugins/module_utils/models/nd_manage_switches/discovery_models.py b/plugins/module_utils/models/manage_switches/discovery_models.py similarity index 97% rename from plugins/module_utils/models/nd_manage_switches/discovery_models.py rename to plugins/module_utils/models/manage_switches/discovery_models.py index df79aaf8..dfe190f0 100644 --- a/plugins/module_utils/models/nd_manage_switches/discovery_models.py +++ b/plugins/module_utils/models/manage_switches/discovery_models.py @@ -19,14 +19,14 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.enums import ( PlatformType, RemoteCredentialStore, ShallowDiscoveryPlatformType, SnmpV3AuthProtocol, SwitchRole, ) -from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.validators import SwitchValidators class ShallowDiscoveryRequestModel(NDBaseModel): diff --git a/plugins/module_utils/models/nd_manage_switches/enums.py b/plugins/module_utils/models/manage_switches/enums.py similarity index 100% rename from plugins/module_utils/models/nd_manage_switches/enums.py rename to plugins/module_utils/models/manage_switches/enums.py diff --git a/plugins/module_utils/models/nd_manage_switches/preprovision_models.py b/plugins/module_utils/models/manage_switches/preprovision_models.py similarity index 97% rename from plugins/module_utils/models/nd_manage_switches/preprovision_models.py rename to plugins/module_utils/models/manage_switches/preprovision_models.py index d42daa2d..ba073824 100644 --- a/plugins/module_utils/models/nd_manage_switches/preprovision_models.py +++ b/plugins/module_utils/models/manage_switches/preprovision_models.py @@ -20,12 +20,12 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.enums import ( RemoteCredentialStore, SnmpV3AuthProtocol, SwitchRole, ) -from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.validators import SwitchValidators class PreProvisionSwitchModel(NDBaseModel): diff --git a/plugins/module_utils/models/nd_manage_switches/rma_models.py b/plugins/module_utils/models/manage_switches/rma_models.py similarity index 96% rename from plugins/module_utils/models/nd_manage_switches/rma_models.py rename to plugins/module_utils/models/manage_switches/rma_models.py index f1f692fa..7760d11b 100644 --- a/plugins/module_utils/models/nd_manage_switches/rma_models.py +++ b/plugins/module_utils/models/manage_switches/rma_models.py @@ -19,12 +19,12 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.enums import ( RemoteCredentialStore, SnmpV3AuthProtocol, SwitchRole, ) -from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.validators import SwitchValidators class RMASwitchModel(NDBaseModel): """ diff --git a/plugins/module_utils/models/nd_manage_switches/switch_actions_models.py b/plugins/module_utils/models/manage_switches/switch_actions_models.py similarity index 97% rename from plugins/module_utils/models/nd_manage_switches/switch_actions_models.py rename to plugins/module_utils/models/manage_switches/switch_actions_models.py index 18113445..5f903e65 100644 --- a/plugins/module_utils/models/nd_manage_switches/switch_actions_models.py +++ b/plugins/module_utils/models/manage_switches/switch_actions_models.py @@ -19,7 +19,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel -from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches.validators import SwitchValidators +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.validators import SwitchValidators class SwitchCredentialsRequestModel(NDBaseModel): diff --git a/plugins/module_utils/models/nd_manage_switches/switch_data_models.py b/plugins/module_utils/models/manage_switches/switch_data_models.py similarity index 99% rename from plugins/module_utils/models/nd_manage_switches/switch_data_models.py rename to plugins/module_utils/models/manage_switches/switch_data_models.py index 484815d8..e4de26cb 100644 --- a/plugins/module_utils/models/nd_manage_switches/switch_data_models.py +++ b/plugins/module_utils/models/manage_switches/switch_data_models.py @@ -20,7 +20,7 @@ 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.nd_manage_switches.enums import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.enums import ( AdvisoryLevel, AnomalyLevel, ConfigSyncStatus, diff --git a/plugins/module_utils/models/nd_manage_switches/validators.py b/plugins/module_utils/models/manage_switches/validators.py similarity index 100% rename from plugins/module_utils/models/nd_manage_switches/validators.py rename to plugins/module_utils/models/manage_switches/validators.py diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index 59d4e9ca..638e870d 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -26,7 +26,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results -from ansible_collections.cisco.nd.plugins.module_utils.models.nd_manage_switches import ( +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches import ( SwitchRole, SnmpV3AuthProtocol, PlatformType, @@ -211,17 +211,6 @@ def validate_configs( log.warning("No valid configurations found in input") return validated_configs - # Cross-config check — model can't do this per-instance - try: - SwitchConfigModel.validate_no_mixed_operations(validated_configs) - except ValueError as e: - error_msg = str(e) - log.error(error_msg) - if hasattr(nd, 'module'): - nd.module.fail_json(msg=error_msg) - else: - raise - # Duplicate seed_ip check seen_ips: set = set() duplicate_ips: set = set() @@ -240,14 +229,14 @@ def validate_configs( else: raise ValueError(error_msg) - operation_type = validated_configs[0].operation_type + operation_types = {c.operation_type for c in validated_configs} log.info( f"Successfully validated {len(validated_configs)} " - f"configuration(s) with operation type: {operation_type}" + f"configuration(s) with operation type(s): {operation_types}" ) log.debug( f"EXIT: validate_configs() -> " - f"{len(validated_configs)} configs, operation_type={operation_type}" + f"{len(validated_configs)} configs, operation_types={operation_types}" ) return validated_configs @@ -2487,7 +2476,7 @@ def manage_state(self) -> None: """Dispatch the requested module state to the appropriate workflow. This method validates input, routes POAP and RMA operations to dedicated - handlers, and executes state-specific orchestration for query, merged, + handlers, and executes state-specific orchestration for merged, overridden, and deleted operations. Returns: @@ -2525,43 +2514,58 @@ def manage_state(self) -> None: model_class=SwitchConfigModel, items=proposed_config ) ) - self.operation_type = proposed_config[0].operation_type - - # POAP and RMA bypass normal discovery — delegate to handlers - if self.operation_type == "poap": - return self.poap_handler.handle(proposed_config, list(self.existing)) - if self.operation_type == "rma": - return self.rma_handler.handle(proposed_config, list(self.existing)) - - # Normal: discover → build proposed models → compute diff → delegate - # Skip discovery for switches already in the fabric. - existing_ips = {sw.fabric_management_ip for sw in self.existing} - configs_to_discover = [cfg for cfg in proposed_config if cfg.seed_ip not in existing_ips] - if configs_to_discover: - self.log.info( - f"Discovery needed for {len(configs_to_discover)}/{len(proposed_config)} " - f"switch(es) — {len(proposed_config) - len(configs_to_discover)} already in fabric" - ) - discovered_data = self.discovery.discover(configs_to_discover) - else: - self.log.info("All proposed switches already in fabric — skipping discovery") - discovered_data = {} - built = self.discovery.build_proposed( - proposed_config, discovered_data, list(self.existing) - ) - self.proposed = NDConfigCollection(model_class=SwitchDataModel, items=built) - diff = SwitchDiffEngine.compute_changes( - list(self.proposed), list(self.existing), self.log + # Partition configs by operation type + poap_configs = [c for c in proposed_config if c.operation_type == "poap"] + rma_configs = [c for c in proposed_config if c.operation_type == "rma"] + normal_configs = [c for c in proposed_config if c.operation_type not in ("poap", "rma")] + + self.log.info( + f"Config partition: {len(normal_configs)} normal, " + f"{len(poap_configs)} poap, {len(rma_configs)} rma" ) - state_handlers = { - "merged": self._handle_merged_state, - "overridden": self._handle_overridden_state, - } - handler = state_handlers.get(self.state) - if handler is None: - self.nd.module.fail_json(msg=f"Unsupported state: {self.state}") - return handler(diff, proposed_config, discovered_data) + # POAP and RMA are only valid with state=merged + if (poap_configs or rma_configs) and self.state != "merged": + self.nd.module.fail_json( + msg="POAP and RMA configs are only supported with state=merged" + ) + + # Normal discovery runs first so the fabric inventory is up to date + # before POAP/RMA handlers execute. + if normal_configs: + existing_ips = {sw.fabric_management_ip for sw in self.existing} + configs_to_discover = [cfg for cfg in normal_configs if cfg.seed_ip not in existing_ips] + if configs_to_discover: + self.log.info( + f"Discovery needed for {len(configs_to_discover)}/{len(normal_configs)} " + f"switch(es) — {len(normal_configs) - len(configs_to_discover)} already in fabric" + ) + discovered_data = self.discovery.discover(configs_to_discover) + else: + self.log.info("All proposed switches already in fabric — skipping discovery") + discovered_data = {} + built = self.discovery.build_proposed( + normal_configs, discovered_data, list(self.existing) + ) + self.proposed = NDConfigCollection(model_class=SwitchDataModel, items=built) + diff = SwitchDiffEngine.compute_changes( + list(self.proposed), list(self.existing), self.log + ) + + state_handlers = { + "merged": self._handle_merged_state, + "overridden": self._handle_overridden_state, + } + handler = state_handlers.get(self.state) + if handler is None: + self.nd.module.fail_json(msg=f"Unsupported state: {self.state}") + handler(diff, normal_configs, discovered_data) + + # POAP and RMA run after normal discovery + if poap_configs: + self.poap_handler.handle(poap_configs, list(self.existing)) + if rma_configs: + self.rma_handler.handle(rma_configs, list(self.existing)) # ===================================================================== # State Handlers (orchestration only — delegate to services) diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index 6b86a9b7..df0a53d7 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -17,7 +17,7 @@ version_added: "1.0.0" author: Akshayanat Chengam Saravanan (@achengam) description: -- Add, delete, override, and query switches in Cisco Nexus Dashboard. +- Add, delete, and override switches in Cisco Nexus Dashboard. - Supports normal discovery, POAP (bootstrap/preprovision), and RMA operations. - Uses Pydantic model validation for switch configurations. - Provides state-based operations with intelligent diff calculation. @@ -30,7 +30,7 @@ state: description: - The state of ND and switch(es) after module completion. - - C(merged) and C(query) are the only states supported for POAP. + - C(merged) is the only state supported for POAP. - C(merged) is the only state supported for RMA. type: str default: merged @@ -38,7 +38,6 @@ - merged - overridden - deleted - - query save: description: - Save/Recalculate the configuration of the fabric after inventory is updated. @@ -344,11 +343,6 @@ - seed_ip: 192.168.10.202 state: deleted -- name: Query all switches in fabric - cisco.nd.nd_manage_switches: - fabric: my-fabric - state: query - register: switches_result """ RETURN = """ @@ -364,7 +358,7 @@ elements: dict sent: description: The configuration sent to the API. - returned: when state is not query + returned: always type: list elements: dict current: @@ -479,7 +473,7 @@ def main(): state=dict( type="str", default="merged", - choices=["merged", "overridden", "deleted", "query"] + choices=["merged", "overridden", "deleted"] ), ) @@ -529,7 +523,7 @@ def main(): ) log.info(f"NDSwitchResourceModule initialized for fabric: {fabric}") - # Manage state for merged, overridden, deleted, query + # Manage state for merged, overridden, deleted log.info(f"Managing state: {state}") sw_module.manage_state() From 824b6c9fe2a27839bd2e127bc120e6461cb81073 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Thu, 19 Mar 2026 15:25:24 +0530 Subject: [PATCH 16/27] Fixing paths, docstrings, class names, adding UT for endpoints --- ...ials.py => manage_credentials_switches.py} | 4 +- .../fabric_config.py => manage_fabrics.py} | 104 +-- .../v1/manage/manage_fabrics_actions.py | 139 ++++ ...otstrap.py => manage_fabrics_bootstrap.py} | 24 +- ...scovery.py => manage_fabrics_inventory.py} | 40 +- ...ons.py => manage_fabrics_switchactions.py} | 256 +------- .../v1/manage/manage_fabrics_switches.py | 449 +++++++++++++ .../nd_manage_switches/fabric_switches.py | 256 -------- .../models/manage_switches/__init__.py | 141 ++++ .../manage_switches/bootstrap_models.py | 2 +- .../models/manage_switches/config_models.py | 2 +- .../manage_switches/discovery_models.py | 2 +- .../models/manage_switches/enums.py | 2 +- .../manage_switches/preprovision_models.py | 2 +- .../models/manage_switches/rma_models.py | 2 +- .../manage_switches/switch_actions_models.py | 2 +- .../manage_switches/switch_data_models.py | 2 +- .../models/manage_switches/validators.py | 2 +- plugins/module_utils/nd_switch_resources.py | 48 +- .../__init__.py | 10 +- .../bootstrap_utils.py | 8 +- .../exceptions.py | 2 +- .../fabric_utils.py | 10 +- .../payload_utils.py | 2 +- .../switch_helpers.py | 2 +- .../switch_wait_utils.py | 20 +- plugins/modules/nd_manage_switches.py | 6 +- ...ints_api_v1_manage_credentials_switches.py | 177 +++++ .../test_endpoints_api_v1_manage_fabrics.py | 271 ++++++++ ...endpoints_api_v1_manage_fabrics_actions.py | 162 +++++ ...dpoints_api_v1_manage_fabrics_bootstrap.py | 206 ++++++ ...dpoints_api_v1_manage_fabrics_inventory.py | 92 +++ ...nts_api_v1_manage_fabrics_switchactions.py | 491 ++++++++++++++ ...ndpoints_api_v1_manage_fabrics_switches.py | 614 ++++++++++++++++++ 34 files changed, 2874 insertions(+), 678 deletions(-) rename plugins/module_utils/endpoints/v1/manage/{nd_manage_switches/credentials.py => manage_credentials_switches.py} (96%) rename plugins/module_utils/endpoints/v1/manage/{nd_manage_switches/fabric_config.py => manage_fabrics.py} (66%) create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions.py rename plugins/module_utils/endpoints/v1/manage/{nd_manage_switches/fabric_bootstrap.py => manage_fabrics_bootstrap.py} (81%) rename plugins/module_utils/endpoints/v1/manage/{nd_manage_switches/fabric_discovery.py => manage_fabrics_inventory.py} (57%) rename plugins/module_utils/endpoints/v1/manage/{nd_manage_switches/fabric_switch_actions.py => manage_fabrics_switchactions.py} (61%) create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py delete mode 100644 plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py create mode 100644 plugins/module_utils/models/manage_switches/__init__.py rename plugins/module_utils/utils/{nd_manage_switches => manage_switches}/__init__.py (71%) rename plugins/module_utils/utils/{nd_manage_switches => manage_switches}/bootstrap_utils.py (92%) rename plugins/module_utils/utils/{nd_manage_switches => manage_switches}/exceptions.py (82%) rename plugins/module_utils/utils/{nd_manage_switches => manage_switches}/fabric_utils.py (94%) rename plugins/module_utils/utils/{nd_manage_switches => manage_switches}/payload_utils.py (96%) rename plugins/module_utils/utils/{nd_manage_switches => manage_switches}/switch_helpers.py (97%) rename plugins/module_utils/utils/{nd_manage_switches => manage_switches}/switch_wait_utils.py (97%) create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_credentials_switches.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_actions.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_bootstrap.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_inventory.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_switchactions.py create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_switches.py diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py b/plugins/module_utils/endpoints/v1/manage/manage_credentials_switches.py similarity index 96% rename from plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py rename to plugins/module_utils/endpoints/v1/manage/manage_credentials_switches.py index ae40a17a..9609dc99 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/credentials.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_credentials_switches.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) """ @@ -20,7 +20,7 @@ # pylint: disable=invalid-name __metaclass__ = type -__author__ = "Akshayanat Chengam Saravanan" +__author__ = "Akshayanat C S" # pylint: enable=invalid-name from typing import Literal diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics.py similarity index 66% rename from plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics.py index b8a5d906..6541dccc 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_config.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics.py @@ -1,26 +1,24 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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 Config endpoint models. +ND Manage Fabrics endpoint models. -This module contains endpoint definitions for fabric configuration operations +This module contains endpoint definitions for fabric-level operations in the ND Manage API. Endpoints covered: -- Config save (recalculate) - Config deploy - Get fabric info -- Inventory discover status """ from __future__ import absolute_import, annotations, division, print_function # pylint: disable=invalid-name __metaclass__ = type -__author__ = "Akshayanat Chengam Saravanan" +__author__ = "Akshayanat C S" # pylint: enable=invalid-name from typing import Literal, Optional @@ -67,9 +65,9 @@ class FabricConfigDeployEndpointParams(EndpointQueryParams): incl_all_msd_switches: Optional[bool] = Field(default=None, description="Include all MSD fabric switches") -class _EpManageFabricConfigBase(FabricNameMixin, NDEndpointBaseModel): +class _EpManageFabricsBase(FabricNameMixin, NDEndpointBaseModel): """ - Base class for Fabric Config endpoints. + Base class for Fabrics endpoints. Provides common functionality for all HTTP methods on the /api/v1/manage/fabrics/{fabricName} endpoint family. @@ -83,50 +81,7 @@ def _base_path(self) -> str: return BasePath.path("fabrics", self.fabric_name) -class EpManageFabricConfigSavePost(_EpManageFabricConfigBase): - """ - # Summary - - Fabric Config Save Endpoint - - ## Description - - Endpoint to save (recalculate) fabric configuration. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/actions/configSave - - ## Verb - - - POST - - ## Usage - - ```python - request = EpManageFabricConfigSavePost() - request.fabric_name = "MyFabric" - path = request.path - verb = request.verb - ``` - """ - - class_name: Literal["EpManageFabricConfigSavePost"] = Field( - default="EpManageFabricConfigSavePost", frozen=True, description="Class name for backward compatibility" - ) - - @property - def path(self) -> str: - """Build the endpoint path.""" - return f"{self._base_path}/actions/configSave" - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.POST - - -class EpManageFabricConfigDeployPost(_EpManageFabricConfigBase): +class EpManageFabricConfigDeployPost(_EpManageFabricsBase): """ # Summary @@ -199,7 +154,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class EpManageFabricGet(_EpManageFabricConfigBase): +class EpManageFabricGet(_EpManageFabricsBase): """ # Summary @@ -240,46 +195,3 @@ def path(self) -> str: def verb(self) -> HttpVerbEnum: """Return the HTTP verb for this endpoint.""" return HttpVerbEnum.GET - - -class EpManageFabricInventoryDiscoverGet(_EpManageFabricConfigBase): - """ - # Summary - - Fabric Inventory Discover Endpoint - - ## Description - - Endpoint to get discovery status for switches in a fabric. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/inventory/discover - - ## Verb - - - GET - - ## Usage - - ```python - request = EpManageFabricInventoryDiscoverGet() - request.fabric_name = "MyFabric" - path = request.path - verb = request.verb - ``` - """ - - class_name: Literal["EpManageFabricInventoryDiscoverGet"] = Field( - default="EpManageFabricInventoryDiscoverGet", frozen=True, description="Class name for backward compatibility" - ) - - @property - def path(self) -> str: - """Build the endpoint path.""" - return f"{self._base_path}/inventory/discover" - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.GET diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions.py new file mode 100644 index 00000000..5c2a72bb --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_actions.py @@ -0,0 +1,139 @@ +# -*- 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 Discovery endpoint models. + +This module contains endpoint definitions for switch discovery operations +within fabrics in the ND Manage API. + +Endpoints covered: +- Shallow discovery +""" + +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 + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( + FabricNameMixin, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.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 _EpManageFabricsActionsBase(FabricNameMixin, NDEndpointBaseModel): + """ + Base class for Fabric Actions endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabricName}/actions 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, "actions") + + +class EpManageFabricsActionsShallowDiscoveryPost(_EpManageFabricsActionsBase): + """ + # Summary + + Shallow Discovery Endpoint + + ## Description + + Endpoint to shallow discover switches given seed switches with hop count. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/actions/shallowDiscovery + + ## Verb + + - POST + + ## Usage + + ```python + request = EpManageFabricsActionsShallowDiscoveryPost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + ``` + """ + + class_name: Literal["EpManageFabricsActionsShallowDiscoveryPost"] = Field( + default="EpManageFabricsActionsShallowDiscoveryPost", frozen=True, description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """Build the endpoint path.""" + return f"{self._base_path}/shallowDiscovery" + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +class EpManageFabricsActionsConfigSavePost(_EpManageFabricsActionsBase): + """ + # Summary + + Fabric Config Save Endpoint + + ## Description + + Endpoint to save (recalculate) fabric configuration. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/actions/configSave + + ## Verb + + - POST + + ## Usage + + ```python + request = EpManageFabricsActionsConfigSavePost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + ``` + """ + + class_name: Literal["EpManageFabricsActionsConfigSavePost"] = Field( + default="EpManageFabricsActionsConfigSavePost", frozen=True, description="Class name for backward compatibility" + ) + + @property + def path(self) -> str: + """Build the endpoint path.""" + return f"{self._base_path}/configSave" + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_bootstrap.py similarity index 81% rename from plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_bootstrap.py index 5bef2ff5..89dcb6a8 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_bootstrap.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_bootstrap.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) """ @@ -17,7 +17,7 @@ # pylint: disable=invalid-name __metaclass__ = type -__author__ = "Akshayanat Chengam Saravanan" +__author__ = "Akshayanat C S" # pylint: enable=invalid-name from typing import Literal, Optional @@ -43,7 +43,7 @@ ) -class FabricBootstrapEndpointParams(FilterMixin, MaxMixin, OffsetMixin, EndpointQueryParams): +class FabricsBootstrapEndpointParams(FilterMixin, MaxMixin, OffsetMixin, EndpointQueryParams): """ # Summary @@ -58,14 +58,14 @@ class FabricBootstrapEndpointParams(FilterMixin, MaxMixin, OffsetMixin, Endpoint ## Usage ```python - params = FabricBootstrapEndpointParams(max=50, offset=0) + params = FabricsBootstrapEndpointParams(max=50, offset=0) query_string = params.to_query_string() # Returns: "max=50&offset=0" ``` """ -class _EpManageFabricBootstrapBase(FabricNameMixin, NDEndpointBaseModel): +class _EpManageFabricsBootstrapBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Bootstrap endpoints. @@ -81,7 +81,7 @@ def _base_path(self) -> str: return BasePath.path("fabrics", self.fabric_name, "bootstrap") -class EpManageFabricBootstrapGet(_EpManageFabricBootstrapBase): +class EpManageFabricsBootstrapGet(_EpManageFabricsBootstrapBase): """ # Summary @@ -110,13 +110,13 @@ class EpManageFabricBootstrapGet(_EpManageFabricBootstrapBase): ```python # List all bootstrap switches - request = EpManageFabricBootstrapGet() + request = EpManageFabricsBootstrapGet() request.fabric_name = "MyFabric" path = request.path verb = request.verb # List with pagination - request = EpManageFabricBootstrapGet() + request = EpManageFabricsBootstrapGet() request.fabric_name = "MyFabric" request.endpoint_params.max = 50 request.endpoint_params.offset = 0 @@ -126,11 +126,11 @@ class EpManageFabricBootstrapGet(_EpManageFabricBootstrapBase): ``` """ - class_name: Literal["EpManageFabricBootstrapGet"] = Field( - default="EpManageFabricBootstrapGet", frozen=True, description="Class name for backward compatibility" + class_name: Literal["EpManageFabricsBootstrapGet"] = Field( + default="EpManageFabricsBootstrapGet", frozen=True, description="Class name for backward compatibility" ) - endpoint_params: FabricBootstrapEndpointParams = Field( - default_factory=FabricBootstrapEndpointParams, description="Endpoint-specific query parameters" + endpoint_params: FabricsBootstrapEndpointParams = Field( + default_factory=FabricsBootstrapEndpointParams, description="Endpoint-specific query parameters" ) @property diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_inventory.py similarity index 57% rename from plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_inventory.py index 8bfc45d9..5cad5a42 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_discovery.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_inventory.py @@ -1,23 +1,23 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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 Discovery endpoint models. +ND Manage Fabrics Inventory endpoint models. -This module contains endpoint definitions for switch discovery operations -within fabrics in the ND Manage API. +This module contains endpoint definitions for fabric inventory operations +in the ND Manage API. Endpoints covered: -- Shallow discovery +- Inventory discover status """ from __future__ import absolute_import, annotations, division, print_function # pylint: disable=invalid-name __metaclass__ = type -__author__ = "Akshayanat Chengam Saravanan" +__author__ = "Akshayanat C S" # pylint: enable=invalid-name from typing import Literal @@ -37,12 +37,12 @@ ) -class _EpManageFabricDiscoveryBase(FabricNameMixin, NDEndpointBaseModel): +class _EpManageFabricsInventoryBase(FabricNameMixin, NDEndpointBaseModel): """ - Base class for Fabric Discovery endpoints. + Base class for Fabric Inventory endpoints. Provides common functionality for all HTTP methods on the - /api/v1/manage/fabrics/{fabricName}/actions/shallowDiscovery endpoint. + /api/v1/manage/fabrics/{fabricName}/inventory endpoint family. """ @property @@ -50,47 +50,47 @@ 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, "actions", "shallowDiscovery") + return BasePath.path("fabrics", self.fabric_name) -class EpManageFabricShallowDiscoveryPost(_EpManageFabricDiscoveryBase): +class EpManageFabricsInventoryDiscoverGet(_EpManageFabricsInventoryBase): """ # Summary - Shallow Discovery Endpoint + Fabric Inventory Discover Endpoint ## Description - Endpoint to shallow discover switches given seed switches with hop count. + Endpoint to get discovery status for switches in a fabric. ## Path - - /api/v1/manage/fabrics/{fabricName}/actions/shallowDiscovery + - /api/v1/manage/fabrics/{fabricName}/inventory/discover ## Verb - - POST + - GET ## Usage ```python - request = EpManageFabricShallowDiscoveryPost() + request = EpManageFabricsInventoryDiscoverGet() request.fabric_name = "MyFabric" path = request.path verb = request.verb ``` """ - class_name: Literal["EpManageFabricShallowDiscoveryPost"] = Field( - default="EpManageFabricShallowDiscoveryPost", frozen=True, description="Class name for backward compatibility" + class_name: Literal["EpManageFabricsInventoryDiscoverGet"] = Field( + default="EpManageFabricsInventoryDiscoverGet", frozen=True, description="Class name for backward compatibility" ) @property def path(self) -> str: """Build the endpoint path.""" - return self._base_path + return f"{self._base_path}/inventory/discover" @property def verb(self) -> HttpVerbEnum: """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.POST + return HttpVerbEnum.GET diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switchactions.py similarity index 61% rename from plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py rename to plugins/module_utils/endpoints/v1/manage/manage_fabrics_switchactions.py index 5a455091..7613140d 100644 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switch_actions.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switchactions.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) """ @@ -14,15 +14,14 @@ - Change switch roles (bulk) - Import bootstrap (POAP) - Pre-provision switches -- Provision RMA -- Change switch serial number +- Rediscover switches """ from __future__ import absolute_import, annotations, division, print_function # pylint: disable=invalid-name __metaclass__ = type -__author__ = "Akshayanat Chengam Saravanan" +__author__ = "Akshayanat C S" # pylint: enable=invalid-name from typing import Literal, Optional @@ -31,7 +30,6 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import ( ClusterNameMixin, FabricNameMixin, - SwitchSerialNumberMixin, TicketIdMixin, ) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.query_params import ( @@ -122,7 +120,7 @@ class SwitchActionsImportEndpointParams(ClusterNameMixin, TicketIdMixin, Endpoin # ============================================================================ -class _EpManageFabricSwitchActionsBase(FabricNameMixin, NDEndpointBaseModel): +class _EpManageFabricsSwitchActionsBase(FabricNameMixin, NDEndpointBaseModel): """ Base class for Fabric Switch Actions endpoints. @@ -138,7 +136,7 @@ def _base_path(self) -> str: return BasePath.path("fabrics", self.fabric_name, "switchActions") -class EpManageFabricSwitchActionsRemovePost(_EpManageFabricSwitchActionsBase): +class EpManageFabricsSwitchActionsRemovePost(_EpManageFabricsSwitchActionsBase): """ # Summary @@ -166,13 +164,13 @@ class EpManageFabricSwitchActionsRemovePost(_EpManageFabricSwitchActionsBase): ```python # Remove switches - request = EpManageFabricSwitchActionsRemovePost() + request = EpManageFabricsSwitchActionsRemovePost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Remove switches with force and ticket - request = EpManageFabricSwitchActionsRemovePost() + request = EpManageFabricsSwitchActionsRemovePost() request.fabric_name = "MyFabric" request.endpoint_params.force = True request.endpoint_params.ticket_id = "CHG12345" @@ -182,8 +180,8 @@ class EpManageFabricSwitchActionsRemovePost(_EpManageFabricSwitchActionsBase): ``` """ - class_name: Literal["EpManageFabricSwitchActionsRemovePost"] = Field( - default="EpManageFabricSwitchActionsRemovePost", frozen=True, description="Class name for backward compatibility" + class_name: Literal["EpManageFabricsSwitchActionsRemovePost"] = Field( + default="EpManageFabricsSwitchActionsRemovePost", frozen=True, description="Class name for backward compatibility" ) endpoint_params: SwitchActionsRemoveEndpointParams = Field( default_factory=SwitchActionsRemoveEndpointParams, description="Endpoint-specific query parameters" @@ -212,7 +210,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class EpManageFabricSwitchActionsChangeRolesPost(_EpManageFabricSwitchActionsBase): +class EpManageFabricsSwitchActionsChangeRolesPost(_EpManageFabricsSwitchActionsBase): """ # Summary @@ -239,13 +237,13 @@ class EpManageFabricSwitchActionsChangeRolesPost(_EpManageFabricSwitchActionsBas ```python # Change roles - request = EpManageFabricSwitchActionsChangeRolesPost() + request = EpManageFabricsSwitchActionsChangeRolesPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Change roles with change control ticket - request = EpManageFabricSwitchActionsChangeRolesPost() + request = EpManageFabricsSwitchActionsChangeRolesPost() request.fabric_name = "MyFabric" request.endpoint_params.ticket_id = "CHG12345" path = request.path @@ -254,8 +252,8 @@ class EpManageFabricSwitchActionsChangeRolesPost(_EpManageFabricSwitchActionsBas ``` """ - class_name: Literal["EpManageFabricSwitchActionsChangeRolesPost"] = Field( - default="EpManageFabricSwitchActionsChangeRolesPost", frozen=True, + class_name: Literal["EpManageFabricsSwitchActionsChangeRolesPost"] = Field( + default="EpManageFabricsSwitchActionsChangeRolesPost", frozen=True, description="Class name for backward compatibility", ) endpoint_params: SwitchActionsTicketEndpointParams = Field( @@ -285,7 +283,7 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -class EpManageFabricSwitchActionsImportBootstrapPost(_EpManageFabricSwitchActionsBase): +class EpManageFabricsSwitchActionsImportBootstrapPost(_EpManageFabricsSwitchActionsBase): """ # Summary @@ -313,13 +311,13 @@ class EpManageFabricSwitchActionsImportBootstrapPost(_EpManageFabricSwitchAction ```python # Import bootstrap switches - request = EpManageFabricSwitchActionsImportBootstrapPost() + request = EpManageFabricsSwitchActionsImportBootstrapPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Import with cluster and ticket - request = EpManageFabricSwitchActionsImportBootstrapPost() + request = EpManageFabricsSwitchActionsImportBootstrapPost() request.fabric_name = "MyFabric" request.endpoint_params.cluster_name = "cluster1" request.endpoint_params.ticket_id = "CHG12345" @@ -329,8 +327,8 @@ class EpManageFabricSwitchActionsImportBootstrapPost(_EpManageFabricSwitchAction ``` """ - class_name: Literal["EpManageFabricSwitchActionsImportBootstrapPost"] = Field( - default="EpManageFabricSwitchActionsImportBootstrapPost", frozen=True, description="Class name for backward compatibility" + class_name: Literal["EpManageFabricsSwitchActionsImportBootstrapPost"] = Field( + default="EpManageFabricsSwitchActionsImportBootstrapPost", frozen=True, description="Class name for backward compatibility" ) endpoint_params: SwitchActionsImportEndpointParams = Field( default_factory=SwitchActionsImportEndpointParams, description="Endpoint-specific query parameters" @@ -364,7 +362,7 @@ def verb(self) -> HttpVerbEnum: # ============================================================================ -class EpManageFabricSwitchActionsPreProvisionPost(_EpManageFabricSwitchActionsBase): +class EpManageFabricsSwitchActionsPreProvisionPost(_EpManageFabricsSwitchActionsBase): """ # Summary @@ -395,13 +393,13 @@ class EpManageFabricSwitchActionsPreProvisionPost(_EpManageFabricSwitchActionsBa ```python # Pre-provision switches - request = EpManageFabricSwitchActionsPreProvisionPost() + request = EpManageFabricsSwitchActionsPreProvisionPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Pre-provision with cluster and ticket - request = EpManageFabricSwitchActionsPreProvisionPost() + request = EpManageFabricsSwitchActionsPreProvisionPost() request.fabric_name = "MyFabric" request.endpoint_params.cluster_name = "cluster1" request.endpoint_params.ticket_id = "CHG12345" @@ -411,8 +409,8 @@ class EpManageFabricSwitchActionsPreProvisionPost(_EpManageFabricSwitchActionsBa ``` """ - class_name: Literal["EpManageFabricSwitchActionsPreProvisionPost"] = Field( - default="EpManageFabricSwitchActionsPreProvisionPost", frozen=True, + class_name: Literal["EpManageFabricsSwitchActionsPreProvisionPost"] = Field( + default="EpManageFabricsSwitchActionsPreProvisionPost", frozen=True, description="Class name for backward compatibility", ) endpoint_params: SwitchActionsImportEndpointParams = Field( @@ -442,208 +440,12 @@ def verb(self) -> HttpVerbEnum: return HttpVerbEnum.POST -# ============================================================================ -# RMA (Return Material Authorization) Endpoints -# ============================================================================ - - -class _EpManageFabricSwitchActionsPerSwitchBase(FabricNameMixin, SwitchSerialNumberMixin, NDEndpointBaseModel): - """ - Base class for per-switch action endpoints. - - Provides common functionality for all HTTP methods on the - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions 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") - if self.switch_sn is None: - raise ValueError("switch_sn must be set before accessing path") - return BasePath.path("fabrics", self.fabric_name, "switches", self.switch_sn, "actions") - - -class EpManageFabricSwitchProvisionRMAPost(_EpManageFabricSwitchActionsPerSwitchBase): - """ - # Summary - - Provision RMA for Switch Endpoint - - ## Description - - Endpoint to RMA (Return Material Authorization) an existing switch with a new bootstrapped switch. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/provisionRMA - - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/provisionRMA?ticketId=CHG12345 - - ## Verb - - - POST - - ## Query Parameters - - - ticket_id: Change control ticket ID (optional) - - ## Usage - - ```python - # Provision RMA - request = EpManageFabricSwitchProvisionRMAPost() - request.fabric_name = "MyFabric" - request.switch_sn = "SAL1948TRTT" - path = request.path - verb = request.verb - - # Provision RMA with change control ticket - request = EpManageFabricSwitchProvisionRMAPost() - request.fabric_name = "MyFabric" - request.switch_sn = "SAL1948TRTT" - request.endpoint_params.ticket_id = "CHG12345" - path = request.path - verb = request.verb - # Path will be: /api/v1/manage/fabrics/MyFabric/switches/SAL1948TRTT/actions/provisionRMA?ticketId=CHG12345 - ``` - """ - - class_name: Literal["EpManageFabricSwitchProvisionRMAPost"] = Field( - default="EpManageFabricSwitchProvisionRMAPost", frozen=True, description="Class name for backward compatibility" - ) - endpoint_params: SwitchActionsTicketEndpointParams = Field( - default_factory=SwitchActionsTicketEndpointParams, description="Endpoint-specific query parameters" - ) - - @property - def path(self) -> str: - """ - # Summary - - Build the endpoint path with optional query string. - - ## Returns - - - Complete endpoint path string, optionally including query parameters - """ - base = f"{self._base_path}/provisionRMA" - query_string = self.endpoint_params.to_query_string() - if query_string: - return f"{base}?{query_string}" - return base - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.POST - - -# ============================================================================ -# Change Switch Serial Number Endpoints -# ============================================================================ - - -class SwitchActionsClusterEndpointParams(ClusterNameMixin, EndpointQueryParams): - """ - # Summary - - Endpoint-specific query parameters for switch action endpoints that accept only a cluster name. - - ## Parameters - - - cluster_name: Target cluster name for multi-cluster deployments (optional, from `ClusterNameMixin`) - - ## Usage - - ```python - params = SwitchActionsClusterEndpointParams(cluster_name="cluster1") - query_string = params.to_query_string() - # Returns: "clusterName=cluster1" - ``` - """ - - -class EpManageFabricSwitchChangeSerialNumberPost(_EpManageFabricSwitchActionsPerSwitchBase): - """ - # Summary - - Change Switch Serial Number Endpoint - - ## Description - - Endpoint to change the serial number for a pre-provisioned switch. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/changeSwitchSerialNumber - - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/changeSwitchSerialNumber?clusterName=cluster1 - - ## Verb - - - POST - - ## Query Parameters - - - cluster_name: Target cluster name for multi-cluster deployments (optional) - - ## Usage - - ```python - # Change serial number - request = EpManageFabricSwitchChangeSerialNumberPost() - request.fabric_name = "MyFabric" - request.switch_sn = "SAL1948TRTT" - path = request.path - verb = request.verb - - # Change serial number with cluster name - request = EpManageFabricSwitchChangeSerialNumberPost() - request.fabric_name = "MyFabric" - request.switch_sn = "SAL1948TRTT" - request.endpoint_params.cluster_name = "cluster1" - path = request.path - verb = request.verb - # Path will be: /api/v1/manage/fabrics/MyFabric/switches/SAL1948TRTT/actions/changeSwitchSerialNumber?clusterName=cluster1 - ``` - """ - - class_name: Literal["EpManageFabricSwitchChangeSerialNumberPost"] = Field( - default="EpManageFabricSwitchChangeSerialNumberPost", frozen=True, description="Class name for backward compatibility" - ) - endpoint_params: SwitchActionsClusterEndpointParams = Field( - default_factory=SwitchActionsClusterEndpointParams, description="Endpoint-specific query parameters" - ) - - @property - def path(self) -> str: - """ - # Summary - - Build the endpoint path with optional query string. - - ## Returns - - - Complete endpoint path string, optionally including query parameters - """ - base = f"{self._base_path}/changeSwitchSerialNumber" - query_string = self.endpoint_params.to_query_string() - if query_string: - return f"{base}?{query_string}" - return base - - @property - def verb(self) -> HttpVerbEnum: - """Return the HTTP verb for this endpoint.""" - return HttpVerbEnum.POST - - # ============================================================================ # Rediscover Endpoints # ============================================================================ -class EpManageFabricSwitchActionsRediscoverPost(_EpManageFabricSwitchActionsBase): +class EpManageFabricsSwitchActionsRediscoverPost(_EpManageFabricsSwitchActionsBase): """ # Summary @@ -670,13 +472,13 @@ class EpManageFabricSwitchActionsRediscoverPost(_EpManageFabricSwitchActionsBase ```python # Rediscover switches - request = EpManageFabricSwitchActionsRediscoverPost() + request = EpManageFabricsSwitchActionsRediscoverPost() request.fabric_name = "MyFabric" path = request.path verb = request.verb # Rediscover switches with change control ticket - request = EpManageFabricSwitchActionsRediscoverPost() + request = EpManageFabricsSwitchActionsRediscoverPost() request.fabric_name = "MyFabric" request.endpoint_params.ticket_id = "CHG12345" path = request.path @@ -685,8 +487,8 @@ class EpManageFabricSwitchActionsRediscoverPost(_EpManageFabricSwitchActionsBase ``` """ - class_name: Literal["EpManageFabricSwitchActionsRediscoverPost"] = Field( - default="EpManageFabricSwitchActionsRediscoverPost", frozen=True, + class_name: Literal["EpManageFabricsSwitchActionsRediscoverPost"] = Field( + default="EpManageFabricsSwitchActionsRediscoverPost", frozen=True, description="Class name for backward compatibility", ) endpoint_params: SwitchActionsTicketEndpointParams = Field( diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py new file mode 100644 index 00000000..485747ec --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabrics_switches.py @@ -0,0 +1,449 @@ +# -*- 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, + SwitchSerialNumberMixin, + 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 + + +class EpManageFabricsSwitchesPost(_EpManageFabricsSwitchesBase): + """ + # Summary + + Add Switches to Fabric Endpoint + + ## Description + + Endpoint to add switches to a specific fabric. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches + - /api/v1/manage/fabrics/{fabricName}/switches?clusterName=cluster1&ticketId=CHG12345 + + ## Verb + + - POST + + ## Query Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional) + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + # Add switches + request = EpManageFabricsSwitchesPost() + request.fabric_name = "MyFabric" + path = request.path + verb = request.verb + + # Add switches with cluster and ticket + request = EpManageFabricsSwitchesPost() + request.fabric_name = "MyFabric" + request.endpoint_params.cluster_name = "cluster1" + request.endpoint_params.ticket_id = "CHG12345" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switches?clusterName=cluster1&ticketId=CHG12345 + ``` + """ + + class_name: Literal["EpManageFabricsSwitchesPost"] = Field( + default="EpManageFabricsSwitchesPost", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: FabricSwitchesAddEndpointParams = Field( + default_factory=FabricSwitchesAddEndpointParams, 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 + + +# ============================================================================ +# Per-Switch Action Endpoints +# ============================================================================ + +class SwitchActionsTicketEndpointParams(TicketIdMixin, EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for switch action endpoints that accept a ticket ID. + + ## Parameters + + - ticket_id: Change control ticket ID (optional, from `TicketIdMixin`) + + ## Usage + + ```python + params = SwitchActionsTicketEndpointParams(ticket_id="CHG12345") + query_string = params.to_query_string() + # Returns: "ticketId=CHG12345" + ``` + """ + + +class SwitchActionsClusterEndpointParams(ClusterNameMixin, EndpointQueryParams): + """ + # Summary + + Endpoint-specific query parameters for switch action endpoints that accept only a cluster name. + + ## Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional, from `ClusterNameMixin`) + + ## Usage + + ```python + params = SwitchActionsClusterEndpointParams(cluster_name="cluster1") + query_string = params.to_query_string() + # Returns: "clusterName=cluster1" + ``` + """ + +class _EpManageFabricsSwitchActionsPerSwitchBase(FabricNameMixin, SwitchSerialNumberMixin, NDEndpointBaseModel): + """ + Base class for per-switch action endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions 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") + if self.switch_sn is None: + raise ValueError("switch_sn must be set before accessing path") + return BasePath.path("fabrics", self.fabric_name, "switches", self.switch_sn, "actions") + + +class EpManageFabricsSwitchProvisionRMAPost(_EpManageFabricsSwitchActionsPerSwitchBase): + """ + # Summary + + Provision RMA for Switch Endpoint + + ## Description + + Endpoint to RMA (Return Material Authorization) an existing switch with a new bootstrapped switch. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/provisionRMA + - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/provisionRMA?ticketId=CHG12345 + + ## Verb + + - POST + + ## Query Parameters + + - ticket_id: Change control ticket ID (optional) + + ## Usage + + ```python + # Provision RMA + request = EpManageFabricsSwitchProvisionRMAPost() + request.fabric_name = "MyFabric" + request.switch_sn = "SAL1948TRTT" + path = request.path + verb = request.verb + + # Provision RMA with change control ticket + request = EpManageFabricsSwitchProvisionRMAPost() + request.fabric_name = "MyFabric" + request.switch_sn = "SAL1948TRTT" + request.endpoint_params.ticket_id = "CHG12345" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switches/SAL1948TRTT/actions/provisionRMA?ticketId=CHG12345 + ``` + """ + + class_name: Literal["EpManageFabricsSwitchProvisionRMAPost"] = Field( + default="EpManageFabricsSwitchProvisionRMAPost", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: SwitchActionsTicketEndpointParams = Field( + default_factory=SwitchActionsTicketEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def path(self) -> str: + """Build the endpoint path with optional query string.""" + base = f"{self._base_path}/provisionRMA" + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base}?{query_string}" + return base + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +class EpManageFabricsSwitchChangeSerialNumberPost(_EpManageFabricsSwitchActionsPerSwitchBase): + """ + # Summary + + Change Switch Serial Number Endpoint + + ## Description + + Endpoint to change the serial number for a pre-provisioned switch. + + ## Path + + - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/changeSwitchSerialNumber + - /api/v1/manage/fabrics/{fabricName}/switches/{switchSn}/actions/changeSwitchSerialNumber?clusterName=cluster1 + + ## Verb + + - POST + + ## Query Parameters + + - cluster_name: Target cluster name for multi-cluster deployments (optional) + + ## Usage + + ```python + # Change serial number + request = EpManageFabricsSwitchChangeSerialNumberPost() + request.fabric_name = "MyFabric" + request.switch_sn = "SAL1948TRTT" + path = request.path + verb = request.verb + + # Change serial number with cluster name + request = EpManageFabricsSwitchChangeSerialNumberPost() + request.fabric_name = "MyFabric" + request.switch_sn = "SAL1948TRTT" + request.endpoint_params.cluster_name = "cluster1" + path = request.path + verb = request.verb + # Path will be: /api/v1/manage/fabrics/MyFabric/switches/SAL1948TRTT/actions/changeSwitchSerialNumber?clusterName=cluster1 + ``` + """ + + class_name: Literal["EpManageFabricsSwitchChangeSerialNumberPost"] = Field( + default="EpManageFabricsSwitchChangeSerialNumberPost", frozen=True, description="Class name for backward compatibility" + ) + endpoint_params: SwitchActionsClusterEndpointParams = Field( + default_factory=SwitchActionsClusterEndpointParams, description="Endpoint-specific query parameters" + ) + + @property + def path(self) -> str: + """Build the endpoint path with optional query string.""" + base = f"{self._base_path}/changeSwitchSerialNumber" + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base}?{query_string}" + return base + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST diff --git a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py b/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py deleted file mode 100644 index a1498cb6..00000000 --- a/plugins/module_utils/endpoints/v1/manage/nd_manage_switches/fabric_switches.py +++ /dev/null @@ -1,256 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@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 Chengam Saravanan" -# 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 _EpManageFabricSwitchesBase(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 EpManageFabricSwitchesGet(_EpManageFabricSwitchesBase): - """ - # 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 = EpManageFabricSwitchesGet() - request.fabric_name = "MyFabric" - path = request.path - verb = request.verb - - # List with filtering - request = EpManageFabricSwitchesGet() - 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["EpManageFabricSwitchesGet"] = Field( - default="EpManageFabricSwitchesGet", 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 - - -class EpManageFabricSwitchesPost(_EpManageFabricSwitchesBase): - """ - # Summary - - Add Switches to Fabric Endpoint - - ## Description - - Endpoint to add switches to a specific fabric. - - ## Path - - - /api/v1/manage/fabrics/{fabricName}/switches - - /api/v1/manage/fabrics/{fabricName}/switches?clusterName=cluster1&ticketId=CHG12345 - - ## Verb - - - POST - - ## Query Parameters - - - cluster_name: Target cluster name for multi-cluster deployments (optional) - - ticket_id: Change control ticket ID (optional) - - ## Usage - - ```python - # Add switches - request = EpManageFabricSwitchesPost() - request.fabric_name = "MyFabric" - path = request.path - verb = request.verb - - # Add switches with cluster and ticket - request = EpManageFabricSwitchesPost() - request.fabric_name = "MyFabric" - request.endpoint_params.cluster_name = "cluster1" - request.endpoint_params.ticket_id = "CHG12345" - path = request.path - verb = request.verb - # Path will be: /api/v1/manage/fabrics/MyFabric/switches?clusterName=cluster1&ticketId=CHG12345 - ``` - """ - - class_name: Literal["EpManageFabricSwitchesPost"] = Field( - default="EpManageFabricSwitchesPost", frozen=True, description="Class name for backward compatibility" - ) - endpoint_params: FabricSwitchesAddEndpointParams = Field( - default_factory=FabricSwitchesAddEndpointParams, 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 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 00000000..38e667a8 --- /dev/null +++ b/plugins/module_utils/models/manage_switches/__init__.py @@ -0,0 +1,141 @@ +# -*- 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_switches models package. + +Re-exports all model classes, enums, and validators from their individual +modules so that consumers can import directly from the package: + + from .models.nd_manage_switches import SwitchConfigModel, SwitchRole, ... +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +# --- Enums --- +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.enums import ( # noqa: F401 + AdvisoryLevel, + AnomalyLevel, + ConfigSyncStatus, + DiscoveryStatus, + PlatformType, + RemoteCredentialStore, + SnmpV3AuthProtocol, + SwitchRole, + SystemMode, + VpcRole, +) + +# --- Validators --- +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.validators import SwitchValidators # noqa: F401 + +# --- Nested / shared models --- +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.switch_data_models import ( # noqa: F401 + AdditionalAciSwitchData, + AdditionalSwitchData, + Metadata, + SwitchMetadata, + TelemetryIpCollection, + VpcData, +) + +# --- Discovery models --- +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.discovery_models import ( # noqa: F401 + AddSwitchesRequestModel, + ShallowDiscoveryRequestModel, + SwitchDiscoveryModel, +) + +# --- Switch data models --- +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.switch_data_models import ( # noqa: F401 + SwitchDataModel, +) + +# --- Bootstrap models --- +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.bootstrap_models import ( # noqa: F401 + BootstrapBaseData, + BootstrapBaseModel, + BootstrapCredentialModel, + BootstrapImportSpecificModel, + BootstrapImportSwitchModel, + ImportBootstrapSwitchesRequestModel, +) + +# --- Preprovision models --- +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.preprovision_models import ( # noqa: F401 + PreProvisionSwitchesRequestModel, + PreProvisionSwitchModel, +) + +# --- RMA models --- +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.rma_models import ( # noqa: F401 + RMASwitchModel, +) + +# --- Switch actions models --- +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.switch_actions_models import ( # noqa: F401 + ChangeSwitchSerialNumberRequestModel, + SwitchCredentialsRequestModel, +) + +# --- Config / playbook models --- +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.config_models import ( # noqa: F401 + ConfigDataModel, + POAPConfigModel, + RMAConfigModel, + SwitchConfigModel, +) + + +__all__ = [ + # Enums + "AdvisoryLevel", + "AnomalyLevel", + "ConfigSyncStatus", + "DiscoveryStatus", + "PlatformType", + "RemoteCredentialStore", + "SnmpV3AuthProtocol", + "SwitchRole", + "SystemMode", + "VpcRole", + # Validators + "SwitchValidators", + # Nested models + "AdditionalAciSwitchData", + "AdditionalSwitchData", + "Metadata", + "SwitchMetadata", + "TelemetryIpCollection", + "VpcData", + # Discovery models + "AddSwitchesRequestModel", + "ShallowDiscoveryRequestModel", + "SwitchDiscoveryModel", + # Switch data models + "SwitchDataModel", + # Bootstrap models + "BootstrapBaseData", + "BootstrapBaseModel", + "BootstrapCredentialModel", + "BootstrapImportSpecificModel", + "BootstrapImportSwitchModel", + "ImportBootstrapSwitchesRequestModel", + # Preprovision models + "PreProvisionSwitchesRequestModel", + "PreProvisionSwitchModel", + # RMA models + "RMASwitchModel", + # Switch actions models + "ChangeSwitchSerialNumberRequestModel", + "SwitchCredentialsRequestModel", + # Config models + "ConfigDataModel", + "POAPConfigModel", + "RMAConfigModel", + "SwitchConfigModel", +] diff --git a/plugins/module_utils/models/manage_switches/bootstrap_models.py b/plugins/module_utils/models/manage_switches/bootstrap_models.py index 0d72ebed..224d7fa9 100644 --- a/plugins/module_utils/models/manage_switches/bootstrap_models.py +++ b/plugins/module_utils/models/manage_switches/bootstrap_models.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/models/manage_switches/config_models.py b/plugins/module_utils/models/manage_switches/config_models.py index b596ca6f..94336143 100644 --- a/plugins/module_utils/models/manage_switches/config_models.py +++ b/plugins/module_utils/models/manage_switches/config_models.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/models/manage_switches/discovery_models.py b/plugins/module_utils/models/manage_switches/discovery_models.py index dfe190f0..1475edf8 100644 --- a/plugins/module_utils/models/manage_switches/discovery_models.py +++ b/plugins/module_utils/models/manage_switches/discovery_models.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/models/manage_switches/enums.py b/plugins/module_utils/models/manage_switches/enums.py index b88216ad..0d3f85cc 100644 --- a/plugins/module_utils/models/manage_switches/enums.py +++ b/plugins/module_utils/models/manage_switches/enums.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/models/manage_switches/preprovision_models.py b/plugins/module_utils/models/manage_switches/preprovision_models.py index ba073824..4425e486 100644 --- a/plugins/module_utils/models/manage_switches/preprovision_models.py +++ b/plugins/module_utils/models/manage_switches/preprovision_models.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/models/manage_switches/rma_models.py b/plugins/module_utils/models/manage_switches/rma_models.py index 7760d11b..7585d222 100644 --- a/plugins/module_utils/models/manage_switches/rma_models.py +++ b/plugins/module_utils/models/manage_switches/rma_models.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/models/manage_switches/switch_actions_models.py b/plugins/module_utils/models/manage_switches/switch_actions_models.py index 5f903e65..8c1d7bb6 100644 --- a/plugins/module_utils/models/manage_switches/switch_actions_models.py +++ b/plugins/module_utils/models/manage_switches/switch_actions_models.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/models/manage_switches/switch_data_models.py b/plugins/module_utils/models/manage_switches/switch_data_models.py index e4de26cb..9be8b22d 100644 --- a/plugins/module_utils/models/manage_switches/switch_data_models.py +++ b/plugins/module_utils/models/manage_switches/switch_data_models.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/models/manage_switches/validators.py b/plugins/module_utils/models/manage_switches/validators.py index e3ceb3a6..b2e3a704 100644 --- a/plugins/module_utils/models/manage_switches/validators.py +++ b/plugins/module_utils/models/manage_switches/validators.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index 638e870d..5fdb2c47 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) @@ -48,7 +48,7 @@ POAPConfigModel, RMAConfigModel, ) -from ansible_collections.cisco.nd.plugins.module_utils.utils.nd_manage_switches import ( +from ansible_collections.cisco.nd.plugins.module_utils.utils.manage_switches import ( FabricUtils, SwitchWaitUtils, SwitchOperationError, @@ -59,22 +59,22 @@ build_bootstrap_index, build_poap_data_block, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switches import ( - EpManageFabricSwitchesGet, - EpManageFabricSwitchesPost, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches import ( + EpManageFabricsSwitchesGet, + EpManageFabricsSwitchesPost, + EpManageFabricsSwitchProvisionRMAPost, + EpManageFabricsSwitchChangeSerialNumberPost, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_discovery import ( - EpManageFabricShallowDiscoveryPost, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions import ( + EpManageFabricsActionsShallowDiscoveryPost, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switch_actions import ( - EpManageFabricSwitchProvisionRMAPost, - EpManageFabricSwitchActionsImportBootstrapPost, - EpManageFabricSwitchActionsPreProvisionPost, - EpManageFabricSwitchActionsRemovePost, - EpManageFabricSwitchActionsChangeRolesPost, - EpManageFabricSwitchChangeSerialNumberPost, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switchactions import ( + EpManageFabricsSwitchActionsImportBootstrapPost, + EpManageFabricsSwitchActionsPreProvisionPost, + EpManageFabricsSwitchActionsRemovePost, + EpManageFabricsSwitchActionsChangeRolesPost, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.credentials import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_credentials_switches import ( EpManageCredentialsSwitchesPost, ) @@ -562,7 +562,7 @@ def bulk_discover( log.debug("ENTER: bulk_discover()") log.debug(f"Discovering {len(switches)} switches in bulk") - endpoint = EpManageFabricShallowDiscoveryPost() + endpoint = EpManageFabricsActionsShallowDiscoveryPost() endpoint.fabric_name = self.ctx.fabric seed_ips = [switch.seed_ip for switch in switches] @@ -775,7 +775,7 @@ def bulk_add( log.debug("ENTER: bulk_add()") log.debug(f"Adding {len(switches)} switches to fabric") - endpoint = EpManageFabricSwitchesPost() + endpoint = EpManageFabricsSwitchesPost() endpoint.fabric_name = self.ctx.fabric switch_discoveries = [] @@ -903,7 +903,7 @@ def bulk_delete( log.debug("EXIT: bulk_delete() - nothing to delete") return [] - endpoint = EpManageFabricSwitchActionsRemovePost() + endpoint = EpManageFabricsSwitchActionsRemovePost() endpoint.fabric_name = self.ctx.fabric payload = {"switchIds": serial_numbers} @@ -1041,7 +1041,7 @@ def bulk_update_roles( log.debug("EXIT: bulk_update_roles() - no roles to update") return - endpoint = EpManageFabricSwitchActionsChangeRolesPost() + endpoint = EpManageFabricsSwitchActionsChangeRolesPost() endpoint.fabric_name = self.ctx.fabric payload = {"switchRoles": switch_roles} @@ -1542,7 +1542,7 @@ def _import_bootstrap_switches( log.debug("ENTER: _import_bootstrap_switches()") - endpoint = EpManageFabricSwitchActionsImportBootstrapPost() + endpoint = EpManageFabricsSwitchActionsImportBootstrapPost() endpoint.fabric_name = self.ctx.fabric request_model = ImportBootstrapSwitchesRequestModel(switches=models) @@ -1663,7 +1663,7 @@ def _preprovision_switches( log.debug("ENTER: _preprovision_switches()") - endpoint = EpManageFabricSwitchActionsPreProvisionPost() + endpoint = EpManageFabricsSwitchActionsPreProvisionPost() endpoint.fabric_name = self.ctx.fabric request_model = PreProvisionSwitchesRequestModel(switches=models) @@ -1797,7 +1797,7 @@ def _handle_poap_swap( f"{old_serial} → {new_serial}" ) - endpoint = EpManageFabricSwitchChangeSerialNumberPost() + endpoint = EpManageFabricsSwitchChangeSerialNumberPost() endpoint.fabric_name = fabric endpoint.switch_sn = old_serial @@ -2295,7 +2295,7 @@ def _provision_rma_switch( log.debug("ENTER: _provision_rma_switch()") - endpoint = EpManageFabricSwitchProvisionRMAPost() + endpoint = EpManageFabricsSwitchProvisionRMAPost() endpoint.fabric_name = self.ctx.fabric endpoint.switch_sn = old_switch_id @@ -2976,7 +2976,7 @@ def _query_all_switches(self) -> List[Dict[str, Any]]: Returns: List of raw switch dictionaries returned by the controller. """ - endpoint = EpManageFabricSwitchesGet() + endpoint = EpManageFabricsSwitchesGet() endpoint.fabric_name = self.fabric self.log.debug(f"Querying all switches with endpoint: {endpoint.path}") self.log.debug(f"Query verb: {endpoint.verb}") diff --git a/plugins/module_utils/utils/nd_manage_switches/__init__.py b/plugins/module_utils/utils/manage_switches/__init__.py similarity index 71% rename from plugins/module_utils/utils/nd_manage_switches/__init__.py rename to plugins/module_utils/utils/manage_switches/__init__.py index ff3d215b..bb142fe1 100644 --- a/plugins/module_utils/utils/nd_manage_switches/__init__.py +++ b/plugins/module_utils/utils/manage_switches/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2025, Akshayant Chengam Saravanan (@achengam) +# 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) @@ -9,19 +9,13 @@ Re-exports all utility classes, functions, and exceptions so that consumers can import directly from the package: - from .utils.nd_manage_switches import ( - SwitchOperationError, PayloadUtils, FabricUtils, SwitchWaitUtils, - mask_password, get_switch_field, determine_operation_type, - group_switches_by_credentials, query_bootstrap_switches, - build_bootstrap_index, build_poap_data_block, - ) """ from __future__ import absolute_import, division, print_function __metaclass__ = type -from .exceptions import SwitchOperationError # noqa: F401 +from ansible_collections.cisco.nd.plugins.module_utils.utils.manage_switches.exceptions import SwitchOperationError # noqa: F401 from .payload_utils import PayloadUtils, mask_password # noqa: F401 from .fabric_utils import FabricUtils # noqa: F401 from .switch_wait_utils import SwitchWaitUtils # noqa: F401 diff --git a/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py b/plugins/module_utils/utils/manage_switches/bootstrap_utils.py similarity index 92% rename from plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py rename to plugins/module_utils/utils/manage_switches/bootstrap_utils.py index b3e58c57..d78d2531 100644 --- a/plugins/module_utils/utils/nd_manage_switches/bootstrap_utils.py +++ b/plugins/module_utils/utils/manage_switches/bootstrap_utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) @@ -14,8 +14,8 @@ import logging from typing import Any, Dict, List, Optional -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_bootstrap import ( - EpManageFabricBootstrapGet, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_bootstrap import ( + EpManageFabricsBootstrapGet, ) @@ -36,7 +36,7 @@ def query_bootstrap_switches( """ log.debug("ENTER: query_bootstrap_switches()") - endpoint = EpManageFabricBootstrapGet() + endpoint = EpManageFabricsBootstrapGet() endpoint.fabric_name = fabric log.debug(f"Bootstrap endpoint: {endpoint.path}") diff --git a/plugins/module_utils/utils/nd_manage_switches/exceptions.py b/plugins/module_utils/utils/manage_switches/exceptions.py similarity index 82% rename from plugins/module_utils/utils/nd_manage_switches/exceptions.py rename to plugins/module_utils/utils/manage_switches/exceptions.py index 09d7ebb5..8e5b0055 100644 --- a/plugins/module_utils/utils/nd_manage_switches/exceptions.py +++ b/plugins/module_utils/utils/manage_switches/exceptions.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py b/plugins/module_utils/utils/manage_switches/fabric_utils.py similarity index 94% rename from plugins/module_utils/utils/nd_manage_switches/fabric_utils.py rename to plugins/module_utils/utils/manage_switches/fabric_utils.py index 244f2b46..ab4557da 100644 --- a/plugins/module_utils/utils/nd_manage_switches/fabric_utils.py +++ b/plugins/module_utils/utils/manage_switches/fabric_utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) @@ -14,11 +14,13 @@ import time from typing import Any, Dict, Optional -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_config import ( +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics import ( EpManageFabricConfigDeployPost, - EpManageFabricConfigSavePost, EpManageFabricGet, ) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions import ( + EpManageFabricsActionsConfigSavePost, +) from .exceptions import SwitchOperationError @@ -44,7 +46,7 @@ def __init__( self.log = logger or logging.getLogger("nd.FabricUtils") # Pre-configure endpoints - self.ep_config_save = EpManageFabricConfigSavePost() + self.ep_config_save = EpManageFabricsActionsConfigSavePost() self.ep_config_save.fabric_name = fabric self.ep_config_deploy = EpManageFabricConfigDeployPost() diff --git a/plugins/module_utils/utils/nd_manage_switches/payload_utils.py b/plugins/module_utils/utils/manage_switches/payload_utils.py similarity index 96% rename from plugins/module_utils/utils/nd_manage_switches/payload_utils.py rename to plugins/module_utils/utils/manage_switches/payload_utils.py index effadfb8..84e99b99 100644 --- a/plugins/module_utils/utils/nd_manage_switches/payload_utils.py +++ b/plugins/module_utils/utils/manage_switches/payload_utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/utils/nd_manage_switches/switch_helpers.py b/plugins/module_utils/utils/manage_switches/switch_helpers.py similarity index 97% rename from plugins/module_utils/utils/nd_manage_switches/switch_helpers.py rename to plugins/module_utils/utils/manage_switches/switch_helpers.py index bffb2bdb..55f71ba9 100644 --- a/plugins/module_utils/utils/nd_manage_switches/switch_helpers.py +++ b/plugins/module_utils/utils/manage_switches/switch_helpers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) diff --git a/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py b/plugins/module_utils/utils/manage_switches/switch_wait_utils.py similarity index 97% rename from plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py rename to plugins/module_utils/utils/manage_switches/switch_wait_utils.py index dda4c712..2d6e281d 100644 --- a/plugins/module_utils/utils/nd_manage_switches/switch_wait_utils.py +++ b/plugins/module_utils/utils/manage_switches/switch_wait_utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) @@ -14,14 +14,14 @@ import time from typing import Any, Dict, List, Optional -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_config import ( - EpManageFabricInventoryDiscoverGet, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_inventory import ( + EpManageFabricsInventoryDiscoverGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switches import ( - EpManageFabricSwitchesGet, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches import ( + EpManageFabricsSwitchesGet, ) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.nd_manage_switches.fabric_switch_actions import ( - EpManageFabricSwitchActionsRediscoverPost, +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switchactions import ( + EpManageFabricsSwitchActionsRediscoverPost, ) from .fabric_utils import FabricUtils @@ -94,13 +94,13 @@ def __init__( ) # Pre-configure endpoints - self.ep_switches_get = EpManageFabricSwitchesGet() + self.ep_switches_get = EpManageFabricsSwitchesGet() self.ep_switches_get.fabric_name = fabric - self.ep_inventory_discover = EpManageFabricInventoryDiscoverGet() + self.ep_inventory_discover = EpManageFabricsInventoryDiscoverGet() self.ep_inventory_discover.fabric_name = fabric - self.ep_rediscover = EpManageFabricSwitchActionsRediscoverPost() + self.ep_rediscover = EpManageFabricsSwitchActionsRediscoverPost() self.ep_rediscover.fabric_name = fabric # Cached greenfield flag diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index df0a53d7..9e1ea604 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -1,21 +1,21 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# Copyright: (c) 2026, Akshayanat Chengam Saravanan (@achengam) +# 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) from __future__ import absolute_import, division, print_function __metaclass__ = type __copyright__ = "Copyright (c) 2026 Cisco and/or its affiliates." -__author__ = "Akshayanat Chengam Saravanan" +__author__ = "Akshayanat C S" DOCUMENTATION = """ --- module: nd_manage_switches short_description: Manage switches in Cisco Nexus Dashboard (ND). version_added: "1.0.0" -author: Akshayanat Chengam Saravanan (@achengam) +author: Akshayanat C S (@achengam) description: - Add, delete, and override switches in Cisco Nexus Dashboard. - Supports normal discovery, POAP (bootstrap/preprovision), and RMA operations. diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_credentials_switches.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_credentials_switches.py new file mode 100644 index 00000000..a3a088b2 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_credentials_switches.py @@ -0,0 +1,177 @@ +# 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) + +""" +Unit tests for manage_credentials_switches.py + +Tests the ND Manage Credentials Switches endpoint classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest # pylint: disable=unused-import +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_credentials_switches import ( + CredentialsSwitchesEndpointParams, + EpManageCredentialsSwitchesPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: CredentialsSwitchesEndpointParams +# ============================================================================= + + +def test_endpoints_api_v1_manage_credentials_switches_00010(): + """ + # Summary + + Verify CredentialsSwitchesEndpointParams default values + + ## Test + + - ticket_id defaults to None + + ## Classes and Methods + + - CredentialsSwitchesEndpointParams.__init__() + """ + with does_not_raise(): + params = CredentialsSwitchesEndpointParams() + assert params.ticket_id is None + + +def test_endpoints_api_v1_manage_credentials_switches_00020(): + """ + # Summary + + Verify CredentialsSwitchesEndpointParams ticket_id can be set + + ## Test + + - ticket_id can be set to a string value + + ## Classes and Methods + + - CredentialsSwitchesEndpointParams.__init__() + """ + with does_not_raise(): + params = CredentialsSwitchesEndpointParams(ticket_id="CHG12345") + assert params.ticket_id == "CHG12345" + + +def test_endpoints_api_v1_manage_credentials_switches_00030(): + """ + # Summary + + Verify CredentialsSwitchesEndpointParams generates correct query string + + ## Test + + - to_query_string() returns ticketId=CHG12345 when ticket_id is set + + ## Classes and Methods + + - CredentialsSwitchesEndpointParams.to_query_string() + """ + with does_not_raise(): + params = CredentialsSwitchesEndpointParams(ticket_id="CHG12345") + result = params.to_query_string() + assert result == "ticketId=CHG12345" + + +def test_endpoints_api_v1_manage_credentials_switches_00040(): + """ + # Summary + + Verify CredentialsSwitchesEndpointParams returns empty query string when no params set + + ## Test + + - to_query_string() returns empty string when ticket_id is not set + + ## Classes and Methods + + - CredentialsSwitchesEndpointParams.to_query_string() + """ + with does_not_raise(): + params = CredentialsSwitchesEndpointParams() + result = params.to_query_string() + assert result == "" + + +# ============================================================================= +# Test: EpManageCredentialsSwitchesPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_credentials_switches_00100(): + """ + # Summary + + Verify EpManageCredentialsSwitchesPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageCredentialsSwitchesPost.__init__() + - EpManageCredentialsSwitchesPost.class_name + - EpManageCredentialsSwitchesPost.verb + """ + with does_not_raise(): + instance = EpManageCredentialsSwitchesPost() + assert instance.class_name == "EpManageCredentialsSwitchesPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_credentials_switches_00110(): + """ + # Summary + + Verify EpManageCredentialsSwitchesPost path without query params + + ## Test + + - path returns the correct base endpoint path + + ## Classes and Methods + + - EpManageCredentialsSwitchesPost.path + """ + with does_not_raise(): + instance = EpManageCredentialsSwitchesPost() + result = instance.path + assert result == "/api/v1/manage/credentials/switches" + + +def test_endpoints_api_v1_manage_credentials_switches_00120(): + """ + # Summary + + Verify EpManageCredentialsSwitchesPost path with ticket_id + + ## Test + + - path includes ticketId in query string when set + + ## Classes and Methods + + - EpManageCredentialsSwitchesPost.path + """ + with does_not_raise(): + instance = EpManageCredentialsSwitchesPost() + instance.endpoint_params.ticket_id = "CHG12345" + result = instance.path + assert result == "/api/v1/manage/credentials/switches?ticketId=CHG12345" diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics.py new file mode 100644 index 00000000..b0ed3f95 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics.py @@ -0,0 +1,271 @@ +# 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) + +""" +Unit tests for manage_fabrics.py + +Tests the ND Manage Fabrics endpoint classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics import ( + EpManageFabricConfigDeployPost, + EpManageFabricGet, + FabricConfigDeployEndpointParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: FabricConfigDeployEndpointParams +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_00010(): + """ + # Summary + + Verify FabricConfigDeployEndpointParams default values + + ## Test + + - force_show_run defaults to None + - incl_all_msd_switches defaults to None + + ## Classes and Methods + + - FabricConfigDeployEndpointParams.__init__() + """ + with does_not_raise(): + params = FabricConfigDeployEndpointParams() + assert params.force_show_run is None + assert params.incl_all_msd_switches is None + + +def test_endpoints_api_v1_manage_fabrics_00020(): + """ + # Summary + + Verify FabricConfigDeployEndpointParams force_show_run can be set + + ## Test + + - force_show_run can be set to True + + ## Classes and Methods + + - FabricConfigDeployEndpointParams.__init__() + """ + with does_not_raise(): + params = FabricConfigDeployEndpointParams(force_show_run=True) + assert params.force_show_run is True + + +def test_endpoints_api_v1_manage_fabrics_00030(): + """ + # Summary + + Verify FabricConfigDeployEndpointParams generates query string with both params + + ## Test + + - to_query_string() includes forceShowRun and inclAllMsdSwitches when both are set + + ## Classes and Methods + + - FabricConfigDeployEndpointParams.to_query_string() + """ + with does_not_raise(): + params = FabricConfigDeployEndpointParams(force_show_run=True, incl_all_msd_switches=True) + result = params.to_query_string() + assert "forceShowRun=true" in result + assert "inclAllMsdSwitches=true" in result + + +def test_endpoints_api_v1_manage_fabrics_00040(): + """ + # Summary + + Verify FabricConfigDeployEndpointParams returns empty query string when no params set + + ## Test + + - to_query_string() returns empty string when no params set + + ## Classes and Methods + + - FabricConfigDeployEndpointParams.to_query_string() + """ + with does_not_raise(): + params = FabricConfigDeployEndpointParams() + result = params.to_query_string() + assert result == "" + + +# ============================================================================= +# Test: EpManageFabricConfigDeployPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_00100(): + """ + # Summary + + Verify EpManageFabricConfigDeployPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricConfigDeployPost.__init__() + - EpManageFabricConfigDeployPost.class_name + - EpManageFabricConfigDeployPost.verb + """ + with does_not_raise(): + instance = EpManageFabricConfigDeployPost() + assert instance.class_name == "EpManageFabricConfigDeployPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_00110(): + """ + # Summary + + Verify EpManageFabricConfigDeployPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricConfigDeployPost.path + """ + instance = EpManageFabricConfigDeployPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_00120(): + """ + # Summary + + Verify EpManageFabricConfigDeployPost path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricConfigDeployPost.path + """ + with does_not_raise(): + instance = EpManageFabricConfigDeployPost() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/actions/configDeploy" + + +def test_endpoints_api_v1_manage_fabrics_00130(): + """ + # Summary + + Verify EpManageFabricConfigDeployPost path with force_show_run + + ## Test + + - path includes forceShowRun in query string when set to True + + ## Classes and Methods + + - EpManageFabricConfigDeployPost.path + """ + with does_not_raise(): + instance = EpManageFabricConfigDeployPost() + instance.fabric_name = "MyFabric" + instance.endpoint_params.force_show_run = True + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/actions/configDeploy?forceShowRun=true" + + +# ============================================================================= +# Test: EpManageFabricGet +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_00200(): + """ + # Summary + + Verify EpManageFabricGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpManageFabricGet.__init__() + - EpManageFabricGet.class_name + - EpManageFabricGet.verb + """ + with does_not_raise(): + instance = EpManageFabricGet() + assert instance.class_name == "EpManageFabricGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_manage_fabrics_00210(): + """ + # Summary + + Verify EpManageFabricGet raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricGet.path + """ + instance = EpManageFabricGet() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_00220(): + """ + # Summary + + Verify EpManageFabricGet path + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricGet.path + """ + with does_not_raise(): + instance = EpManageFabricGet() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric" diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_actions.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_actions.py new file mode 100644 index 00000000..263b9f0c --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_actions.py @@ -0,0 +1,162 @@ +# 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) + +""" +Unit tests for manage_fabrics_actions.py + +Tests the ND Manage Fabrics Actions endpoint classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions import ( + EpManageFabricsActionsConfigSavePost, + EpManageFabricsActionsShallowDiscoveryPost, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: EpManageFabricsActionsShallowDiscoveryPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_actions_00100(): + """ + # Summary + + Verify EpManageFabricsActionsShallowDiscoveryPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricsActionsShallowDiscoveryPost.__init__() + - EpManageFabricsActionsShallowDiscoveryPost.class_name + - EpManageFabricsActionsShallowDiscoveryPost.verb + """ + with does_not_raise(): + instance = EpManageFabricsActionsShallowDiscoveryPost() + assert instance.class_name == "EpManageFabricsActionsShallowDiscoveryPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_actions_00110(): + """ + # Summary + + Verify EpManageFabricsActionsShallowDiscoveryPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsActionsShallowDiscoveryPost.path + """ + instance = EpManageFabricsActionsShallowDiscoveryPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_actions_00120(): + """ + # Summary + + Verify EpManageFabricsActionsShallowDiscoveryPost path + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsActionsShallowDiscoveryPost.path + """ + with does_not_raise(): + instance = EpManageFabricsActionsShallowDiscoveryPost() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/actions/shallowDiscovery" + + +# ============================================================================= +# Test: EpManageFabricsActionsConfigSavePost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_actions_00200(): + """ + # Summary + + Verify EpManageFabricsActionsConfigSavePost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricsActionsConfigSavePost.__init__() + - EpManageFabricsActionsConfigSavePost.class_name + - EpManageFabricsActionsConfigSavePost.verb + """ + with does_not_raise(): + instance = EpManageFabricsActionsConfigSavePost() + assert instance.class_name == "EpManageFabricsActionsConfigSavePost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_actions_00210(): + """ + # Summary + + Verify EpManageFabricsActionsConfigSavePost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsActionsConfigSavePost.path + """ + instance = EpManageFabricsActionsConfigSavePost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_actions_00220(): + """ + # Summary + + Verify EpManageFabricsActionsConfigSavePost path + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsActionsConfigSavePost.path + """ + with does_not_raise(): + instance = EpManageFabricsActionsConfigSavePost() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/actions/configSave" diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_bootstrap.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_bootstrap.py new file mode 100644 index 00000000..bf5f6c68 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_bootstrap.py @@ -0,0 +1,206 @@ +# 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) + +""" +Unit tests for manage_fabrics_bootstrap.py + +Tests the ND Manage Fabrics Bootstrap endpoint classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_bootstrap import ( + EpManageFabricsBootstrapGet, + FabricsBootstrapEndpointParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: FabricsBootstrapEndpointParams +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_bootstrap_00010(): + """ + # Summary + + Verify FabricsBootstrapEndpointParams default values + + ## Test + + - max defaults to None + - offset defaults to None + - filter defaults to None + + ## Classes and Methods + + - FabricsBootstrapEndpointParams.__init__() + """ + with does_not_raise(): + params = FabricsBootstrapEndpointParams() + assert params.max is None + assert params.offset is None + assert params.filter is None + + +def test_endpoints_api_v1_manage_fabrics_bootstrap_00020(): + """ + # Summary + + Verify FabricsBootstrapEndpointParams max can be set + + ## Test + + - max can be set to an integer value + + ## Classes and Methods + + - FabricsBootstrapEndpointParams.__init__() + """ + with does_not_raise(): + params = FabricsBootstrapEndpointParams(max=50) + assert params.max == 50 + + +def test_endpoints_api_v1_manage_fabrics_bootstrap_00030(): + """ + # Summary + + Verify FabricsBootstrapEndpointParams generates query string with pagination + + ## Test + + - to_query_string() returns correct format with max and offset + + ## Classes and Methods + + - FabricsBootstrapEndpointParams.to_query_string() + """ + with does_not_raise(): + params = FabricsBootstrapEndpointParams(max=50, offset=0) + result = params.to_query_string() + assert "max=50" in result + assert "offset=0" in result + + +def test_endpoints_api_v1_manage_fabrics_bootstrap_00040(): + """ + # Summary + + Verify FabricsBootstrapEndpointParams returns empty query string when no params set + + ## Test + + - to_query_string() returns empty string when no params set + + ## Classes and Methods + + - FabricsBootstrapEndpointParams.to_query_string() + """ + with does_not_raise(): + params = FabricsBootstrapEndpointParams() + result = params.to_query_string() + assert result == "" + + +# ============================================================================= +# Test: EpManageFabricsBootstrapGet +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_bootstrap_00100(): + """ + # Summary + + Verify EpManageFabricsBootstrapGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpManageFabricsBootstrapGet.__init__() + - EpManageFabricsBootstrapGet.class_name + - EpManageFabricsBootstrapGet.verb + """ + with does_not_raise(): + instance = EpManageFabricsBootstrapGet() + assert instance.class_name == "EpManageFabricsBootstrapGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_manage_fabrics_bootstrap_00110(): + """ + # Summary + + Verify EpManageFabricsBootstrapGet raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsBootstrapGet.path + """ + instance = EpManageFabricsBootstrapGet() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_bootstrap_00120(): + """ + # Summary + + Verify EpManageFabricsBootstrapGet path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsBootstrapGet.path + """ + with does_not_raise(): + instance = EpManageFabricsBootstrapGet() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/bootstrap" + + +def test_endpoints_api_v1_manage_fabrics_bootstrap_00130(): + """ + # Summary + + Verify EpManageFabricsBootstrapGet path with pagination params + + ## Test + + - path includes max and offset in query string when set + + ## Classes and Methods + + - EpManageFabricsBootstrapGet.path + """ + with does_not_raise(): + instance = EpManageFabricsBootstrapGet() + instance.fabric_name = "MyFabric" + instance.endpoint_params.max = 50 + instance.endpoint_params.offset = 0 + result = instance.path + assert result.startswith("/api/v1/manage/fabrics/MyFabric/bootstrap?") + assert "max=50" in result + assert "offset=0" in result diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_inventory.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_inventory.py new file mode 100644 index 00000000..d53488ea --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_inventory.py @@ -0,0 +1,92 @@ +# 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) + +""" +Unit tests for manage_fabrics_inventory.py + +Tests the ND Manage Fabrics Inventory endpoint classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_inventory import ( + EpManageFabricsInventoryDiscoverGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: EpManageFabricsInventoryDiscoverGet +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_inventory_00010(): + """ + # Summary + + Verify EpManageFabricsInventoryDiscoverGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpManageFabricsInventoryDiscoverGet.__init__() + - EpManageFabricsInventoryDiscoverGet.class_name + - EpManageFabricsInventoryDiscoverGet.verb + """ + with does_not_raise(): + instance = EpManageFabricsInventoryDiscoverGet() + assert instance.class_name == "EpManageFabricsInventoryDiscoverGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_manage_fabrics_inventory_00020(): + """ + # Summary + + Verify EpManageFabricsInventoryDiscoverGet raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsInventoryDiscoverGet.path + """ + instance = EpManageFabricsInventoryDiscoverGet() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_inventory_00030(): + """ + # Summary + + Verify EpManageFabricsInventoryDiscoverGet path + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsInventoryDiscoverGet.path + """ + with does_not_raise(): + instance = EpManageFabricsInventoryDiscoverGet() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/inventory/discover" diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_switchactions.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_switchactions.py new file mode 100644 index 00000000..0ce1af96 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_switchactions.py @@ -0,0 +1,491 @@ +# 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) + +""" +Unit tests for manage_fabrics_switchactions.py + +Tests the ND Manage Fabrics Switch Actions endpoint classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switchactions import ( + EpManageFabricsSwitchActionsChangeRolesPost, + EpManageFabricsSwitchActionsImportBootstrapPost, + EpManageFabricsSwitchActionsPreProvisionPost, + EpManageFabricsSwitchActionsRediscoverPost, + EpManageFabricsSwitchActionsRemovePost, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: EpManageFabricsSwitchActionsRemovePost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00100(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsRemovePost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricsSwitchActionsRemovePost.__init__() + - EpManageFabricsSwitchActionsRemovePost.class_name + - EpManageFabricsSwitchActionsRemovePost.verb + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsRemovePost() + assert instance.class_name == "EpManageFabricsSwitchActionsRemovePost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00110(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsRemovePost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsSwitchActionsRemovePost.path + """ + instance = EpManageFabricsSwitchActionsRemovePost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00120(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsRemovePost path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsSwitchActionsRemovePost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsRemovePost() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switchActions/remove" + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00130(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsRemovePost path with force and ticket_id + + ## Test + + - path includes force and ticketId in query string when set + + ## Classes and Methods + + - EpManageFabricsSwitchActionsRemovePost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsRemovePost() + instance.fabric_name = "MyFabric" + instance.endpoint_params.force = True + instance.endpoint_params.ticket_id = "CHG12345" + result = instance.path + assert result.startswith("/api/v1/manage/fabrics/MyFabric/switchActions/remove?") + assert "force=true" in result + assert "ticketId=CHG12345" in result + + +# ============================================================================= +# Test: EpManageFabricsSwitchActionsChangeRolesPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00200(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsChangeRolesPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricsSwitchActionsChangeRolesPost.__init__() + - EpManageFabricsSwitchActionsChangeRolesPost.class_name + - EpManageFabricsSwitchActionsChangeRolesPost.verb + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsChangeRolesPost() + assert instance.class_name == "EpManageFabricsSwitchActionsChangeRolesPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00210(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsChangeRolesPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsSwitchActionsChangeRolesPost.path + """ + instance = EpManageFabricsSwitchActionsChangeRolesPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00220(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsChangeRolesPost path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsSwitchActionsChangeRolesPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsChangeRolesPost() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switchActions/changeRoles" + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00230(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsChangeRolesPost path with ticket_id + + ## Test + + - path includes ticketId in query string when set + + ## Classes and Methods + + - EpManageFabricsSwitchActionsChangeRolesPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsChangeRolesPost() + instance.fabric_name = "MyFabric" + instance.endpoint_params.ticket_id = "CHG12345" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switchActions/changeRoles?ticketId=CHG12345" + + +# ============================================================================= +# Test: EpManageFabricsSwitchActionsImportBootstrapPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00300(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsImportBootstrapPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricsSwitchActionsImportBootstrapPost.__init__() + - EpManageFabricsSwitchActionsImportBootstrapPost.class_name + - EpManageFabricsSwitchActionsImportBootstrapPost.verb + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsImportBootstrapPost() + assert instance.class_name == "EpManageFabricsSwitchActionsImportBootstrapPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00310(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsImportBootstrapPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsSwitchActionsImportBootstrapPost.path + """ + instance = EpManageFabricsSwitchActionsImportBootstrapPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00320(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsImportBootstrapPost path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsSwitchActionsImportBootstrapPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsImportBootstrapPost() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switchActions/importBootstrap" + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00330(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsImportBootstrapPost path with cluster_name and ticket_id + + ## Test + + - path includes clusterName and ticketId in query string when set + + ## Classes and Methods + + - EpManageFabricsSwitchActionsImportBootstrapPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsImportBootstrapPost() + instance.fabric_name = "MyFabric" + instance.endpoint_params.cluster_name = "cluster1" + instance.endpoint_params.ticket_id = "CHG12345" + result = instance.path + assert result.startswith("/api/v1/manage/fabrics/MyFabric/switchActions/importBootstrap?") + assert "clusterName=cluster1" in result + assert "ticketId=CHG12345" in result + + +# ============================================================================= +# Test: EpManageFabricsSwitchActionsPreProvisionPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00400(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsPreProvisionPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricsSwitchActionsPreProvisionPost.__init__() + - EpManageFabricsSwitchActionsPreProvisionPost.class_name + - EpManageFabricsSwitchActionsPreProvisionPost.verb + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsPreProvisionPost() + assert instance.class_name == "EpManageFabricsSwitchActionsPreProvisionPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00410(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsPreProvisionPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsSwitchActionsPreProvisionPost.path + """ + instance = EpManageFabricsSwitchActionsPreProvisionPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00420(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsPreProvisionPost path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsSwitchActionsPreProvisionPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsPreProvisionPost() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switchActions/preProvision" + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00430(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsPreProvisionPost path with cluster_name and ticket_id + + ## Test + + - path includes clusterName and ticketId in query string when set + + ## Classes and Methods + + - EpManageFabricsSwitchActionsPreProvisionPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsPreProvisionPost() + instance.fabric_name = "MyFabric" + instance.endpoint_params.cluster_name = "cluster1" + instance.endpoint_params.ticket_id = "CHG12345" + result = instance.path + assert result.startswith("/api/v1/manage/fabrics/MyFabric/switchActions/preProvision?") + assert "clusterName=cluster1" in result + assert "ticketId=CHG12345" in result + + +# ============================================================================= +# Test: EpManageFabricsSwitchActionsRediscoverPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00700(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsRediscoverPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricsSwitchActionsRediscoverPost.__init__() + - EpManageFabricsSwitchActionsRediscoverPost.class_name + - EpManageFabricsSwitchActionsRediscoverPost.verb + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsRediscoverPost() + assert instance.class_name == "EpManageFabricsSwitchActionsRediscoverPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00710(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsRediscoverPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsSwitchActionsRediscoverPost.path + """ + instance = EpManageFabricsSwitchActionsRediscoverPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00720(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsRediscoverPost path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsSwitchActionsRediscoverPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsRediscoverPost() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switchActions/rediscover" + + +def test_endpoints_api_v1_manage_fabrics_switchactions_00730(): + """ + # Summary + + Verify EpManageFabricsSwitchActionsRediscoverPost path with ticket_id + + ## Test + + - path includes ticketId in query string when set + + ## Classes and Methods + + - EpManageFabricsSwitchActionsRediscoverPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchActionsRediscoverPost() + instance.fabric_name = "MyFabric" + instance.endpoint_params.ticket_id = "CHG12345" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switchActions/rediscover?ticketId=CHG12345" diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_switches.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_switches.py new file mode 100644 index 00000000..a5d7217f --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabrics_switches.py @@ -0,0 +1,614 @@ +# 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) + +""" +Unit tests for manage_fabrics_switches.py + +Tests the ND Manage Fabrics Switches endpoint classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switches import ( + EpManageFabricsSwitchesGet, + EpManageFabricsSwitchesPost, + EpManageFabricsSwitchChangeSerialNumberPost, + EpManageFabricsSwitchProvisionRMAPost, + FabricSwitchesAddEndpointParams, + FabricSwitchesGetEndpointParams, + SwitchActionsClusterEndpointParams, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import ( + does_not_raise, +) + +# ============================================================================= +# Test: FabricSwitchesGetEndpointParams +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switches_00010(): + """ + # Summary + + Verify FabricSwitchesGetEndpointParams default values + + ## Test + + - hostname defaults to None + - max defaults to None + - offset defaults to None + + ## Classes and Methods + + - FabricSwitchesGetEndpointParams.__init__() + """ + with does_not_raise(): + params = FabricSwitchesGetEndpointParams() + assert params.hostname is None + assert params.max is None + assert params.offset is None + + +def test_endpoints_api_v1_manage_fabrics_switches_00020(): + """ + # Summary + + Verify FabricSwitchesGetEndpointParams hostname can be set + + ## Test + + - hostname can be set to a string value + + ## Classes and Methods + + - FabricSwitchesGetEndpointParams.__init__() + """ + with does_not_raise(): + params = FabricSwitchesGetEndpointParams(hostname="leaf1") + assert params.hostname == "leaf1" + + +def test_endpoints_api_v1_manage_fabrics_switches_00030(): + """ + # Summary + + Verify FabricSwitchesGetEndpointParams generates query string with hostname and max + + ## Test + + - to_query_string() includes hostname and max when both are set + + ## Classes and Methods + + - FabricSwitchesGetEndpointParams.to_query_string() + """ + with does_not_raise(): + params = FabricSwitchesGetEndpointParams(hostname="leaf1", max=100) + result = params.to_query_string() + assert "hostname=leaf1" in result + assert "max=100" in result + + +def test_endpoints_api_v1_manage_fabrics_switches_00040(): + """ + # Summary + + Verify FabricSwitchesAddEndpointParams default values + + ## Test + + - cluster_name defaults to None + - ticket_id defaults to None + + ## Classes and Methods + + - FabricSwitchesAddEndpointParams.__init__() + """ + with does_not_raise(): + params = FabricSwitchesAddEndpointParams() + assert params.cluster_name is None + assert params.ticket_id is None + + +def test_endpoints_api_v1_manage_fabrics_switches_00050(): + """ + # Summary + + Verify FabricSwitchesAddEndpointParams generates query string with both params + + ## Test + + - to_query_string() includes clusterName and ticketId when both are set + + ## Classes and Methods + + - FabricSwitchesAddEndpointParams.to_query_string() + """ + with does_not_raise(): + params = FabricSwitchesAddEndpointParams(cluster_name="cluster1", ticket_id="CHG12345") + result = params.to_query_string() + assert "clusterName=cluster1" in result + assert "ticketId=CHG12345" in result + + +# ============================================================================= +# Test: EpManageFabricsSwitchesGet +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switches_00100(): + """ + # Summary + + Verify EpManageFabricsSwitchesGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + + ## Classes and Methods + + - EpManageFabricsSwitchesGet.__init__() + - EpManageFabricsSwitchesGet.class_name + - EpManageFabricsSwitchesGet.verb + """ + with does_not_raise(): + instance = EpManageFabricsSwitchesGet() + assert instance.class_name == "EpManageFabricsSwitchesGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_manage_fabrics_switches_00110(): + """ + # Summary + + Verify EpManageFabricsSwitchesGet raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsSwitchesGet.path + """ + instance = EpManageFabricsSwitchesGet() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switches_00120(): + """ + # Summary + + Verify EpManageFabricsSwitchesGet path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsSwitchesGet.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchesGet() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switches" + + +def test_endpoints_api_v1_manage_fabrics_switches_00130(): + """ + # Summary + + Verify EpManageFabricsSwitchesGet path with hostname filter + + ## Test + + - path includes hostname in query string when set + + ## Classes and Methods + + - EpManageFabricsSwitchesGet.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchesGet() + instance.fabric_name = "MyFabric" + instance.endpoint_params.hostname = "leaf1" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switches?hostname=leaf1" + + +# ============================================================================= +# Test: EpManageFabricsSwitchesPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switches_00200(): + """ + # Summary + + Verify EpManageFabricsSwitchesPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricsSwitchesPost.__init__() + - EpManageFabricsSwitchesPost.class_name + - EpManageFabricsSwitchesPost.verb + """ + with does_not_raise(): + instance = EpManageFabricsSwitchesPost() + assert instance.class_name == "EpManageFabricsSwitchesPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_switches_00210(): + """ + # Summary + + Verify EpManageFabricsSwitchesPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsSwitchesPost.path + """ + instance = EpManageFabricsSwitchesPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switches_00220(): + """ + # Summary + + Verify EpManageFabricsSwitchesPost path without query params + + ## Test + + - path returns correct endpoint path + + ## Classes and Methods + + - EpManageFabricsSwitchesPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchesPost() + instance.fabric_name = "MyFabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switches" + + +def test_endpoints_api_v1_manage_fabrics_switches_00230(): + """ + # Summary + + Verify EpManageFabricsSwitchesPost path with cluster_name and ticket_id + + ## Test + + - path includes clusterName and ticketId in query string when set + + ## Classes and Methods + + - EpManageFabricsSwitchesPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchesPost() + instance.fabric_name = "MyFabric" + instance.endpoint_params.cluster_name = "cluster1" + instance.endpoint_params.ticket_id = "CHG12345" + result = instance.path + assert result.startswith("/api/v1/manage/fabrics/MyFabric/switches?") + assert "clusterName=cluster1" in result + assert "ticketId=CHG12345" in result + + +# ============================================================================= +# Test: SwitchActionsClusterEndpointParams +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switches_00300(): + """ + # Summary + + Verify SwitchActionsClusterEndpointParams basic instantiation + + ## Test + + - Instance can be created with defaults + - cluster_name defaults to None + + ## Classes and Methods + + - SwitchActionsClusterEndpointParams.__init__() + """ + with does_not_raise(): + instance = SwitchActionsClusterEndpointParams() + assert instance.cluster_name is None + + +def test_endpoints_api_v1_manage_fabrics_switches_00310(): + """ + # Summary + + Verify SwitchActionsClusterEndpointParams to_query_string returns empty when no params set + + ## Test + + - to_query_string() returns empty string when cluster_name is None + + ## Classes and Methods + + - SwitchActionsClusterEndpointParams.to_query_string() + """ + instance = SwitchActionsClusterEndpointParams() + assert instance.to_query_string() == "" + + +def test_endpoints_api_v1_manage_fabrics_switches_00320(): + """ + # Summary + + Verify SwitchActionsClusterEndpointParams to_query_string with cluster_name + + ## Test + + - to_query_string() returns "clusterName=cluster1" when cluster_name is set + + ## Classes and Methods + + - SwitchActionsClusterEndpointParams.to_query_string() + """ + instance = SwitchActionsClusterEndpointParams(cluster_name="cluster1") + assert instance.to_query_string() == "clusterName=cluster1" + + +# ============================================================================= +# Test: EpManageFabricsSwitchProvisionRMAPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switches_00500(): + """ + # Summary + + Verify EpManageFabricsSwitchProvisionRMAPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricsSwitchProvisionRMAPost.__init__() + - EpManageFabricsSwitchProvisionRMAPost.class_name + - EpManageFabricsSwitchProvisionRMAPost.verb + """ + with does_not_raise(): + instance = EpManageFabricsSwitchProvisionRMAPost() + assert instance.class_name == "EpManageFabricsSwitchProvisionRMAPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_switches_00510(): + """ + # Summary + + Verify EpManageFabricsSwitchProvisionRMAPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsSwitchProvisionRMAPost.path + """ + instance = EpManageFabricsSwitchProvisionRMAPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switches_00520(): + """ + # Summary + + Verify EpManageFabricsSwitchProvisionRMAPost raises ValueError when switch_sn is not set + + ## Test + + - Accessing path raises ValueError when switch_sn is None + + ## Classes and Methods + + - EpManageFabricsSwitchProvisionRMAPost.path + """ + instance = EpManageFabricsSwitchProvisionRMAPost() + instance.fabric_name = "MyFabric" + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switches_00530(): + """ + # Summary + + Verify EpManageFabricsSwitchProvisionRMAPost path without query params + + ## Test + + - Path is correctly built with fabric_name and switch_sn + - No query string appended when ticket_id is not set + + ## Classes and Methods + + - EpManageFabricsSwitchProvisionRMAPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchProvisionRMAPost() + instance.fabric_name = "MyFabric" + instance.switch_sn = "SAL1948TRTT" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switches/SAL1948TRTT/actions/provisionRMA" + + +def test_endpoints_api_v1_manage_fabrics_switches_00540(): + """ + # Summary + + Verify EpManageFabricsSwitchProvisionRMAPost path with ticket_id + + ## Test + + - Path includes ticketId query parameter when set + + ## Classes and Methods + + - EpManageFabricsSwitchProvisionRMAPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchProvisionRMAPost() + instance.fabric_name = "MyFabric" + instance.switch_sn = "SAL1948TRTT" + instance.endpoint_params.ticket_id = "CHG12345" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switches/SAL1948TRTT/actions/provisionRMA?ticketId=CHG12345" + + +# ============================================================================= +# Test: EpManageFabricsSwitchChangeSerialNumberPost +# ============================================================================= + + +def test_endpoints_api_v1_manage_fabrics_switches_00600(): + """ + # Summary + + Verify EpManageFabricsSwitchChangeSerialNumberPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + + ## Classes and Methods + + - EpManageFabricsSwitchChangeSerialNumberPost.__init__() + - EpManageFabricsSwitchChangeSerialNumberPost.class_name + - EpManageFabricsSwitchChangeSerialNumberPost.verb + """ + with does_not_raise(): + instance = EpManageFabricsSwitchChangeSerialNumberPost() + assert instance.class_name == "EpManageFabricsSwitchChangeSerialNumberPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_fabrics_switches_00610(): + """ + # Summary + + Verify EpManageFabricsSwitchChangeSerialNumberPost raises ValueError when fabric_name is not set + + ## Test + + - Accessing path raises ValueError when fabric_name is None + + ## Classes and Methods + + - EpManageFabricsSwitchChangeSerialNumberPost.path + """ + instance = EpManageFabricsSwitchChangeSerialNumberPost() + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switches_00620(): + """ + # Summary + + Verify EpManageFabricsSwitchChangeSerialNumberPost raises ValueError when switch_sn is not set + + ## Test + + - Accessing path raises ValueError when switch_sn is None + + ## Classes and Methods + + - EpManageFabricsSwitchChangeSerialNumberPost.path + """ + instance = EpManageFabricsSwitchChangeSerialNumberPost() + instance.fabric_name = "MyFabric" + with pytest.raises(ValueError): + _ = instance.path + + +def test_endpoints_api_v1_manage_fabrics_switches_00630(): + """ + # Summary + + Verify EpManageFabricsSwitchChangeSerialNumberPost path without query params + + ## Test + + - Path is correctly built with fabric_name and switch_sn + - No query string appended when cluster_name is not set + + ## Classes and Methods + + - EpManageFabricsSwitchChangeSerialNumberPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchChangeSerialNumberPost() + instance.fabric_name = "MyFabric" + instance.switch_sn = "SAL1948TRTT" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switches/SAL1948TRTT/actions/changeSwitchSerialNumber" + + +def test_endpoints_api_v1_manage_fabrics_switches_00640(): + """ + # Summary + + Verify EpManageFabricsSwitchChangeSerialNumberPost path with cluster_name + + ## Test + + - Path includes clusterName query parameter when set + + ## Classes and Methods + + - EpManageFabricsSwitchChangeSerialNumberPost.path + """ + with does_not_raise(): + instance = EpManageFabricsSwitchChangeSerialNumberPost() + instance.fabric_name = "MyFabric" + instance.switch_sn = "SAL1948TRTT" + instance.endpoint_params.cluster_name = "cluster1" + result = instance.path + assert result == "/api/v1/manage/fabrics/MyFabric/switches/SAL1948TRTT/actions/changeSwitchSerialNumber?clusterName=cluster1" + From f9900f8270a2dc0925a74f5ebf4c8d71b8d73225 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Thu, 19 Mar 2026 16:04:28 +0530 Subject: [PATCH 17/27] Remove NDOutput Changes --- plugins/module_utils/nd_switch_resources.py | 141 +------------------- 1 file changed, 5 insertions(+), 136 deletions(-) diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index 5fdb2c47..38241cb9 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -23,7 +23,6 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType -from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches import ( @@ -87,42 +86,6 @@ _DISCOVERY_MAX_HOPS: int = 0 -# ========================================================================= -# Output Collections -# ========================================================================= - -class SwitchOutputCollection(NDConfigCollection): - """Output collection for all output keys (previous, current, proposed, diff). - - Accepts ``SwitchDataModel``, ``SwitchConfigModel``, or ``_DiffRecord`` items - and serializes them via ``to_config_dict()``. - """ - - def __init__(self, model_class=None, items: Optional[List] = None): - # Store directly — skip add() type guard to support mixed-type diffs. - self._model_class = model_class - self._items: List = list(items) if items else [] - self._index: Dict = {} - - def to_ansible_config(self, **kwargs) -> List[Dict]: - return [item.to_config_dict() for item in self._items] - - def copy(self) -> "SwitchOutputCollection": - return SwitchOutputCollection( - model_class=self._model_class, - items=deepcopy(list(self._items)), - ) - - -@dataclass -class _DiffRecord: - """Wraps a plain dict as a diff entry, exposing ``to_config_dict()``.""" - - data: Dict[str, Any] - - def to_config_dict(self) -> Dict[str, Any]: - return self.data - @dataclass class SwitchServiceContext: @@ -142,7 +105,6 @@ class SwitchServiceContext: log: logging.Logger save_config: bool = True deploy_config: bool = True - output: Optional[NDOutput] = None # ========================================================================= @@ -1309,20 +1271,6 @@ def handle( if preprov_models: self._preprovision_switches(preprov_models) - if self.ctx.output: - diff_items = [ - _DiffRecord({ - "serial_number": m.serial_number, - "hostname": m.hostname, - "ip": m.ip, - "model": m.model, - "software_version": m.software_version, - "role": m.switch_role, - }) - for m in preprov_models - ] - self.ctx.output.assign(diff=SwitchOutputCollection(items=diff_items)) - # Edge case: nothing actionable if not bootstrap_entries and not preprov_entries and not swap_entries: log.warning("No POAP switch models built — nothing to process") @@ -1418,21 +1366,6 @@ def _handle_poap_bootstrap( skip_greenfield_check=True, ) - if self.ctx.output: - import_by_serial = {m.serial_number: m for m in import_models} - diff_items = [ - _DiffRecord({ - "seed_ip": switch_cfg.seed_ip, - "serial_number": serial, - "hostname": import_by_serial[serial].hostname if serial in import_by_serial else None, - "model": import_by_serial[serial].model if serial in import_by_serial else None, - "software_version": import_by_serial[serial].version if serial in import_by_serial else None, - "role": switch_cfg.role, - }) - for serial, switch_cfg in switch_actions - ] - self.ctx.output.assign(diff=SwitchOutputCollection(items=diff_items)) - log.debug("EXIT: _handle_poap_bootstrap()") def _build_bootstrap_import_model( @@ -2074,19 +2007,6 @@ def handle( log.error(msg) nd.module.fail_json(msg=msg) - if self.ctx.output: - diff_items = [ - _DiffRecord({ - "seed_ip": switch_cfg.seed_ip, - "old_serial_number": old_serial, - "new_serial_number": new_serial, - "hostname": old_switch_info[old_serial]["hostname"], - "role": switch_cfg.role, - }) - for new_serial, old_serial, switch_cfg in rma_diff_data - ] - self.ctx.output.assign(diff=SwitchOutputCollection(items=diff_items)) - self.fabric_ops.bulk_save_credentials(switch_actions) try: @@ -2391,11 +2311,11 @@ def __init__( # Switch collections try: self.proposed: NDConfigCollection = NDConfigCollection(model_class=SwitchDataModel) - self.existing: SwitchOutputCollection = SwitchOutputCollection.from_api_response( + self.existing: NDConfigCollection = NDConfigCollection.from_api_response( response_data=self._query_all_switches(), model_class=SwitchDataModel, ) - self.previous: SwitchOutputCollection = self.existing.copy() + self.previous: NDConfigCollection = self.existing.copy() except Exception as e: msg = ( f"Failed to query fabric '{self.fabric}' inventory " @@ -2407,12 +2327,6 @@ def __init__( # Operation tracking self.nd_logs: List[Dict[str, Any]] = [] - # Output tracking — NDOutput serializes all collections via their - # overridden to_ansible_config() methods. - self.output = NDOutput(output_level=self.module.params.get("output_level", "normal")) - self.output.assign(before=self.previous, after=self.existing) - self.ctx.output = self.output - # Utility instances (SwitchWaitUtils / FabricUtils depend on self) self.fabric_utils = FabricUtils(self.nd, self.fabric, log) self.wait_utils = SwitchWaitUtils( @@ -2442,27 +2356,12 @@ def exit_json(self) -> None: # Re-query the fabric to get the actual post-operation inventory so # that "current" reflects real state rather than the pre-op snapshot. if True not in self.results.failed and not self.nd.module.check_mode: - self.existing = SwitchOutputCollection.from_api_response( + self.existing = NDConfigCollection.from_api_response( response_data=self._query_all_switches(), model_class=SwitchDataModel ) - self.output.assign(after=self.existing) - - self.output._changed = bool(final.get("changed", False)) - formatted = self.output.format() - - output_level = formatted["output_level"] - # Rename before/after to previous/current for backward compatibility. - final["previous"] = formatted.pop("before", []) - final["current"] = formatted.pop("after", []) - final["output_level"] = output_level - final["diff"] = formatted.get("diff", []) - - if output_level in ("info", "debug"): - final["proposed"] = formatted.get("proposed", []) - if output_level == "debug": - # Override NDOutput's placeholder with real operation logs. - final["logs"] = self.nd_logs + final["previous"] = self.previous.to_ansible_config() + final["current"] = self.existing.to_ansible_config() if True in self.results.failed: self.nd.module.fail_json(**final) @@ -2491,12 +2390,6 @@ def manage_state(self) -> None: if self.config else None ) - if proposed_config: - self.output.assign( - proposed=SwitchOutputCollection( - model_class=SwitchConfigModel, items=proposed_config - ) - ) return self._handle_deleted_state(proposed_config) # merged / overridden — config is required @@ -2508,12 +2401,6 @@ def manage_state(self) -> None: proposed_config = SwitchDiffEngine.validate_configs( self.config, self.state, self.nd, self.log ) - # Register proposed config (credentials excluded via SwitchOutputCollection) - self.output.assign( - proposed=SwitchOutputCollection( - model_class=SwitchConfigModel, items=proposed_config - ) - ) # Partition configs by operation type poap_configs = [c for c in proposed_config if c.operation_type == "poap"] rma_configs = [c for c in proposed_config if c.operation_type == "rma"] @@ -2633,7 +2520,6 @@ def _handle_merged_state( # Collect (serial_number, SwitchConfigModel) pairs for post-processing switch_actions: List[Tuple[str, SwitchConfigModel]] = [] - diff_items: List = [] # Phase 4: Bulk add new switches to fabric if switches_to_add and discovered_data: @@ -2677,17 +2563,6 @@ def _handle_merged_state( sn = disc.get("serialNumber") if sn: switch_actions.append((sn, cfg)) - # Discovery response has softwareVersion, hostname, - # model — richer than SwitchConfigModel fields. - diff_items.append(_DiffRecord({ - "seed_ip": cfg.seed_ip, - "serial_number": sn, - "hostname": disc.get("hostname"), - "model": disc.get("model"), - "role": cfg.role, - "software_version": disc.get("softwareVersion"), - "mode": None, - })) self._log_operation("add", cfg.seed_ip) # Phase 5: Collect migration switches for post-processing @@ -2701,9 +2576,6 @@ def _handle_merged_state( cfg = config_by_ip.get(mig_sw.fabric_management_ip) if cfg and mig_sw.switch_id: switch_actions.append((mig_sw.switch_id, cfg)) - # mig_sw is a SwitchDataModel — has all 7 fields including - # software_version and mode from the inventory API. - diff_items.append(mig_sw) self._log_operation("migrate", mig_sw.fabric_management_ip) if not switch_actions: @@ -2731,8 +2603,6 @@ def _handle_merged_state( all_preserve_config=all_preserve_config, update_roles=have_migration_switches, ) - self.output.assign(diff=SwitchOutputCollection(items=diff_items)) - self.log.debug("EXIT: _handle_merged_state() - completed") # ----------------------------------------------------------------- @@ -2963,7 +2833,6 @@ def _handle_deleted_state( f"Proceeding to delete {len(switches_to_delete)} switch(es) from fabric" ) self.fabric_ops.bulk_delete(switches_to_delete) - self.output.assign(diff=SwitchOutputCollection(items=switches_to_delete)) self.log.debug("EXIT: _handle_deleted_state()") # ===================================================================== From 36488a82ad961852512b9609a86b9ffcd9996ed7 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 20 Mar 2026 12:16:19 +0530 Subject: [PATCH 18/27] Module Cleanup + Check Mode --- .../models/manage_switches/config_models.py | 15 +++ plugins/module_utils/nd_switch_resources.py | 48 ++++--- plugins/modules/nd_manage_switches.py | 123 ++---------------- 3 files changed, 55 insertions(+), 131 deletions(-) diff --git a/plugins/module_utils/models/manage_switches/config_models.py b/plugins/module_utils/models/manage_switches/config_models.py index 94336143..c8b97195 100644 --- a/plugins/module_utils/models/manage_switches/config_models.py +++ b/plugins/module_utils/models/manage_switches/config_models.py @@ -591,6 +591,21 @@ def to_payload(self) -> Dict[str, Any]: exclude_none=True, ) + @classmethod + def get_argument_spec(cls) -> Dict[str, Any]: + """Return the Ansible argument spec for nd_manage_switches.""" + return dict( + fabric=dict(type="str", required=True), + state=dict( + type="str", + default="merged", + choices=["merged", "overridden", "deleted"], + ), + save=dict(type="bool", default=True), + deploy=dict(type="bool", default=True), + config=dict(type="list", elements="dict"), + ) + __all__ = [ "ConfigDataModel", diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index 38241cb9..b69c342d 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -1167,20 +1167,7 @@ def handle( log.debug("ENTER: POAPHandler.handle()") log.info(f"Processing POAP for {len(proposed_config)} switch config(s)") - # Check mode — preview only - if nd.module.check_mode: - log.info("Check mode: would run POAP bootstrap / pre-provision") - results.action = "poap" - results.operation_type = OperationType.CREATE - results.response_current = {"MESSAGE": "check mode — skipped"} - results.result_current = {"success": True, "changed": True} - results.diff_current = { - "poap_switches": [pc.seed_ip for pc in proposed_config] - } - results.register_api_call() - return - - # Classify entries + # Classify entries first so check mode can report per-operation counts bootstrap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]] = [] preprov_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]] = [] swap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]] = [] @@ -1211,6 +1198,24 @@ def handle( f"{len(swap_entries)} swap" ) + # Check mode — preview only + if nd.module.check_mode: + log.info( + f"Check mode: would bootstrap {len(bootstrap_entries)}, " + f"pre-provision {len(preprov_entries)}, swap {len(swap_entries)}" + ) + results.action = "poap" + results.operation_type = OperationType.CREATE + results.response_current = {"MESSAGE": "check mode — skipped"} + results.result_current = {"success": True, "changed": False} + results.diff_current = { + "bootstrap": [cfg.seed_ip for cfg, _ in bootstrap_entries], + "preprovision": [cfg.seed_ip for cfg, _ in preprov_entries], + "swap": [cfg.seed_ip for cfg, _ in swap_entries], + } + results.register_api_call() + return + # Idempotency: skip entries whose target serial is already in the fabric. # Build lookup structures for idempotency checks. # Bootstrap: idempotent when both IP address AND serial number match. @@ -1904,7 +1909,7 @@ def handle( results.action = "rma" results.operation_type = OperationType.CREATE results.response_current = {"MESSAGE": "check mode — skipped"} - results.result_current = {"success": True, "changed": True} + results.result_current = {"success": True, "changed": False} results.diff_current = { "rma_switches": [pc.seed_ip for pc in proposed_config] } @@ -2503,17 +2508,19 @@ def _handle_merged_state( # Check mode — preview only if self.nd.module.check_mode: self.log.info( - f"Check mode: would add {len(switches_to_add)} and " - f"process {len(migration_switches)} migration switches" + f"Check mode: would add {len(switches_to_add)}, " + f"process {len(migration_switches)} migration switch(es), " + f"save_deploy_required={idempotent_save_req}" ) self.results.action = "merge" self.results.state = self.state self.results.operation_type = OperationType.CREATE self.results.response_current = {"MESSAGE": "check mode — skipped", "RETURN_CODE": 200} - self.results.result_current = {"success": True, "changed": True} + self.results.result_current = {"success": True, "changed": False} self.results.diff_current = { "to_add": [sw.fabric_management_ip for sw in switches_to_add], "migration_mode": [sw.fabric_management_ip for sw in migration_switches], + "save_deploy_required": idempotent_save_req, } self.results.register_api_call() return @@ -2710,12 +2717,11 @@ def _handle_overridden_state( f"delete-and-re-add {n_update}, " f"add {n_add}, migrate {n_migrate}" ) - would_change = (n_delete + n_update + n_add + n_migrate) > 0 self.results.action = "override" self.results.state = self.state self.results.operation_type = OperationType.CREATE self.results.response_current = {"MESSAGE": "check mode — skipped", "RETURN_CODE": 200} - self.results.result_current = {"success": True, "changed": would_change} + self.results.result_current = {"success": True, "changed": False} self.results.diff_current = { "to_delete": n_delete, "to_update": n_update, @@ -2822,7 +2828,7 @@ def _handle_deleted_state( self.results.state = self.state self.results.operation_type = OperationType.DELETE self.results.response_current = {"MESSAGE": "check mode — skipped", "RETURN_CODE": 200} - self.results.result_current = {"success": True, "changed": True} + self.results.result_current = {"success": True, "changed": False} self.results.diff_current = { "to_delete": [sw.fabric_management_ip for sw in switches_to_delete], } diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index 9e1ea604..ffd39f01 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -372,6 +372,7 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.config_models import SwitchConfigModel from ansible_collections.cisco.nd.plugins.module_utils.nd_switch_resources import NDSwitchResourceModule from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( NDModule, @@ -383,99 +384,10 @@ def main(): """Main entry point for the nd_manage_switches module.""" - + # Build argument spec argument_spec = nd_argument_spec() - argument_spec.update( - fabric=dict(type="str", required=True), - config=dict( - type="list", - elements="dict", - options=dict( - seed_ip=dict(type="str", required=True), - auth_proto=dict( - type="str", - default="MD5", - choices=["MD5", "SHA", "MD5_DES", "MD5_AES", "SHA_DES", "SHA_AES"] - ), - user_name=dict(type="str", default="admin"), - password=dict(type="str", no_log=True), - role=dict( - type="str", - default="leaf", - choices=[ - "leaf", "spine", "border", "border_spine", - "border_gateway", "border_gateway_spine", - "super_spine", "border_super_spine", - "border_gateway_super_spine", "access", - "aggregation", "edge_router", "core_router", "tor" - ] - ), - preserve_config=dict(type="bool", default=False), - poap=dict( - type="list", - elements="dict", - options=dict( - discovery_username=dict(type="str"), - discovery_password=dict(type="str", no_log=True), - serial_number=dict(type="str"), - preprovision_serial=dict(type="str"), - model=dict(type="str"), - version=dict(type="str"), - hostname=dict(type="str"), - image_policy=dict(type="str"), - config_data=dict( - type="dict", - options=dict( - models=dict( - type="list", - elements="str", - ), - gateway=dict( - type="str", - ), - ), - ), - ), - ), - rma=dict( - type="list", - elements="dict", - options=dict( - old_serial=dict(type="str", required=True), - serial_number=dict(type="str", required=True), - model=dict(type="str", required=True), - version=dict(type="str", required=True), - image_policy=dict(type="str"), - discovery_username=dict(type="str"), - discovery_password=dict(type="str", no_log=True), - config_data=dict( - type="dict", - required=True, - options=dict( - models=dict( - type="list", - elements="str", - required=True, - ), - gateway=dict( - type="str", - required=True, - ), - ), - ), - ), - ), - ), - ), - save=dict(type="bool", default=True), - deploy=dict(type="bool", default=True), - state=dict( - type="str", - default="merged", - choices=["merged", "overridden", "deleted"] - ), - ) + argument_spec.update(SwitchConfigModel.get_argument_spec()) # Create Ansible module module = AnsibleModule( @@ -490,7 +402,6 @@ def main(): # Initialize logging try: log_config = Log() - log_config.config = "/Users/achengam/Documents/Ansible_Dev/NDBranch/ansible_collections/cisco/nd/ansible_cisco_log_r.json" log_config.commit() # Create logger instance for this module log = logging.getLogger("nd.nd_manage_switches") @@ -498,22 +409,16 @@ def main(): module.fail_json(msg=str(error)) # Get parameters - state = module.params.get("state") - fabric = module.params.get("fabric") output_level = module.params.get("output_level") # Initialize Results - this collects all operation results results = Results() - results.state = state results.check_mode = module.check_mode - results.action = f"manage_switches_{state}" + results.action = "manage_switches" try: - log.info(f"Starting nd_manage_switches module: fabric={fabric}, state={state}") - # Initialize NDModule (uses RestSend infrastructure internally) nd = NDModule(module) - log.info("NDModule initialized successfully") # Create NDSwitchResourceModule sw_module = NDSwitchResourceModule( @@ -521,12 +426,10 @@ def main(): results=results, logger=log ) - log.info(f"NDSwitchResourceModule initialized for fabric: {fabric}") - + # Manage state for merged, overridden, deleted - log.info(f"Managing state: {state}") sw_module.manage_state() - + # Exit with results log.info(f"State management completed successfully. Changed: {results.changed}") sw_module.exit_json() @@ -534,7 +437,7 @@ def main(): except NDModuleError as error: # NDModule-specific errors (API failures, authentication issues, etc.) log.error(f"NDModule error: {error.msg}") - + # Try to get response from RestSend if available try: results.response_current = nd.rest_send.response_current @@ -550,15 +453,15 @@ def main(): "success": False, "found": False, } - + results.diff_current = {} results.register_api_call() results.build_final_result() - + # Add error details if debug output is requested if output_level == "debug": results.final_result["error_details"] = error.to_dict() - + log.error(f"Module failed: {results.final_result}") module.fail_json(msg=error.msg, **results.final_result) @@ -566,7 +469,7 @@ def main(): # Unexpected errors log.error(f"Unexpected error during module execution: {str(error)}") log.error(f"Error type: {type(error).__name__}") - + # Build failed result results.response_current = { "RETURN_CODE": -1, @@ -580,11 +483,11 @@ def main(): results.diff_current = {} results.register_api_call() results.build_final_result() - + if output_level == "debug": import traceback results.final_result["traceback"] = traceback.format_exc() - + module.fail_json(msg=str(error), **results.final_result) From e5ae068e81d1a93e7eda6620ccc03032648188e7 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 20 Mar 2026 14:38:54 +0530 Subject: [PATCH 19/27] Add gathered state support to the module --- .../models/manage_switches/config_models.py | 62 ++++++++++++++++- plugins/module_utils/nd_switch_resources.py | 69 ++++++++++++++++--- plugins/modules/nd_manage_switches.py | 25 +++++-- 3 files changed, 142 insertions(+), 14 deletions(-) diff --git a/plugins/module_utils/models/manage_switches/config_models.py b/plugins/module_utils/models/manage_switches/config_models.py index c8b97195..b464966f 100644 --- a/plugins/module_utils/models/manage_switches/config_models.py +++ b/plugins/module_utils/models/manage_switches/config_models.py @@ -591,6 +591,66 @@ def to_payload(self) -> Dict[str, Any]: exclude_none=True, ) + @classmethod + def from_switch_data(cls, sw: Any) -> "SwitchConfigModel": + """Build a config-shaped entry from a live inventory record. + + Only the fields recoverable from the ND inventory API are populated. + Credentials (user_name, password) are intentionally omitted. + + Args: + sw: A SwitchDataModel instance from the fabric inventory. + + Returns: + SwitchConfigModel instance with seed_ip, role, and platform_type + populated from live data. + + Raises: + ValueError: If the inventory record is missing a management IP, + making it impossible to construct a valid config entry. + """ + if not sw.fabric_management_ip: + raise ValueError( + f"Switch {sw.switch_id!r} has no fabric_management_ip — " + "cannot build a gathered config entry without a seed IP." + ) + + platform_type = ( + sw.additional_data.platform_type + if sw.additional_data and hasattr(sw.additional_data, "platform_type") + else None + ) + + data: Dict[str, Any] = {"seed_ip": sw.fabric_management_ip} + if sw.switch_role is not None: + data["role"] = sw.switch_role + if platform_type is not None: + data["platform_type"] = platform_type + + return cls.model_validate(data) + + def to_gathered_dict(self) -> Dict[str, Any]: + """Return a config dict suitable for gathered output. + + platform_type is excluded (internal detail not needed by the user). + user_name and password are replaced with placeholders so the returned + data is immediately usable as ``config:`` input after substituting + real credentials. + + Returns: + Dict with seed_ip, role, auth_proto, preserve_config, + user_name set to ``""``, password set to ``""``. + """ + result = self.to_config(exclude={ + "platform_type": True, + "poap": True, + "rma": True, + "operation_type": True, + }) + result["user_name"] = "" + result["password"] = "" + return result + @classmethod def get_argument_spec(cls) -> Dict[str, Any]: """Return the Ansible argument spec for nd_manage_switches.""" @@ -599,7 +659,7 @@ def get_argument_spec(cls) -> Dict[str, Any]: state=dict( type="str", default="merged", - choices=["merged", "overridden", "deleted"], + choices=["merged", "overridden", "deleted", "gathered"], ), save=dict(type="bool", default=True), deploy=dict(type="bool", default=True), diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index b69c342d..af768af1 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -2358,15 +2358,29 @@ def exit_json(self) -> None: self.results.build_final_result() final = self.results.final_result - # Re-query the fabric to get the actual post-operation inventory so - # that "current" reflects real state rather than the pre-op snapshot. - if True not in self.results.failed and not self.nd.module.check_mode: - self.existing = NDConfigCollection.from_api_response( - response_data=self._query_all_switches(), model_class=SwitchDataModel - ) - - final["previous"] = self.previous.to_ansible_config() - final["current"] = self.existing.to_ansible_config() + if self.state == "gathered": + # gathered: expose the already-queried inventory in config shape. + # No re-query needed — nothing was changed. + gathered = [] + for sw in self.existing: + try: + gathered.append(SwitchConfigModel.from_switch_data(sw).to_gathered_dict()) + except (ValueError, Exception) as exc: + msg = ( + f"Failed to convert switch {sw.switch_id!r} to gathered format: {exc}" + ) + self.log.error(msg) + self.nd.module.fail_json(msg=msg) + final["gathered"] = gathered + else: + # Re-query the fabric to get the actual post-operation inventory so + # that "current" reflects real state rather than the pre-op snapshot. + if True not in self.results.failed and not self.nd.module.check_mode: + self.existing = NDConfigCollection.from_api_response( + response_data=self._query_all_switches(), model_class=SwitchDataModel + ) + final["previous"] = self.previous.to_ansible_config() + final["current"] = self.existing.to_ansible_config() if True in self.results.failed: self.nd.module.fail_json(**final) @@ -2388,6 +2402,14 @@ def manage_state(self) -> None: """ self.log.info(f"Managing state: {self.state}") + # gathered — read-only, no config accepted + if self.state == "gathered": + if self.config: + self.nd.module.fail_json( + msg="'config' must not be provided for 'gathered' state." + ) + return self._handle_gathered_state() + # deleted — config is optional if self.state == "deleted": proposed_config = ( @@ -2776,6 +2798,35 @@ def _handle_overridden_state( self._handle_merged_state(diff, proposed_config, discovered_data) self.log.debug("EXIT: _handle_overridden_state()") + def _handle_gathered_state(self) -> None: + """Handle gathered-state read of the fabric inventory. + + No API writes are performed. The existing inventory is serialised into + SwitchConfigModel shape by exit_json(). This method only records the + result metadata so that Results aggregation works correctly. + + Returns: + None. + """ + self.log.debug("ENTER: _handle_gathered_state()") + self.log.info(f"Gathering inventory for fabric '{self.fabric}'") + + if not self.existing: + self.log.info(f"Fabric '{self.fabric}' has no switches in inventory") + + self.results.action = "gathered" + self.results.state = self.state + self.results.operation_type = OperationType.QUERY + self.results.response_current = {"MESSAGE": "gathered", "RETURN_CODE": 200} + self.results.result_current = {"success": True, "changed": False} + self.results.diff_current = {} + self.results.register_api_call() + + self.log.info( + f"Gathered {len(list(self.existing))} switch(es) from fabric '{self.fabric}'" + ) + self.log.debug("EXIT: _handle_gathered_state()") + def _handle_deleted_state( self, proposed_config: Optional[List[SwitchConfigModel]] = None, diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index ffd39f01..b6f01cb8 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -32,12 +32,15 @@ - The state of ND and switch(es) after module completion. - C(merged) is the only state supported for POAP. - C(merged) is the only state supported for RMA. + - C(gathered) reads the current fabric inventory and returns it in the + C(gathered) key in config format. No changes are made. type: str default: merged choices: - merged - overridden - deleted + - gathered save: description: - Save/Recalculate the configuration of the fabric after inventory is updated. @@ -343,27 +346,41 @@ - seed_ip: 192.168.10.202 state: deleted +- name: Gather all switches from fabric + cisco.nd.nd_manage_switches: + fabric: my-fabric + state: gathered + register: result + """ RETURN = """ previous: description: The configuration prior to the module execution. - returned: always + returned: when state is not gathered type: list elements: dict proposed: description: The proposed configuration sent to the API. - returned: always + returned: when state is not gathered type: list elements: dict sent: description: The configuration sent to the API. - returned: always + returned: when state is not gathered type: list elements: dict current: description: The current configuration after module execution. - returned: always + returned: when state is not gathered + type: list + elements: dict +gathered: + description: + - The current fabric switch inventory returned in config format. + - Each entry mirrors the C(config) input schema (seed_ip, role, + auth_proto, preserve_config). Credentials are replaced with placeholders. + returned: when state is gathered type: list elements: dict """ From 79394df447a455468bfbe7c37cc69e1f328e046c Mon Sep 17 00:00:00 2001 From: AKDRG Date: Tue, 24 Mar 2026 01:15:36 +0530 Subject: [PATCH 20/27] Integration Tests + Fixes --- plugins/action/nd_inventory_validate.py | 265 +++++++++++++++ .../models/manage_switches/config_models.py | 45 +-- .../models/manage_switches/enums.py | 11 +- plugins/module_utils/nd_switch_resources.py | 26 +- .../nd_manage_switches/defaults/main.yaml | 2 + .../targets/nd_manage_switches/meta/main.yaml | 1 + .../nd_manage_switches/tasks/base_tasks.yaml | 67 ++++ .../tasks/conf_prep_tasks.yaml | 11 + .../nd_manage_switches/tasks/main.yaml | 17 + .../nd_manage_switches/tasks/query_task.yaml | 33 ++ .../templates/nd_manage_switches_conf.j2 | 62 ++++ .../nd_manage_switches/tests/nd/deleted.yaml | 143 ++++++++ .../nd_manage_switches/tests/nd/gathered.yaml | 64 ++++ .../nd_manage_switches/tests/nd/merged.yaml | 318 ++++++++++++++++++ .../tests/nd/overridden.yaml | 166 +++++++++ .../nd_manage_switches/tests/nd/poap.yaml | 265 +++++++++++++++ .../nd_manage_switches/tests/nd/rma.yaml | 182 ++++++++++ .../nd_manage_switches/tests/nd/sanity.yaml | 184 ++++++++++ 18 files changed, 1828 insertions(+), 34 deletions(-) create mode 100644 plugins/action/nd_inventory_validate.py create mode 100644 tests/integration/targets/nd_manage_switches/defaults/main.yaml create mode 100644 tests/integration/targets/nd_manage_switches/meta/main.yaml create mode 100644 tests/integration/targets/nd_manage_switches/tasks/base_tasks.yaml create mode 100644 tests/integration/targets/nd_manage_switches/tasks/conf_prep_tasks.yaml create mode 100644 tests/integration/targets/nd_manage_switches/tasks/main.yaml create mode 100644 tests/integration/targets/nd_manage_switches/tasks/query_task.yaml create mode 100644 tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 create mode 100644 tests/integration/targets/nd_manage_switches/tests/nd/deleted.yaml create mode 100644 tests/integration/targets/nd_manage_switches/tests/nd/gathered.yaml create mode 100644 tests/integration/targets/nd_manage_switches/tests/nd/merged.yaml create mode 100644 tests/integration/targets/nd_manage_switches/tests/nd/overridden.yaml create mode 100644 tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml create mode 100644 tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml create mode 100644 tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml diff --git a/plugins/action/nd_inventory_validate.py b/plugins/action/nd_inventory_validate.py new file mode 100644 index 00000000..024ba634 --- /dev/null +++ b/plugins/action/nd_inventory_validate.py @@ -0,0 +1,265 @@ +# -*- 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 Inventory Validation Action Plugin. + +Validates switch inventory data returned from nd_rest against expected +configuration entries. Checks that every entry in test_data has a matching +switch in the ND API response (fabricManagementIp == seed_ip, +switchRole == role). + +Supports an optional ``mode`` argument: + - ``"both"`` (default): match by seed_ip AND role. + - ``"ip"``: match by seed_ip only (role is ignored). + - ``"role"``: match by role only (seed_ip is ignored). +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import json +from typing import Any, Dict, List, Optional, Union + +from ansible.plugins.action import ActionBase +from ansible.utils.display import Display + +try: + from pydantic import BaseModel, ValidationError, field_validator, model_validator + HAS_PYDANTIC = True +except ImportError: + HAS_PYDANTIC = False + +try: + from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.config_models import SwitchConfigModel + from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.switch_data_models import SwitchDataModel + HAS_MODELS = True +except ImportError: + HAS_MODELS = False + +display = Display() + + +# --------------------------------------------------------------------------- +# Validation orchestration model +# --------------------------------------------------------------------------- + +class InventoryValidate(BaseModel): + """Orchestrates the match between playbook config entries and live ND inventory.""" + + config_data: Optional[List[Any]] = None + nd_data: Optional[List[Any]] = None + ignore_fields: Optional[Dict[str, int]] = None + response: Union[bool, None] = None + + @field_validator("config_data", mode="before") + @classmethod + def parse_config_data(cls, value): + """Coerce raw dicts into SwitchConfigModel instances. + + Accepts a single dict or a list of dicts. + """ + if isinstance(value, dict): + return [SwitchConfigModel.model_validate(value)] + if isinstance(value, list): + try: + return [ + SwitchConfigModel.model_validate(item) if isinstance(item, dict) else item + for item in value + ] + except (ValidationError, ValueError) as e: + raise ValueError("Invalid format in Config Data: {0}".format(e)) + if value is None: + return None + raise ValueError("Config Data must be a single/list of dictionary, or None.") + + @field_validator("nd_data", mode="before") + @classmethod + def parse_nd_data(cls, value): + """Coerce raw ND API switch dicts into SwitchDataModel instances.""" + if isinstance(value, list): + try: + return [ + SwitchDataModel.from_response(item) if isinstance(item, dict) else item + for item in value + ] + except (ValidationError, ValueError) as e: + raise ValueError("Invalid format in ND Response: {0}".format(e)) + if value is None: + return None + raise ValueError("ND Response must be a list of dictionaries.") + + @model_validator(mode="after") + def validate_lists_equality(self): + """Match every config entry against the live ND switch inventory. + + Sets ``self.response = True`` when all entries match, ``False`` otherwise. + Respects ``ignore_fields`` to support ip-only or role-only matching modes. + + Role comparison uses SwitchRole enum equality — no string normalization needed. + """ + config_data = self.config_data + nd_data_list = self.nd_data + ignore_fields = self.ignore_fields + + # Both empty → nothing to validate, treat as success. + # Exactly one empty → mismatch, treat as failure. + if not config_data and not nd_data_list: + self.response = True + return self + if not config_data or not nd_data_list: + self.response = False + return self + + missing_ips = [] + role_mismatches = {} + nd_data_copy = nd_data_list.copy() + matched_indices = set() + + for config_item in config_data: + found_match = False + seed_ip = config_item.seed_ip + role_expected = config_item.role # SwitchRole enum or None + + for i, nd_item in enumerate(nd_data_copy): + if i in matched_indices: + continue + + ip_address = nd_item.fabric_management_ip + switch_role = nd_item.switch_role # SwitchRole enum or None + + seed_ip_match = ( + (seed_ip is not None and ip_address is not None and ip_address == seed_ip) + or bool(ignore_fields["seed_ip"]) + ) + role_match = ( + (role_expected is not None and switch_role is not None and switch_role == role_expected) + or bool(ignore_fields["role"]) + ) + + if seed_ip_match and role_match: + matched_indices.add(i) + found_match = True + if ignore_fields["seed_ip"]: + break + elif ( + seed_ip_match + and role_expected is not None + and switch_role is not None + and switch_role != role_expected + ) or ignore_fields["role"]: + role_mismatches.setdefault( + seed_ip or ip_address, + { + "expected_role": role_expected.value if role_expected else None, + "response_role": switch_role.value if switch_role else None, + }, + ) + matched_indices.add(i) + found_match = True + if ignore_fields["seed_ip"]: + break + + if not found_match and seed_ip is not None: + missing_ips.append(seed_ip) + + if not missing_ips and not role_mismatches: + self.response = True + else: + display.display("Invalid Data:") + if missing_ips: + display.display(" Missing IPs: {0}".format(missing_ips)) + if role_mismatches: + display.display(" Role mismatches: {0}".format(json.dumps(role_mismatches, indent=2))) + self.response = False + + return self + + +# --------------------------------------------------------------------------- +# Action plugin +# --------------------------------------------------------------------------- + +class ActionModule(ActionBase): + """Ansible action plugin for validating ND switch inventory data. + + Arguments (task args): + nd_data (dict): The registered result of a cisco.nd.nd_rest GET call. + test_data (list|dict): Expected switch entries, each with ``seed_ip`` + and optionally ``role``. + changed (bool, optional): If provided and False, the task fails + immediately (used to assert an upstream + operation produced a change). + mode (str, optional): ``"both"`` (default), ``"ip"``, or ``"role"``. + """ + + def run(self, tmp=None, task_vars=None): + results = super(ActionModule, self).run(tmp, task_vars) + results["failed"] = False + + if not HAS_PYDANTIC or not HAS_MODELS: + results["failed"] = True + results["msg"] = "pydantic and the ND collection models are required for nd_inventory_validate" + return results + + nd_data = self._task.args["nd_data"] + test_data = self._task.args["test_data"] + + # Fail fast if the caller signals that no change occurred when one was expected. + if "changed" in self._task.args and not self._task.args["changed"]: + results["failed"] = True + results["msg"] = 'Changed is "false"' + return results + + # Fail fast if the upstream nd_rest task itself failed. + if nd_data.get("failed"): + results["failed"] = True + results["msg"] = nd_data.get("msg", "ND module returned a failure") + return results + + # Extract switch list from nd_data.current.switches + switches = nd_data.get("current", {}).get("switches", []) + + # Normalise test_data to a list. + if isinstance(test_data, dict): + test_data = [test_data] + + # If both are empty treat as success; if only nd response is empty it's a failure. + if not switches and not test_data: + results["msg"] = "Validation Successful!" + return results + + if not switches: + results["failed"] = True + results["msg"] = "No switches found in ND response" + return results + + # Resolve matching mode via ignore_fields flags. + ignore_fields = {"seed_ip": 0, "role": 0} + if "mode" in self._task.args: + mode = self._task.args["mode"].lower() + if mode == "ip": + # IP mode: only match by seed_ip, ignore role + ignore_fields["role"] = 1 + elif mode == "role": + # Role mode: only match by role, ignore seed_ip + ignore_fields["seed_ip"] = 1 + + validation = InventoryValidate( + config_data=test_data, + nd_data=switches, + ignore_fields=ignore_fields, + response=None, + ) + + if validation.response: + results["msg"] = "Validation Successful!" + else: + results["failed"] = True + results["msg"] = "Validation Failed! Please check output above." + + return results + diff --git a/plugins/module_utils/models/manage_switches/config_models.py b/plugins/module_utils/models/manage_switches/config_models.py index b464966f..711bbb57 100644 --- a/plugins/module_utils/models/manage_switches/config_models.py +++ b/plugins/module_utils/models/manage_switches/config_models.py @@ -423,41 +423,24 @@ def to_config_dict(self) -> Dict[str, Any]: "rma": {"__all__": {"discovery_username": True, "discovery_password": True}}, }) - @model_validator(mode='before') - @classmethod - def reject_auth_proto_for_poap_rma(cls, data: Any) -> Any: + @model_validator(mode='after') + def reject_auth_proto_for_poap_rma(self) -> Self: """Reject non-MD5 auth_proto when POAP or RMA is configured. POAP, Pre-provision, and RMA operations always use MD5 internally. - If the user explicitly supplies a non-MD5 ``auth_proto`` (or - ``authProto``) alongside ``poap`` or ``rma``, raise an error so - they know the field is not user-configurable for these operation - types. - - Note: Ansible argspec injects the default ``"MD5"`` even when the - user omits ``auth_proto``, so we must allow MD5 through. + By validating mode='after', all inputs (raw strings, enum instances, + or Ansible argspec-injected defaults) have already been coerced by + Pydantic into a typed SnmpV3AuthProtocol value, so a direct enum + comparison is safe and unambiguous. """ - if not isinstance(data, dict): - return data - - has_poap = bool(data.get("poap")) - has_rma = bool(data.get("rma")) - - if has_poap or has_rma: - # Check both snake_case (Ansible playbook) and camelCase (API) keys - auth_val = data.get("auth_proto") or data.get("authProto") - if auth_val is not None: - # Normalize to lowercase for comparison - normalized = str(auth_val).strip().lower() - if normalized not in ("md5", ""): - op = "POAP" if has_poap else "RMA" - raise ValueError( - f"'auth_proto' must not be specified for {op} operations. " - f"The authentication protocol is always MD5 and is set " - f"automatically. Received: '{auth_val}'" - ) - - return data + if (self.poap or self.rma) and self.auth_proto != SnmpV3AuthProtocol.MD5: + op = "POAP" if self.poap else "RMA" + raise ValueError( + f"'auth_proto' must not be specified for {op} operations. " + f"The authentication protocol is always MD5 and is set " + f"automatically. Received: '{self.auth_proto.value}'" + ) + return self @model_validator(mode='after') def validate_poap_rma_mutual_exclusion(self) -> Self: diff --git a/plugins/module_utils/models/manage_switches/enums.py b/plugins/module_utils/models/manage_switches/enums.py index 0d3f85cc..edb8f28a 100644 --- a/plugins/module_utils/models/manage_switches/enums.py +++ b/plugins/module_utils/models/manage_switches/enums.py @@ -317,12 +317,16 @@ def choices(cls) -> List[str]: class AnomalyLevel(str, Enum): """ Anomaly level classification. + + Based on: components/schemas/anomalyLevel """ CRITICAL = "critical" MAJOR = "major" MINOR = "minor" WARNING = "warning" - INFO = "info" + HEALTHY = "healthy" + NOT_APPLICABLE = "notApplicable" + UNKNOWN = "unknown" @classmethod def choices(cls) -> List[str]: @@ -332,11 +336,16 @@ def choices(cls) -> List[str]: 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]: diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index af768af1..c5f4147b 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -299,6 +299,7 @@ def compute_changes( log.debug(f"Switch {ip} is idempotent — no changes needed") changes["idempotent"].append(prop_sw) else: + diff_keys = {k for k in set(prop_dict) | set(existing_dict) if prop_dict.get(k) != existing_dict.get(k)} log.info( f"Switch {ip} has differences — marking to_update. " f"Changed fields: {diff_keys}" @@ -671,7 +672,12 @@ def build_proposed( None, ) if existing_match: - proposed.append(existing_match) + if cfg.role is not None: + data = existing_match.model_dump(by_alias=True) + data["switchRole"] = cfg.role.value if isinstance(cfg.role, SwitchRole) else cfg.role + proposed.append(SwitchDataModel.model_validate(data)) + else: + proposed.append(existing_match) log.debug( f"Switch {seed_ip} already in fabric inventory — " f"using existing record (discovery skipped)" @@ -2794,7 +2800,23 @@ def _handle_overridden_state( diff["to_update"] = [] - # Phase 3: Delegate add + migration to merged state + # Phase 3: Re-discover switches that were just deleted (they were + # skipped during initial discovery because they were already in the + # fabric). + update_ips = {sw.fabric_management_ip for sw in switches_to_delete} + configs_needing_rediscovery = [ + cfg for cfg in proposed_config if cfg.seed_ip in update_ips + ] + if configs_needing_rediscovery: + self.log.info( + f"Re-discovering {len(configs_needing_rediscovery)} switch(es) " + f"after deletion for re-add: " + f"{[cfg.seed_ip for cfg in configs_needing_rediscovery]}" + ) + fresh_discovered = self.discovery.discover(configs_needing_rediscovery) + discovered_data = {**(discovered_data or {}), **fresh_discovered} + + # Phase 4: Delegate add + migration to merged state self._handle_merged_state(diff, proposed_config, discovered_data) self.log.debug("EXIT: _handle_overridden_state()") diff --git a/tests/integration/targets/nd_manage_switches/defaults/main.yaml b/tests/integration/targets/nd_manage_switches/defaults/main.yaml new file mode 100644 index 00000000..5f709c5a --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/defaults/main.yaml @@ -0,0 +1,2 @@ +--- +testcase: "*" diff --git a/tests/integration/targets/nd_manage_switches/meta/main.yaml b/tests/integration/targets/nd_manage_switches/meta/main.yaml new file mode 100644 index 00000000..32cf5dda --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/meta/main.yaml @@ -0,0 +1 @@ +dependencies: [] diff --git a/tests/integration/targets/nd_manage_switches/tasks/base_tasks.yaml b/tests/integration/targets/nd_manage_switches/tasks/base_tasks.yaml new file mode 100644 index 00000000..da143944 --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tasks/base_tasks.yaml @@ -0,0 +1,67 @@ +--- +- name: Test Entry Point - [nd_manage_switches] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ Executing Base Tests - [nd_manage_switches] +" + - "----------------------------------------------------------------" + +# -------------------------------- +# Create Dictionary of Test Data +# -------------------------------- +- name: Base - Setup Internal TestCase Variables + ansible.builtin.set_fact: + test_data: + test_fabric: "{{ ansible_it_fabric }}" + sw1: "{{ ansible_switch1 }}" + sw2: "{{ ansible_switch2 }}" + sw3: "{{ ansible_switch3 }}" + deploy: "{{ deploy }}" + delegate_to: localhost + +# ---------------------------------------------- +# Create Module Payloads using Jinja2 Templates +# ---------------------------------------------- + +- name: Base - Prepare Configuration + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{test_data.sw1}}" + auth_proto: MD5 + role: leaf + - seed_ip: "{{test_data.sw2}}" + auth_proto: MD5 + role: spine + - seed_ip: "{{test_data.sw3}}" + auth_proto: MD5 + role: border + delegate_to: localhost + + +- name: Import Configuration Prepare Tasks + vars: + file: base + ansible.builtin.import_tasks: ./conf_prep_tasks.yaml + +# ---------------------------------------------- +# Test Setup +# ---------------------------------------------- + +- name: Base - Verify fabric is reachable via API + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}" + method: get + register: fabric_query + ignore_errors: true + +- name: Base - Assert fabric exists + ansible.builtin.assert: + that: + - fabric_query.status == 200 + fail_msg: "Fabric '{{ test_data.test_fabric }}' not found (HTTP {{ fabric_query.status }})." + success_msg: "Fabric '{{ test_data.test_fabric }}' found." + +- name: Base - Clean Up Existing Devices in Fabric + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted \ No newline at end of file diff --git a/tests/integration/targets/nd_manage_switches/tasks/conf_prep_tasks.yaml b/tests/integration/targets/nd_manage_switches/tasks/conf_prep_tasks.yaml new file mode 100644 index 00000000..dce2fdec --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tasks/conf_prep_tasks.yaml @@ -0,0 +1,11 @@ +--- +- name: Build Fabric Base Config Data + ansible.builtin.template: + src: nd_manage_switches_conf.j2 + dest: "{{ role_path }}/files/nd_manage_switches_{{file}}_conf.yaml" + delegate_to: localhost + +- name: Access Fabric Configuration Data and Save to Local Variable + ansible.builtin.set_fact: + "{{ 'nd_switches_' + file +'_conf' }}": "{{ lookup('file', '{{ role_path }}/files/nd_manage_switches_{{file}}_conf.yaml') | from_yaml }}" + delegate_to: localhost diff --git a/tests/integration/targets/nd_manage_switches/tasks/main.yaml b/tests/integration/targets/nd_manage_switches/tasks/main.yaml new file mode 100644 index 00000000..834955ba --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tasks/main.yaml @@ -0,0 +1,17 @@ +--- +- name: Discover ND Test Cases + ansible.builtin.find: + paths: "{{ role_path }}/tests/nd" + patterns: "{{ testcase }}.yaml" + connection: local + register: nd_testcases + +- name: Build List of Test Items + ansible.builtin.set_fact: + test_items: "{{ nd_testcases.files | map(attribute='path') | list }}" + +- name: Run ND Test Cases + ansible.builtin.include_tasks: "{{ test_case_to_run }}" + with_items: "{{ test_items }}" + loop_control: + loop_var: test_case_to_run diff --git a/tests/integration/targets/nd_manage_switches/tasks/query_task.yaml b/tests/integration/targets/nd_manage_switches/tasks/query_task.yaml new file mode 100644 index 00000000..7f851042 --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tasks/query_task.yaml @@ -0,0 +1,33 @@ +--- +- name: "Query Task: Authenticate with ND to get token" + ansible.builtin.uri: + url: "https://{{ ansible_host }}:{{ ansible_httpapi_port | default(443) }}/login" + method: POST + headers: + Content-Type: "application/json" + body_format: json + body: + domain: "{{ ansible_httpapi_login_domain | default('local') }}" + userName: "{{ ansible_user }}" + userPasswd: "{{ ansible_password }}" + validate_certs: false + return_content: true + status_code: + - 200 + register: nd_auth_response + delegate_to: localhost + +- name: "Query Task: Query {{ test_data.test_fabric }} switch data from ND" + ansible.builtin.uri: + url: "https://{{ ansible_host }}:{{ ansible_httpapi_port | default(443) }}/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: GET + headers: + Authorization: "Bearer {{ nd_auth_response.json.jwttoken }}" + Content-Type: "application/json" + validate_certs: false + return_content: true + status_code: + - 200 + - 404 + register: query_result + delegate_to: localhost diff --git a/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 b/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 new file mode 100644 index 00000000..fd4978fa --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 @@ -0,0 +1,62 @@ +--- +# This ND test data structure is auto-generated +# DO NOT EDIT MANUALLY +# + +# ------------------------------ +# Fabric Switches +# ------------------------------ + +{% if switch_conf is iterable %} +{% set switch_list = [] %} +{% for switch in switch_conf %} +{% set switch_item = {} %} +{% if switch.seed_ip is defined %} +{% set _ = switch_item.update({'seed_ip': switch.seed_ip | default('') }) %} +{% endif %} +{% set _ = switch_item.update({'user_name': switch_username}) %} +{% set _ = switch_item.update({'password': switch_password}) %} +{% if switch.role is defined %} +{% set _ = switch_item.update({'role': switch.role | default('') }) %} +{% endif %} +{% if switch.poap is defined %} +{% for sw_poap_item in switch.poap %} +{% set poap_item = {} %} +{% if sw_poap_item.preprovision_serial is defined and sw_poap_item.preprovision_serial %} +{% set _ = poap_item.update({'preprovision_serial': sw_poap_item.preprovision_serial}) %} +{% endif %} +{% if sw_poap_item.serial_number is defined and sw_poap_item.serial_number %} +{% set _ = poap_item.update({'serial_number': sw_poap_item.serial_number}) %} +{% endif %} +{% if sw_poap_item.model is defined and sw_poap_item.model %} +{% set _ = poap_item.update({'model': sw_poap_item.model}) %} +{% endif %} +{% if sw_poap_item.version is defined and sw_poap_item.version %} +{% set _ = poap_item.update({'version': sw_poap_item.version}) %} +{% endif %} +{% if sw_poap_item.hostname is defined and sw_poap_item.hostname %} +{% set _ = poap_item.update({'hostname': sw_poap_item.hostname}) %} +{% endif %} +{% if sw_poap_item.config_data is defined %} +{% set poap_config_item = {} %} +{% for sw_poap_config_item in sw_poap_item.config_data %} +{% set _ = poap_config_item.update({sw_poap_config_item: sw_poap_item.config_data[sw_poap_config_item]}) %} +{% endfor %} +{% set _ = poap_item.update({'config_data': poap_config_item}) %} +{% endif %} +{% set _ = switch_item.update({'poap': [poap_item]}) %} +{% endfor %} +{% else %} +{% if switch.auth_proto is defined %} +{% set _ = switch_item.update({'auth_proto': switch.auth_proto | default('') }) %} +{% endif %} +{% if switch.preserve_config is defined %} +{% set _ = switch_item.update({'preserve_config': switch.preserve_config | default('') }) %} +{% else %} +{% set _ = switch_item.update({'preserve_config': false }) %} +{% endif %} +{% endif %} +{% set _ = switch_list.append(switch_item) %} +{% endfor %} +{{ switch_list | to_nice_yaml(indent=2) }} +{% endif %} \ No newline at end of file diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/deleted.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/deleted.yaml new file mode 100644 index 00000000..97202466 --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tests/nd/deleted.yaml @@ -0,0 +1,143 @@ +--- + +- name: Import ND Manage Switches Base Tasks + ansible.builtin.import_tasks: ../../tasks/base_tasks.yaml + tags: deleted + +# ---------------------------------------------- +# Run Test Cases +# ---------------------------------------------- +# TC - 1 +- name: Deleted TC1 - Prepare Switches in Fabric - GreenField Deployment + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_base_conf }}" + deploy: "{{ test_data.deploy }}" + register: merged_result + tags: deleted + +- name: Deleted TC1 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: deleted + +- name: Debug - Print Query Result + ansible.builtin.debug: + var: query_result + tags: deleted + +- name: Deleted TC1 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_base_conf }}" + changed: "{{merged_result.changed}}" + register: result + tags: deleted + +# TC - 2 +- name: Deleted TC2 - Delete a Switch from the Fabric + cisco.nd.nd_manage_switches: &conf_del + fabric: "{{ test_data.test_fabric }}" + state: deleted + config: + - seed_ip: "{{ test_data.sw1 }}" + register: delete_result + tags: deleted + +- name: Deleted TC2 - Prepare Test Data + ansible.builtin.set_fact: + nd_switches_delete_conf: "{{ nd_switches_base_conf | rejectattr('seed_ip', 'equalto', test_data.sw1) | list }}" + delegate_to: localhost + tags: deleted + +- name: Deleted TC2 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: deleted + +- name: Debug - Print Query Result + ansible.builtin.debug: + var: query_result + tags: deleted + +- name: Deleted TC2 - Validate nd Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_delete_conf }}" + changed: "{{ delete_result.changed }}" + register: result + tags: deleted + +# TC - 3 +- name: Deleted TC3 - Removing a previously Deleted Switch - Idempotence + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted + config: + - seed_ip: "{{ test_data.sw1 }}" + register: delete_result + register: result + tags: deleted + +- name: Debug - Print Query Result + ansible.builtin.debug: + var: result + tags: deleted + +- name: Assert + ansible.builtin.assert: + that: + - 'result.changed == false' + # - 'result.response == "The switch provided is not part of the fabric and cannot be deleted"' + tags: deleted + +# TC - 4 +- name: Deleted TC4 - Delete all Switches from Fabric + cisco.nd.nd_manage_switches: &conf_del_all + fabric: "{{ test_data.test_fabric }}" + state: deleted + register: delete_result + tags: deleted + +- name: Deleted TC4 - Prepare Test Data + ansible.builtin.set_fact: + nd_switches_delete_conf: [] + delegate_to: localhost + tags: deleted + +- name: Deleted TC4 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: deleted + +- name: Debug - Print Query Result + ansible.builtin.debug: + var: query_result + tags: deleted + +- name: Deleted TC4 - Validate nd Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_delete_conf }}" + changed: "{{ delete_result.changed }}" + register: result + tags: deleted + +# TC - 5 +- name: Deleted TC5 - Delete all Switches from Fabric - Idempotence + cisco.nd.nd_manage_switches: *conf_del_all + register: result + tags: deleted + +- name: Assert + ansible.builtin.assert: + that: + - 'result.changed == false' + tags: deleted \ No newline at end of file diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/gathered.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/gathered.yaml new file mode 100644 index 00000000..6fb378d9 --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tests/nd/gathered.yaml @@ -0,0 +1,64 @@ +--- +- name: Import ND Manage Switches Base Tasks + ansible.builtin.import_tasks: ../../tasks/base_tasks.yaml + tags: query + +# ---------------------------------------------- +# Run Test Cases +# ---------------------------------------------- + +# TC - 1 +- name: Query TC1 - Merge a Switch using GreenField Deployment + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_base_conf }}" + deploy: "{{ test_data.deploy }}" + register: create_result + tags: query + +- name: Query TC1 - Gather Switch State in Fabric + cisco.nd.nd_manage_switches: + state: gathered + fabric: "{{ test_data.test_fabric }}" + register: query_result + tags: query + +- name: Query TC1 - Build Gathered Lookup + ansible.builtin.set_fact: + gathered_seeds: "{{ query_result.gathered | map(attribute='seed_ip') | list }}" + gathered_role_map: "{{ query_result.gathered | items2dict(key_name='seed_ip', value_name='role') }}" + delegate_to: localhost + tags: query + +- name: Query TC1 - Validate Gathered Count + ansible.builtin.assert: + that: + - query_result.gathered | length == nd_switches_base_conf | length + fail_msg: >- + Gathered count {{ query_result.gathered | length }} does not match + expected {{ nd_switches_base_conf | length }} + tags: query + +- name: Query TC1 - Validate Each Switch Present and Role Matches + ansible.builtin.assert: + that: + - item.seed_ip in gathered_seeds + - "'role' not in item or gathered_role_map[item.seed_ip] == item.role" + fail_msg: >- + Switch {{ item.seed_ip }} missing from gathered output or role mismatch + (expected={{ item.role | default('any') }}, + got={{ gathered_role_map[item.seed_ip] | default('not found') }}) + loop: "{{ nd_switches_base_conf }}" + tags: query + +# ---------------------------------------------- +# Cleanup Fabric Switches +# ---------------------------------------------- + +- name: Query - Cleanup Fabric + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted + register: result + tags: query diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/merged.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/merged.yaml new file mode 100644 index 00000000..2b8dc056 --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tests/nd/merged.yaml @@ -0,0 +1,318 @@ +--- + +- name: Import ND Manage Switches Base Tasks + ansible.builtin.import_tasks: ../../tasks/base_tasks.yaml + tags: merged + +# ---------------------------------------------- +# Run Test Cases +# ---------------------------------------------- +# TC - 1 +- name: Merged TC1 - Merge a Switch using GreenField Deployment + cisco.nd.nd_manage_switches: &conf + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_base_conf }}" + deploy: "{{ test_data.deploy }}" + register: merged_result + tags: merged + +- name: Merged TC1 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: deleted + +- name: Merged TC1 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_base_conf }}" + changed: "{{ merged_result.changed }}" + register: result + tags: merged + +# TC - 2 +- name: Merged TC2 - Idempotence + cisco.nd.nd_manage_switches: *conf + register: result + tags: merged + +- name: Assert + ansible.builtin.assert: + that: + - 'result.changed == false' + # - 'result.response == "The switch provided is already part of the fabric and cannot be created again"' + tags: merged + +# TC - 3 +- name: Merged TC3 - Clean up Existing Switches + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted + register: delete_result + tags: merged + +- name: Merged TC3 - Prepare Test Data + ansible.builtin.set_fact: + nd_switches_delete_conf: [] + delegate_to: localhost + tags: merged + +- name: Merged TC3 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: deleted + +- name: Merged TC3 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_delete_conf }}" + changed: "{{ delete_result.changed }}" + register: result + tags: merged + +# TC - 4 +- name: Merged TC4 - Prepare Config + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw1 }}" + role: leaf + auth_proto: MD5 + preserve_config: true + delegate_to: localhost + tags: merged + +- name: Import Configuration Prepare Tasks + vars: + file: merge + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + tags: merged + +- name: Merged TC4 - Merge a Switch using BrownField Deployment + cisco.nd.nd_manage_switches: &conf_bf + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_merge_conf }}" + deploy: "{{ test_data.deploy }}" + register: merged_result + tags: merged + +- name: Merged TC4 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: deleted + +- name: Merged TC4 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_merge_conf }}" + changed: "{{ merged_result.changed }}" + register: result + tags: merged + +# TC - 5 +- name: Merged TC5 - Verify Idempotence + cisco.nd.nd_manage_switches: *conf_bf + register: result + tags: merged + +- name: Assert + ansible.builtin.assert: + that: + - 'result.changed == false' + # - 'result.response == "The switch provided is already part of the fabric and cannot be created again"' + tags: merged + +# TC - 6 +- name: Merged TC6 - Clean up Existing Switches + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted + register: deleted_result + tags: merged + +- name: Merged TC6 - Prepare Config + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw1 }}" + delegate_to: localhost + tags: merged + +- name: Import Configuration Prepare Tasks + vars: + file: merge + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + tags: merged + +- name: Merged TC6 - Merge a Switch using GreenField Deployment - Using default role/auth_proto + cisco.nd.nd_manage_switches: &conf_def + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_merge_conf }}" + deploy: "{{ test_data.deploy }}" + register: merged_result + tags: merged + +- name: Merged TC6 - Prepare Config + ansible.builtin.set_fact: + nd_switches_mergev_conf: + - seed_ip: "{{ test_data.sw1 }}" + role: leaf # default role in ND + delegate_to: localhost + tags: merged + +- name: Merged TC6 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: deleted + +- name: Merged TC6 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_mergev_conf }}" + changed: "{{ merged_result.changed }}" + register: result + tags: merged + +# TC - 7 +- name: Merged TC7 - Verify Idempotence + cisco.nd.nd_manage_switches: *conf_def + register: result + tags: merged + +- name: Assert + ansible.builtin.assert: + that: + - 'result.changed == false' + # - 'result.response == "The switch provided is already part of the fabric and cannot be created again"' + tags: merged + +# TC - 8 +- name: Merged TC8 - Clean up Existing Switches + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted + register: deleted_result + tags: merged + +# TC - 9 +- name: Merged TC9 - Prepare Config + ansible.builtin.set_fact: + switch_conf: + - seed_ip: + role: leaf + auth_proto: MD5 + delegate_to: localhost + tags: merged + +- name: Import Configuration Prepare Tasks + vars: + file: merge + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + tags: merged + +- name: Merged TC9 - Merge a Switch without seed_ip + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_merge_conf }}" + deploy: "{{ test_data.deploy }}" + ignore_errors: true + register: merged_result + tags: merged + +- name: Assert + ansible.builtin.assert: + that: + - 'merged_result.changed == false' + - '"seed_ip cannot be empty" in merged_result.msg' + tags: merged + +# TC - 10 +- name: Merged TC10 - Prepare Config + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw1 }}" + auth_proto: MD5 + role: invalid + delegate_to: localhost + tags: merged + +- name: Import Configuration Prepare Tasks + vars: + file: merge + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + tags: merged + +- name: Merged TC10 - Merge a Switch with Invalid Role + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_merge_conf }}" + deploy: "{{ test_data.deploy }}" + register: merged_result + ignore_errors: true + tags: merged + +- name: Assert + ansible.builtin.assert: + that: + - 'merged_result.changed == false' + - '"Invalid SwitchRole: invalid" in merged_result.msg' + tags: merged + +# TC - 11 +- name: Merged TC11 - Prepare Config + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw1 }}" + auth_proto: MD55DM + role: leaf + delegate_to: localhost + tags: merged + +- name: Import Configuration Prepare Tasks + vars: + file: merge + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + tags: merged + +- name: Merged TC11 - Merge a Switch with invalid auth choice + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_merge_conf }}" + deploy: "{{ test_data.deploy }}" + register: merged_result + ignore_errors: true + tags: merged + +- name: Assert + ansible.builtin.assert: + that: + - 'merged_result.changed == false' + - '"Invalid SnmpV3AuthProtocol: MD55DM" in merged_result.msg' + tags: merged + +# TC - 12 +- name: Merged TC12 - Merge a Switch without a config + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: merged + register: merged_result + ignore_errors: true + tags: merged + +- name: Assert + ansible.builtin.assert: + that: + - 'merged_result.changed == false' + - '"state is merged but all of the following are missing: config" in merged_result.msg' + tags: merged \ No newline at end of file diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/overridden.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/overridden.yaml new file mode 100644 index 00000000..75390bec --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tests/nd/overridden.yaml @@ -0,0 +1,166 @@ +--- +- name: Import ND Manage Switches Base Tasks + ansible.builtin.import_tasks: ../../tasks/base_tasks.yaml + tags: overridden + +# ---------------------------------------------- +# Run Test Cases +# ---------------------------------------------- + +# TC - 1 +- name: Overridden TC1 - Prepare Switches in Fabric - GreenField Deployment + cisco.nd.nd_manage_switches: &conf + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_base_conf }}" + deploy: "{{ test_data.deploy }}" + register: merged_result + tags: overridden + +- name: Overridden TC1 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: overridden + +- name: Overridden TC1 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_base_conf }}" + changed: " {{ merged_result.changed }}" + register: result + tags: overridden + +# TC - 2 +- name: Overridden TC2 - Verify Idempotence + cisco.nd.nd_manage_switches: *conf + register: result + tags: overridden + +- name: Assert + ansible.builtin.assert: + that: + - 'result.changed == false' + tags: overridden + +# TC - 3 +- name: Overridden TC3 - Prepare Config + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw2 }}" + role: spine + preserve_config: false + delegate_to: localhost + tags: overridden + +- name: Import Configuration Prepare Tasks + vars: + file: overridden + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + tags: overridden + +- name: Overridden TC3 - Override Existing Switch - Removes Other Switches from Fabric + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: overridden + config: "{{ nd_switches_overridden_conf }}" + deploy: "{{ test_data.deploy }}" + register: overridden_result + tags: overridden + +- name: Overridden TC3 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: overridden + +- name: Overridden TC3 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_overridden_conf }}" + changed: "{{ overridden_result.changed }}" + register: result + tags: overridden + +# TC - 4 +- name: Overridden TC4 - Prepare Config + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw2 }}" + role: leaf + preserve_config: false + delegate_to: localhost + tags: overridden + +- name: Import Configuration Prepare Tasks + vars: + file: overridden + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + tags: overridden + +- name: Overridden TC4 - New Role for the Existing Switch + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: overridden + config: "{{ nd_switches_overridden_conf }}" + deploy: "{{ test_data.deploy }}" + register: overridden_result + tags: overridden + +- name: Overridden TC4 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: overridden + +- name: Overridden TC4 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_overridden_conf }}" + changed: "{{ overridden_result.changed }}" + register: result + tags: overridden + +# TC - 5 +- name: Overridden TC5 - Prepare Config + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw2 }}" + preserve_config: false + delegate_to: localhost + tags: overridden + +- name: Import Configuration Prepare Tasks + vars: + file: overridden + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + tags: overridden + +- name: Overridden TC5 - Unspecified Role for the Existing Switch (Default, Leaf) + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: overridden + config: "{{ nd_switches_overridden_conf }}" + deploy: "{{ test_data.deploy }}" + register: overridden_result + tags: overridden + +- name: Assert + ansible.builtin.assert: + that: + - 'overridden_result.changed == false' + tags: overridden + +# ---------------------------------------------- +# Cleanup Fabric Switches +# ---------------------------------------------- + +- name: Overridden - Cleanup Fabric Switches + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted + register: result + tags: overridden diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml new file mode 100644 index 00000000..c098c7ca --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml @@ -0,0 +1,265 @@ +--- +- name: Test Entry Point - [nd_manage_switches - Poap] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ Executing Poap Tests - [nd_manage_switches] +" + - "----------------------------------------------------------------" + tags: poap + +- name: Poap - Setup Internal TestCase Variables + ansible.builtin.set_fact: + test_data: + test_fabric: "{{ ansible_it_fabric }}" + sw1: "{{ ansible_switch1 }}" + sw1_serial: "1ABC23DEFGH" + sw2: "{{ ansible_switch2 }}" + sw2_serial: "1ABC23DEFHI" + poap_model: "ABC-D1230a" + poap_version: "1.2(3)" + prepro_hostname: "PreProv-SW" + poap_hostname: "Poap-SW" + poap_configmodel: "['ABC-D1230a']" + poap_gateway: "192.168.2.1/24" + sw3: "{{ ansible_switch3 }}" + deploy: "{{ deploy }}" + poap_enabled: false + delegate_to: localhost + tags: poap + +# Below commented tasks are sample tasks to enable Bootstrap and DHCP along with DHCP configs +# Please make sure you provide correct values for required fields +# Fabric config has many ND/DCNM auto generated values, so always GET the configs first +# and then set the required values. +# +# +# - name: Poap Merged - Get the configs of the fabric deployed. +# cisco.nd.nd_rest: +# path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}" +# method: get +# register: result + +# - set_fact: +# result.jsondata.management.day0Bootstrap = true +# result.jsondata.management.localDhcpServer = true +# result.jsondata.management.dhcpProtocolVersion = "dhcpv4" +# result.jsondata.management.dhcpStartAddress = "192.168.1.10" +# result.jsondata.management.dhcpEndAddress = "192.168.1.20" +# result.jsondata.management.managementGateway = "192.168.1.1" +# result.jsondata.management.managementIpv4Prefix = "24" +# +# - name: Poap Merged - Configure Bootstrap and DHCP on Fabric +# cisco.nd.nd_rest: +# method: PUT +# path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}" +# content: "{{ result.jsondata }}" +# + +# ---------------------------------------------- +# Run Test Cases +# ---------------------------------------------- +# Base Tests +- name: Base - Verify fabric is reachable via API + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}" + method: get + register: fabric_query + ignore_errors: true + tags: poap + +- name: Base - Assert fabric exists + ansible.builtin.assert: + that: + - fabric_query.status == 200 + fail_msg: "Fabric '{{ test_data.test_fabric }}' not found (HTTP {{ fabric_query.status }})." + success_msg: "Fabric '{{ test_data.test_fabric }}' found." + tags: poap + +- name: POAP Base Task - Set Variable + ansible.builtin.set_fact: + poap_enabled: true + when: fabric_query.status == 200 and fabric_query.jsondata.management.day0Bootstrap + tags: poap + +# TC1 +- name: POAP TC1 - Prepare Validate Config + ansible.builtin.set_fact: + nd_switches_delete_conf: + delegate_to: localhost + tags: poap + +- name: POAP TC1 - Clean Up Existing Switches + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted + register: deleted_result + tags: poap + +- name: POAP TC1 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: poap + +- name: POAP TC1 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_delete_conf }}" + register: result + tags: poap + +# ---------------------------------------------- # +# Merged # +# ---------------------------------------------- # + +# TC - 1 +- name: Poap TC1 - Prepare Configuration + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw2 }}" + user_name: '{{ switch_username }}' + password: '{{ switch_password }}' + role: border + poap: + - preprovision_serial: "{{ test_data.sw2_serial }}" + model: "{{ test_data.poap_model }}" + version: "{{ test_data.poap_version }}" + hostname: "{{ test_data.prepro_hostname }}" + config_data: + models: "{{ test_data.poap_configmodel }}" + gateway: "{{ test_data.poap_gateway }}" + when: poap_enabled == True + delegate_to: localhost + tags: poap + +- name: Import Configuration Prepare Tasks + vars: + file: poap + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + when: poap_enabled == True + tags: poap + +- name: Poap TC1 - Merged - Pre-provisioned Switch Configuration + cisco.nd.nd_manage_switches: &conf_prepro + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_poap_conf }}" + deploy: "{{ test_data.deploy }}" + when: poap_enabled == True + register: merged_result + tags: poap + +- name: Poap TC1 - Merged - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + when: poap_enabled == True + register: query_result + tags: poap + +- name: Poap TC1 - Merged - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_poap_conf }}" + changed: "{{ merged_result.changed }}" + when: poap_enabled == True + register: result + tags: poap + +# TC - 2 +- name: Poap TC2 - Merged - Verify Idempotence + cisco.nd.nd_manage_switches: *conf_prepro + when: poap_enabled == True + register: merged_result + tags: poap + +- name: Assert + ansible.builtin.assert: + that: + - 'merged_result.changed == false' + # - 'merged_result.response == "The switch provided is already part of the fabric and cannot be created again"' + when: poap_enabled == True + tags: poap + +# TC - 3 +- name: Poap TC3 - Prepare Configuration + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw1 }}" + role: leaf + poap: + - serial_number: "{{ test_data.sw1_serial }}" + model: "{{ test_data.poap_model }}" + version: "{{ test_data.poap_version }}" + hostname: "{{ test_data.poap_hostname }}" + config_data: + models: "{{ test_data.poap_configmodel }}" + gateway: "{{ test_data.poap_gateway }}" + - seed_ip: "{{ test_data.sw3 }}" + auth_proto: MD5 + role: spine + when: poap_enabled == True + delegate_to: localhost + tags: poap + +- name: Import Configuration Prepare Tasks + vars: + file: poap + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + when: poap_enabled == True + tags: poap + +- name: Poap TC3 - Merge Config + cisco.nd.nd_manage_switches: &conf_poap + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_poap_conf }}" + deploy: "{{ test_data.deploy }}" + when: poap_enabled == True + register: merged_result + tags: poap + +- name: Poap TC3 - Merged - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + when: poap_enabled == True + register: query_result + tags: poap + +- name: Poap TC3 - Merged - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_poap_conf }}" + changed: "{{ merged_result.changed }}" + when: poap_enabled == True + register: result + tags: poap + +# TC - 4 +- name: Poap TC4 - Verify Idempotence + cisco.nd.nd_manage_switches: *conf_poap + when: poap_enabled == True + register: result + tags: poap + +- name: Assert + ansible.builtin.assert: + that: + - 'result.changed == false' + # - 'result.response == "The switch provided is already part of the fabric and cannot be created again"' + when: poap_enabled == True + tags: poap + +# ---------------------------------------------- +# Cleanup Fabric Switches +# ---------------------------------------------- + +- name: Poap - Clean Up Existing Devices + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted + when: poap_enabled == True + register: deleted_result + tags: poap diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml new file mode 100644 index 00000000..d214ac33 --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml @@ -0,0 +1,182 @@ +--- +- name: Test Entry Point - [nd_manage_switches - RMA] + ansible.builtin.debug: + msg: + - "----------------------------------------------------------------" + - "+ Executing RMA Tests - [nd_manage_switches] +" + - "----------------------------------------------------------------" + tags: rma + +- name: RMA - Setup Internal TestCase Variables + ansible.builtin.set_fact: + test_data: + test_fabric: "{{ ansible_it_fabric }}" + sw1: "{{ ansible_switch1 }}" + sw1_serial: "1ABC23DEFGH" + sw1_rma_serial: "1ABC23DERMA" + rma_model: "SW1-K1234v" + rma_version: "12.3(4)" + rma_hostname: "RMA-SW" + rma_configmodel: "['SW1-K1234v']" + rma_gateway: "192.168.2.1/24" + deploy: "{{ deploy }}" + rma_enabled: false + delegate_to: localhost + tags: rma + +# ---------------------------------------------- +# Run Test Cases +# ---------------------------------------------- +# Base Tests +- name: Base - Verify fabric is reachable via API + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}" + method: get + register: fabric_query + ignore_errors: true + +- name: Base - Assert fabric exists + ansible.builtin.assert: + that: + - fabric_query.status == 200 + fail_msg: "Fabric '{{ test_data.test_fabric }}' not found (HTTP {{ fabric_query.status }})." + success_msg: "Fabric '{{ test_data.test_fabric }}' found." + +- name: RMA Base Task - Set Variable + ansible.builtin.set_fact: + rma_enabled: true + when: fabric_query.status == 200 and fabric_query.jsondata.management.day0Bootstrap + tags: rma + +# TC1 +- name: RMA TC1 - Prepare Validate Config + ansible.builtin.set_fact: + nd_switches_delete_conf: + delegate_to: localhost + tags: rma + +- name: RMA TC1 - Clean Up Existing Switches + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted + register: deleted_result + tags: rma + +- name: RMA TC1 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: rma + +- name: RMA TC1 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_delete_conf }}" + register: result + tags: rma + +# Tasks to add a switch to fabric and to configure and deploy +# the switch in maintenance mode. +# Please note that the switch should be shutdown after configuring it +# in maintenance mode + +# TC2 +- name: RMA TC2 - Prepare Configuration + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw1 }}" + auth_proto: MD5 + when: rma_enabled == True + delegate_to: localhost + tags: rma + +- name: Import Configuration Prepare Tasks + vars: + file: rma + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + when: rma_enabled == True + tags: rma + +- name: RMA TC2 - Add Switch to the Fabric + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_rma_conf }}" + deploy: "{{ test_data.deploy }}" + when: rma_enabled == True + register: merged_result + tags: rma + +- name: RMA TC2 - Query Switch State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + when: rma_enabled == True + register: query_result + tags: rma + +- name: RMA TC2 - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_rma_conf }}" + when: rma_enabled == True + register: result + tags: rma + +- name: RMA TC2 - Change System Mode to Maintenance, Deploy and Block until Complete + cisco.nd.nd_rest: + path: "/api/v1/manage/inventory/switchActions/changeSystemMode?deploy=true&blocking=true" + method: POST + content: + mode: "maintenance" + switchIds: + - "{{ test_data.sw1_serial }}" + register: change_system_mode_result + when: (rma_enabled == True) + tags: rma + +# TC3 +- block: + - name: RMA TC3 - RMA the Existing Switch + cisco.nd.nd_manage_switches: + fabric: '{{ test_data.test_fabric }}' + state: merged + config: + - seed_ip: '{{ test_data.sw1 }}' + user_name: '{{ switch_username }}' + password: '{{ switch_password }}' + rma: + - serial_number: '{{ test_data.sw1_rma_serial }}' + old_serial: '{{ test_data.sw1_serial }}' + model: '{{ test_data.rma_model }}' + version: '{{ test_data.rma_version }}' + hostname: '{{ test_data.rma_hostname }}' + config_data: + models: '{{ test_data.rma_configmodel }}' + gateway: '{{ test_data.rma_gateway }}' + register: result + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'result.changed == true' + + - name: ASSERT - Check condition + ansible.builtin.assert: + that: + - 'item["RETURN_CODE"] == 200' + loop: '{{ result.response }}' + when: (rma_enabled == True) + tags: rma + +# ---------------------------------------------- +# Cleanup Fabric Switches +# ---------------------------------------------- + +- name: RMA - Clean Up - Remove Existing Switches + cisco.nd.nd_manage_switches: + fabric: "{{ test_data.test_fabric }}" + state: deleted + register: result + tags: rma diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml new file mode 100644 index 00000000..f66b59ed --- /dev/null +++ b/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml @@ -0,0 +1,184 @@ +--- + +- name: Import ND Manage Switches Base Tasks + ansible.builtin.import_tasks: ../../tasks/base_tasks.yaml + tags: sanity + +# ---------------------------------------------- +# Run Test Cases +# ---------------------------------------------- + +# ---------------------------------------------- # +# Merged # +# ---------------------------------------------- # + +# TC - 1 +- name: Sanity TC1 - Merged - Prepare Switches in Fabric - GreenField Deployment + cisco.nd.nd_manage_switches: &conf + fabric: "{{ test_data.test_fabric }}" + state: merged + config: "{{ nd_switches_base_conf }}" + deploy: "{{ test_data.deploy }}" + register: create_result + tags: sanity + +- name: Sanity TC1 - Merged - Query Inventory State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: sanity + +- name: Sanity TC1 - Merged - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_base_conf }}" + changed: "{{ create_result.changed }}" + register: result + tags: sanity + +# TC - 2 +- name: Sanity TC2 - Merged - Idempotence + cisco.nd.nd_manage_switches: *conf + register: result + tags: sanity + +- name: Assert + ansible.builtin.assert: + that: + - 'result.changed == false' + # - 'result.response == "The switch provided is already part of the fabric and cannot be created again"' + tags: sanity + +# ---------------------------------------------- # +# Query # +# ---------------------------------------------- # + +# # TC - 3 +# - name: Sanity TC3 - Query - Prepare Conf +# ansible.builtin.set_fact: +# nd_switches_sanity_conf: +# - seed_ip: "{{ test_data.sw1 }}" +# role: leaf +# delegate_to: localhost +# tags: sanity + +# - name: Sanity TC3 - Query - Query a Switch - Hostname and Role must match +# cisco.nd.nd_manage_switches: +# fabric: "{{ test_data.test_fabric }}" +# state: query +# config: "{{ nd_switches_sanity_conf }}" +# register: query_result +# tags: sanity + +# - name: Sanity TC3 - Query - Validate ND Data +# cisco.nd.nd_inventory_validate: +# nd_data: "{{ query_result }}" +# test_data: "{{ nd_switches_sanity_conf }}" +# changed: "{{ create_result.changed }}" +# register: result +# tags: sanity + +# ---------------------------------------------- # +# Overridden # +# ---------------------------------------------- # + +# TC - 4 +- name: Sanity TC4 - Overridden - Prepare Conf + ansible.builtin.set_fact: + switch_conf: + - seed_ip: "{{ test_data.sw2 }}" + role: leaf + preserve_config: false + delegate_to: localhost + tags: sanity + +- name: Import Configuration Prepare Tasks + vars: + file: sanity + ansible.builtin.import_tasks: ../../tasks/conf_prep_tasks.yaml + tags: sanity + +- name: Sanity TC4 - Overridden - Update a New Switch using GreenField Deployment - Delete and Create - default role + cisco.nd.nd_manage_switches: &conf_over + fabric: "{{ test_data.test_fabric }}" + state: overridden + config: "{{ nd_switches_sanity_conf }}" + deploy: "{{ test_data.deploy }}" + register: result + tags: sanity + +- name: Sanity TC4 - Overridden - Query Inventory State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: sanity + +- name: Sanity TC4 - Overridden - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_sanity_conf }}" + changed: "{{ create_result.changed }}" + register: result + tags: sanity + +# TC - 5 +- name: Sanity TC5 - Overridden - Idempotence + cisco.nd.nd_manage_switches: *conf_over + register: result + tags: sanity + +- name: Assert + ansible.builtin.assert: + that: + - 'result.changed == false' + # - 'result.response == "The switch provided is already part of the fabric and there is no more device to delete in the fabric"' + tags: sanity + +# ---------------------------------------------- # +# Clean-up # +# ---------------------------------------------- # + +# TC - 6 +- name: Sanity TC6 - Deleted - Clean up Existing devices + cisco.nd.nd_manage_switches: &clean + fabric: "{{ test_data.test_fabric }}" + state: deleted + config: "{{ nd_switches_sanity_conf }}" + register: deleted_result + tags: sanity + +- name: Sanity TC6 - Reset - Prepare Conf + ansible.builtin.set_fact: + nd_switches_sanity_conf: + delegate_to: localhost + tags: sanity + +- name: Sanity TC6 - Deleted - Query Inventory State in Fabric + cisco.nd.nd_rest: + path: "/api/v1/manage/fabrics/{{ test_data.test_fabric }}/switches" + method: get + register: query_result + tags: sanity + +- name: Sanity TC6 - Deleted - Validate ND Data + cisco.nd.nd_inventory_validate: + nd_data: "{{ query_result }}" + test_data: "{{ nd_switches_sanity_conf }}" + changed: "{{ deleted_result.changed }}" + register: result + tags: sanity + +# TC - 7 +- name: Sanity TC7 - Deleted - Idempotence + cisco.nd.nd_manage_switches: *clean + register: result + tags: sanity + +- name: Assert + ansible.builtin.assert: + that: + - 'result.changed == false' + # - 'result.response == "The switch provided is not part of the fabric and cannot be deleted"' + tags: sanity \ No newline at end of file From b4ecddc223fa18cdbb014a068368bc996c084e01 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Wed, 25 Mar 2026 16:28:31 +0530 Subject: [PATCH 21/27] Rename inventory validate to switches validate --- ...d_inventory_validate.py => nd_switches_validate.py} | 10 +++++----- .../targets/nd_manage_switches/tests/nd/deleted.yaml | 6 +++--- .../targets/nd_manage_switches/tests/nd/merged.yaml | 8 ++++---- .../nd_manage_switches/tests/nd/overridden.yaml | 6 +++--- .../targets/nd_manage_switches/tests/nd/poap.yaml | 6 +++--- .../targets/nd_manage_switches/tests/nd/rma.yaml | 4 ++-- .../targets/nd_manage_switches/tests/nd/sanity.yaml | 8 ++++---- 7 files changed, 24 insertions(+), 24 deletions(-) rename plugins/action/{nd_inventory_validate.py => nd_switches_validate.py} (97%) diff --git a/plugins/action/nd_inventory_validate.py b/plugins/action/nd_switches_validate.py similarity index 97% rename from plugins/action/nd_inventory_validate.py rename to plugins/action/nd_switches_validate.py index 024ba634..ed0c4b47 100644 --- a/plugins/action/nd_inventory_validate.py +++ b/plugins/action/nd_switches_validate.py @@ -4,9 +4,9 @@ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -"""ND Inventory Validation Action Plugin. +"""ND Switches Validation Action Plugin. -Validates switch inventory data returned from nd_rest against expected +Validates switch data returned from nd_rest against expected configuration entries. Checks that every entry in test_data has a matching switch in the ND API response (fabricManagementIp == seed_ip, switchRole == role). @@ -47,7 +47,7 @@ # Validation orchestration model # --------------------------------------------------------------------------- -class InventoryValidate(BaseModel): +class SwitchesValidate(BaseModel): """Orchestrates the match between playbook config entries and live ND inventory.""" config_data: Optional[List[Any]] = None @@ -202,7 +202,7 @@ def run(self, tmp=None, task_vars=None): if not HAS_PYDANTIC or not HAS_MODELS: results["failed"] = True - results["msg"] = "pydantic and the ND collection models are required for nd_inventory_validate" + results["msg"] = "pydantic and the ND collection models are required for nd_switches_validate" return results nd_data = self._task.args["nd_data"] @@ -248,7 +248,7 @@ def run(self, tmp=None, task_vars=None): # Role mode: only match by role, ignore seed_ip ignore_fields["seed_ip"] = 1 - validation = InventoryValidate( + validation = SwitchesValidate( config_data=test_data, nd_data=switches, ignore_fields=ignore_fields, diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/deleted.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/deleted.yaml index 97202466..04a2e4f2 100644 --- a/tests/integration/targets/nd_manage_switches/tests/nd/deleted.yaml +++ b/tests/integration/targets/nd_manage_switches/tests/nd/deleted.yaml @@ -30,7 +30,7 @@ tags: deleted - name: Deleted TC1 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_base_conf }}" changed: "{{merged_result.changed}}" @@ -66,7 +66,7 @@ tags: deleted - name: Deleted TC2 - Validate nd Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_delete_conf }}" changed: "{{ delete_result.changed }}" @@ -123,7 +123,7 @@ tags: deleted - name: Deleted TC4 - Validate nd Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_delete_conf }}" changed: "{{ delete_result.changed }}" diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/merged.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/merged.yaml index 2b8dc056..4520833b 100644 --- a/tests/integration/targets/nd_manage_switches/tests/nd/merged.yaml +++ b/tests/integration/targets/nd_manage_switches/tests/nd/merged.yaml @@ -25,7 +25,7 @@ tags: deleted - name: Merged TC1 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_base_conf }}" changed: "{{ merged_result.changed }}" @@ -67,7 +67,7 @@ tags: deleted - name: Merged TC3 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_delete_conf }}" changed: "{{ delete_result.changed }}" @@ -108,7 +108,7 @@ tags: deleted - name: Merged TC4 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_merge_conf }}" changed: "{{ merged_result.changed }}" @@ -174,7 +174,7 @@ tags: deleted - name: Merged TC6 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_mergev_conf }}" changed: "{{ merged_result.changed }}" diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/overridden.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/overridden.yaml index 75390bec..f952e8bc 100644 --- a/tests/integration/targets/nd_manage_switches/tests/nd/overridden.yaml +++ b/tests/integration/targets/nd_manage_switches/tests/nd/overridden.yaml @@ -25,7 +25,7 @@ tags: overridden - name: Overridden TC1 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_base_conf }}" changed: " {{ merged_result.changed }}" @@ -77,7 +77,7 @@ tags: overridden - name: Overridden TC3 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_overridden_conf }}" changed: "{{ overridden_result.changed }}" @@ -117,7 +117,7 @@ tags: overridden - name: Overridden TC4 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_overridden_conf }}" changed: "{{ overridden_result.changed }}" diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml index c098c7ca..d21d965b 100644 --- a/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml +++ b/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml @@ -103,7 +103,7 @@ tags: poap - name: POAP TC1 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_delete_conf }}" register: result @@ -159,7 +159,7 @@ tags: poap - name: Poap TC1 - Merged - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_poap_conf }}" changed: "{{ merged_result.changed }}" @@ -229,7 +229,7 @@ tags: poap - name: Poap TC3 - Merged - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_poap_conf }}" changed: "{{ merged_result.changed }}" diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml index d214ac33..c78ce2f6 100644 --- a/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml +++ b/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml @@ -70,7 +70,7 @@ tags: rma - name: RMA TC1 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_delete_conf }}" register: result @@ -117,7 +117,7 @@ tags: rma - name: RMA TC2 - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_rma_conf }}" when: rma_enabled == True diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml index f66b59ed..46d1f2a7 100644 --- a/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml +++ b/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml @@ -30,7 +30,7 @@ tags: sanity - name: Sanity TC1 - Merged - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_base_conf }}" changed: "{{ create_result.changed }}" @@ -72,7 +72,7 @@ # tags: sanity # - name: Sanity TC3 - Query - Validate ND Data -# cisco.nd.nd_inventory_validate: +# cisco.nd.nd_switches_validate: # nd_data: "{{ query_result }}" # test_data: "{{ nd_switches_sanity_conf }}" # changed: "{{ create_result.changed }}" @@ -116,7 +116,7 @@ tags: sanity - name: Sanity TC4 - Overridden - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_sanity_conf }}" changed: "{{ create_result.changed }}" @@ -163,7 +163,7 @@ tags: sanity - name: Sanity TC6 - Deleted - Validate ND Data - cisco.nd.nd_inventory_validate: + cisco.nd.nd_switches_validate: nd_data: "{{ query_result }}" test_data: "{{ nd_switches_sanity_conf }}" changed: "{{ deleted_result.changed }}" From 76426ac431e5a81d26fb8e6a2a0a06386e7f72a4 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Thu, 26 Mar 2026 12:19:47 +0530 Subject: [PATCH 22/27] Property changes for POAP, RMA. --- .../models/manage_switches/config_models.py | 44 ++++++------- plugins/module_utils/nd_switch_resources.py | 26 ++++---- .../utils/manage_switches/switch_helpers.py | 2 +- plugins/modules/nd_manage_switches.py | 25 ++++---- .../templates/nd_manage_switches_conf.j2 | 2 +- .../nd_manage_switches/tests/nd/poap.yaml | 2 +- .../nd_manage_switches/tests/nd/rma.yaml | 6 +- .../nd_manage_switches/tests/nd/sanity.yaml | 61 +++++++++++-------- 8 files changed, 90 insertions(+), 78 deletions(-) diff --git a/plugins/module_utils/models/manage_switches/config_models.py b/plugins/module_utils/models/manage_switches/config_models.py index 711bbb57..a9cada16 100644 --- a/plugins/module_utils/models/manage_switches/config_models.py +++ b/plugins/module_utils/models/manage_switches/config_models.py @@ -255,17 +255,17 @@ class RMAConfigModel(NDNestedModel): ) # Required fields for RMA - serial_number: str = Field( + new_serial_number: str = Field( ..., - alias="serialNumber", + alias="newSerialNumber", min_length=1, - description="Serial number of switch to Bootstrap for RMA" + description="Serial number of the new/replacement switch to Bootstrap for RMA" ) - old_serial: str = Field( + old_serial_number: str = Field( ..., - alias="oldSerial", + alias="oldSerialNumber", min_length=1, - description="Serial number of switch to be replaced by RMA" + description="Serial number of the existing switch to be replaced by RMA" ) model: Optional[str] = Field( default=None, @@ -296,7 +296,7 @@ class RMAConfigModel(NDNestedModel): ), ) - @field_validator('serial_number', 'old_serial', mode='before') + @field_validator('new_serial_number', 'old_serial_number', mode='before') @classmethod def validate_serial_numbers(cls, v: str) -> str: """Validate serial numbers are not empty.""" @@ -337,7 +337,7 @@ class SwitchConfigModel(NDBaseModel): # Fields excluded from diff — only seed_ip + role are compared exclude_from_diff: ClassVar[List[str]] = [ - "user_name", "password", "auth_proto", + "username", "password", "auth_proto", "preserve_config", "platform_type", "poap", "rma", "operation_type", ] @@ -351,7 +351,7 @@ class SwitchConfigModel(NDBaseModel): ) # Optional fields — required for merged/overridden, optional for query/deleted - user_name: Optional[str] = Field( + username: Optional[str] = Field( default=None, alias="userName", description="Login username to the switch (required for merged/overridden states)" @@ -413,11 +413,11 @@ def to_config_dict(self) -> Dict[str, Any]: """Return the playbook config as a dict with all credentials stripped. Returns: - Dict of config fields with ``user_name``, ``password``, + Dict of config fields with ``username``, ``password``, ``discovery_username``, and ``discovery_password`` excluded. """ return self.to_config(exclude={ - "user_name": True, + "username": True, "password": True, "poap": {"__all__": {"discovery_username": True, "discovery_password": True}}, "rma": {"__all__": {"discovery_username": True, "discovery_password": True}}, @@ -455,13 +455,13 @@ def validate_poap_rma_credentials(self) -> Self: """Validate credentials for POAP and RMA operations.""" if self.poap or self.rma: # POAP/RMA require credentials - if not self.user_name or not self.password: + if not self.username or not self.password: raise ValueError( - "For POAP and RMA operations, user_name and password are required" + "For POAP and RMA operations, username and password are required" ) # For POAP and RMA, username should be 'admin' - if self.user_name != "admin": - raise ValueError("For POAP and RMA operations, user_name should be 'admin'") + if self.username != "admin": + raise ValueError("For POAP and RMA operations, username should be 'admin'") return self @@ -472,7 +472,7 @@ def apply_state_defaults(self, info: ValidationInfo) -> Self: When ``context={"state": "merged"}`` (or ``"overridden"``) is passed to ``model_validate()``, the model: - Defaults ``role`` to ``SwitchRole.LEAF`` when not specified. - - Enforces that ``user_name`` and ``password`` are provided. + - Enforces that ``username`` and ``password`` are provided. For ``query`` / ``deleted`` (or no context), fields remain as-is. """ @@ -495,9 +495,9 @@ def apply_state_defaults(self, info: ValidationInfo) -> Self: if state in ("merged", "overridden"): if self.role is None: self.role = SwitchRole.LEAF - if not self.user_name or not self.password: + if not self.username or not self.password: raise ValueError( - f"user_name and password are required " + f"username and password are required " f"for '{state}' state " f"(switch: {self.seed_ip})" ) @@ -579,7 +579,7 @@ def from_switch_data(cls, sw: Any) -> "SwitchConfigModel": """Build a config-shaped entry from a live inventory record. Only the fields recoverable from the ND inventory API are populated. - Credentials (user_name, password) are intentionally omitted. + Credentials (username, password) are intentionally omitted. Args: sw: A SwitchDataModel instance from the fabric inventory. @@ -616,13 +616,13 @@ def to_gathered_dict(self) -> Dict[str, Any]: """Return a config dict suitable for gathered output. platform_type is excluded (internal detail not needed by the user). - user_name and password are replaced with placeholders so the returned + username and password are replaced with placeholders so the returned data is immediately usable as ``config:`` input after substituting real credentials. Returns: Dict with seed_ip, role, auth_proto, preserve_config, - user_name set to ``""``, password set to ``""``. + username set to ``""``, password set to ``""``. """ result = self.to_config(exclude={ "platform_type": True, @@ -630,7 +630,7 @@ def to_gathered_dict(self) -> Dict[str, Any]: "rma": True, "operation_type": True, }) - result["user_name"] = "" + result["username"] = "" result["password"] = "" return result diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index c5f4147b..c41c59ee 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -925,10 +925,10 @@ def bulk_save_credentials( cred_groups: Dict[Tuple[str, str], List[str]] = {} for sn, cfg in switch_actions: - if not cfg.user_name or not cfg.password: - log.debug(f"Skipping credentials for {sn}: missing user_name or password") + if not cfg.username or not cfg.password: + log.debug(f"Skipping credentials for {sn}: missing username or password") continue - key = (cfg.user_name, cfg.password) + key = (cfg.username, cfg.password) cred_groups.setdefault(key, []).append(sn) if not cred_groups: @@ -1960,7 +1960,7 @@ def handle( switch_actions: List[Tuple[str, SwitchConfigModel]] = [] rma_diff_data: List[Tuple[str, str, SwitchConfigModel]] = [] # (new_serial, old_serial, switch_cfg) for switch_cfg, rma_cfg in rma_entries: - new_serial = rma_cfg.serial_number + new_serial = rma_cfg.new_serial_number bootstrap_data = bootstrap_idx.get(new_serial) if not bootstrap_data: @@ -1975,7 +1975,7 @@ def handle( SwitchDiffEngine.validate_switch_api_fields( nd=nd, - serial=rma_cfg.serial_number, + serial=rma_cfg.new_serial_number, model=rma_cfg.model, version=rma_cfg.version, config_data=rma_cfg.config_data, @@ -1986,16 +1986,16 @@ def handle( rma_model = self._build_rma_model( switch_cfg, rma_cfg, bootstrap_data, - old_switch_info[rma_cfg.old_serial], + old_switch_info[rma_cfg.old_serial_number], ) log.info( - f"Built RMA model: replacing {rma_cfg.old_serial} with " + f"Built RMA model: replacing {rma_cfg.old_serial_number} with " f"{rma_model.new_switch_id}" ) - self._provision_rma_switch(rma_cfg.old_serial, rma_model) + self._provision_rma_switch(rma_cfg.old_serial_number, rma_model) switch_actions.append((rma_model.new_switch_id, switch_cfg)) - rma_diff_data.append((rma_model.new_switch_id, rma_cfg.old_serial, switch_cfg)) + rma_diff_data.append((rma_model.new_switch_id, rma_cfg.old_serial_number, switch_cfg)) # Post-processing: wait for RMA switches to become ready, then # save credentials and finalize. RMA switches come up via POAP @@ -2058,7 +2058,7 @@ def _validate_prerequisites( result: Dict[str, Dict[str, Any]] = {} for switch_cfg, rma_cfg in rma_entries: - old_serial = rma_cfg.old_serial + old_serial = rma_cfg.old_serial_number old_switch = existing_by_serial.get(old_serial) if old_switch is None: @@ -2147,12 +2147,12 @@ def _build_rma_model( """ log = self.ctx.log log.debug( - f"ENTER: _build_rma_model(new={rma_cfg.serial_number}, " - f"old={rma_cfg.old_serial})" + f"ENTER: _build_rma_model(new={rma_cfg.new_serial_number}, " + f"old={rma_cfg.old_serial_number})" ) # User config fields - new_switch_id = rma_cfg.serial_number + new_switch_id = rma_cfg.new_serial_number hostname = old_switch_info.get("hostname", "") ip = switch_cfg.seed_ip image_policy = rma_cfg.image_policy diff --git a/plugins/module_utils/utils/manage_switches/switch_helpers.py b/plugins/module_utils/utils/manage_switches/switch_helpers.py index 55f71ba9..539309a7 100644 --- a/plugins/module_utils/utils/manage_switches/switch_helpers.py +++ b/plugins/module_utils/utils/manage_switches/switch_helpers.py @@ -96,7 +96,7 @@ def group_switches_by_credentials( for switch in switches: password_hash = hash(switch.password) group_key = ( - switch.user_name, + switch.username, password_hash, switch.auth_proto, switch.platform_type, diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index b6f01cb8..7037c024 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -69,7 +69,7 @@ type: str default: MD5 choices: ['MD5', 'SHA', 'MD5_DES', 'MD5_AES', 'SHA_DES', 'SHA_AES'] - user_name: + username: description: - Login username for the switch. - For POAP and RMA, should be C(admin). @@ -184,12 +184,12 @@ description: - Password for device discovery during POAP and RMA discovery. type: str - serial_number: + new_serial_number: description: - Serial number of switch to Bootstrap for RMA. type: str required: true - old_serial: + old_serial_number: description: - Serial number of switch to be replaced by RMA. type: str @@ -257,7 +257,7 @@ fabric: my-fabric config: - seed_ip: 192.168.10.201 - user_name: admin + username: admin password: "{{ switch_password }}" role: leaf preserve_config: false @@ -268,11 +268,11 @@ fabric: my-fabric config: - seed_ip: 192.168.10.201 - user_name: admin + username: admin password: "{{ switch_password }}" role: leaf - seed_ip: 192.168.10.202 - user_name: admin + username: admin password: "{{ switch_password }}" role: spine state: merged @@ -282,7 +282,7 @@ fabric: my-fabric config: - seed_ip: 192.168.10.1 - user_name: admin + username: admin password: "{{ switch_password }}" poap: - preprovision_serial: SAL1234ABCD @@ -297,7 +297,7 @@ fabric: my-fabric config: - seed_ip: 192.168.10.1 - user_name: admin + username: admin password: "{{ switch_password }}" poap: - serial_number: SAL5678EFGH @@ -312,7 +312,7 @@ fabric: my-fabric config: - seed_ip: 192.168.10.1 - user_name: admin + username: admin password: "{{ switch_password }}" poap: - serial_number: SAL5678EFGH @@ -324,11 +324,11 @@ fabric: my-fabric config: - seed_ip: 192.168.10.1 - user_name: admin + username: admin password: "{{ switch_password }}" rma: - - old_serial: SAL1234ABCD - serial_number: SAL9999ZZZZ + - old_serial_number: SAL1234ABCD + new_serial_number: SAL9999ZZZZ model: N9K-C93180YC-EX version: "10.3(1)" hostname: leaf-replaced @@ -419,6 +419,7 @@ def main(): # Initialize logging try: log_config = Log() + log_config.config = "/Users/achengam/Documents/Ansible_Dev/NDBranch/ansible_collections/cisco/nd/ansible_cisco_log_r.json" log_config.commit() # Create logger instance for this module log = logging.getLogger("nd.nd_manage_switches") diff --git a/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 b/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 index fd4978fa..94af1f1b 100644 --- a/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 +++ b/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 @@ -14,7 +14,7 @@ {% if switch.seed_ip is defined %} {% set _ = switch_item.update({'seed_ip': switch.seed_ip | default('') }) %} {% endif %} -{% set _ = switch_item.update({'user_name': switch_username}) %} +{% set _ = switch_item.update({'username': switch_username}) %} {% set _ = switch_item.update({'password': switch_password}) %} {% if switch.role is defined %} {% set _ = switch_item.update({'role': switch.role | default('') }) %} diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml index d21d965b..62c3bd98 100644 --- a/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml +++ b/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml @@ -118,7 +118,7 @@ ansible.builtin.set_fact: switch_conf: - seed_ip: "{{ test_data.sw2 }}" - user_name: '{{ switch_username }}' + username: '{{ switch_username }}' password: '{{ switch_password }}' role: border poap: diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml index c78ce2f6..8113ef04 100644 --- a/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml +++ b/tests/integration/targets/nd_manage_switches/tests/nd/rma.yaml @@ -144,11 +144,11 @@ state: merged config: - seed_ip: '{{ test_data.sw1 }}' - user_name: '{{ switch_username }}' + username: '{{ switch_username }}' password: '{{ switch_password }}' rma: - - serial_number: '{{ test_data.sw1_rma_serial }}' - old_serial: '{{ test_data.sw1_serial }}' + - new_serial_number: '{{ test_data.sw1_rma_serial }}' + old_serial_number: '{{ test_data.sw1_serial }}' model: '{{ test_data.rma_model }}' version: '{{ test_data.rma_version }}' hostname: '{{ test_data.rma_hostname }}' diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml index 46d1f2a7..67b4548d 100644 --- a/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml +++ b/tests/integration/targets/nd_manage_switches/tests/nd/sanity.yaml @@ -51,33 +51,44 @@ tags: sanity # ---------------------------------------------- # -# Query # +# Gathered # # ---------------------------------------------- # -# # TC - 3 -# - name: Sanity TC3 - Query - Prepare Conf -# ansible.builtin.set_fact: -# nd_switches_sanity_conf: -# - seed_ip: "{{ test_data.sw1 }}" -# role: leaf -# delegate_to: localhost -# tags: sanity - -# - name: Sanity TC3 - Query - Query a Switch - Hostname and Role must match -# cisco.nd.nd_manage_switches: -# fabric: "{{ test_data.test_fabric }}" -# state: query -# config: "{{ nd_switches_sanity_conf }}" -# register: query_result -# tags: sanity - -# - name: Sanity TC3 - Query - Validate ND Data -# cisco.nd.nd_switches_validate: -# nd_data: "{{ query_result }}" -# test_data: "{{ nd_switches_sanity_conf }}" -# changed: "{{ create_result.changed }}" -# register: result -# tags: sanity +# TC - 3 +- name: Sanity TC3 - Gathered - Gather Switch State in Fabric + cisco.nd.nd_manage_switches: + state: gathered + fabric: "{{ test_data.test_fabric }}" + register: gathered_result + tags: sanity + +- name: Sanity TC3 - Gathered - Build Gathered Lookup + ansible.builtin.set_fact: + gathered_seeds: "{{ gathered_result.gathered | map(attribute='seed_ip') | list }}" + gathered_role_map: "{{ gathered_result.gathered | items2dict(key_name='seed_ip', value_name='role') }}" + delegate_to: localhost + tags: sanity + +- name: Sanity TC3 - Gathered - Validate Gathered Count + ansible.builtin.assert: + that: + - gathered_result.gathered | length == nd_switches_base_conf | length + fail_msg: >- + Gathered count {{ gathered_result.gathered | length }} does not match + expected {{ nd_switches_base_conf | length }} + tags: sanity + +- name: Sanity TC3 - Gathered - Validate Each Switch Present and Role Matches + ansible.builtin.assert: + that: + - item.seed_ip in gathered_seeds + - "'role' not in item or gathered_role_map[item.seed_ip] == item.role" + fail_msg: >- + Switch {{ item.seed_ip }} missing from gathered output or role mismatch + (expected={{ item.role | default('any') }}, + got={{ gathered_role_map[item.seed_ip] | default('not found') }}) + loop: "{{ nd_switches_base_conf }}" + tags: sanity # ---------------------------------------------- # # Overridden # From 4aa9b7ebdfa2f67ad69ff337e986fbcaf45d3426 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Thu, 26 Mar 2026 15:36:44 +0530 Subject: [PATCH 23/27] Splitting POAP into Preprovision/Poap and Lucene Params Fix --- .../module_utils/endpoints/query_params.py | 4 +- .../models/manage_switches/__init__.py | 2 + .../models/manage_switches/config_models.py | 304 ++++++++++-------- plugins/module_utils/nd_switch_resources.py | 167 ++++++---- plugins/modules/nd_manage_switches.py | 117 ++++--- .../templates/nd_manage_switches_conf.j2 | 54 ++-- .../nd_manage_switches/tests/nd/poap.yaml | 27 +- 7 files changed, 389 insertions(+), 286 deletions(-) diff --git a/plugins/module_utils/endpoints/query_params.py b/plugins/module_utils/endpoints/query_params.py index 0d2c112e..54ae39f6 100644 --- a/plugins/module_utils/endpoints/query_params.py +++ b/plugins/module_utils/endpoints/query_params.py @@ -215,7 +215,9 @@ def to_query_string(self, url_encode: bool = True) -> str: # Lucene filter expressions require ':' and ' ' to remain unencoded # so the server-side parser can recognise the field:value syntax. if url_encode: - safe_chars = ": " if field_name == "filter" else "" + # Keep ':' unencoded so Lucene field:value syntax is preserved. + # Spaces are encoded as %20 so the query string is valid in URLs. + safe_chars = ":" if field_name == "filter" else "" encoded_value = quote(str(field_value), safe=safe_chars) else: encoded_value = str(field_value) diff --git a/plugins/module_utils/models/manage_switches/__init__.py b/plugins/module_utils/models/manage_switches/__init__.py index 38e667a8..83020728 100644 --- a/plugins/module_utils/models/manage_switches/__init__.py +++ b/plugins/module_utils/models/manage_switches/__init__.py @@ -86,6 +86,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.config_models import ( # noqa: F401 ConfigDataModel, POAPConfigModel, + PreprovisionConfigModel, RMAConfigModel, SwitchConfigModel, ) @@ -136,6 +137,7 @@ # Config models "ConfigDataModel", "POAPConfigModel", + "PreprovisionConfigModel", "RMAConfigModel", "SwitchConfigModel", ] diff --git a/plugins/module_utils/models/manage_switches/config_models.py b/plugins/module_utils/models/manage_switches/config_models.py index a9cada16..2f6873c1 100644 --- a/plugins/module_utils/models/manage_switches/config_models.py +++ b/plugins/module_utils/models/manage_switches/config_models.py @@ -84,15 +84,29 @@ def validate_gateway(cls, v: str) -> str: class POAPConfigModel(NDNestedModel): - """ - POAP configuration entry for a single switch in the playbook config list. + """Bootstrap POAP config for a single switch. - Supports Bootstrap (serial_number only), Pre-provision (preprovision_serial only), - and Swap (both serial fields) operation modes. + Used when ``poap`` is specified alone (bootstrap-only operation). + ``serial_number`` and ``hostname`` are mandatory; all other fields are optional. + Model, version, and config data are sourced from the bootstrap API at runtime. + If the bootstrap API reports a different hostname or role, the API value overrides + the user-provided value and a warning is logged. """ identifiers: ClassVar[List[str]] = [] - # Discovery credentials + # Mandatory + serial_number: str = Field( + ..., + alias="serialNumber", + min_length=1, + description="Serial number of the physical switch to Bootstrap" + ) + hostname: str = Field( + ..., + description="Hostname for the switch during bootstrap" + ) + + # Optional discovery_username: Optional[str] = Field( default=None, alias="discoveryUsername", @@ -103,117 +117,97 @@ class POAPConfigModel(NDNestedModel): alias="discoveryPassword", description="Password for device discovery during POAP" ) - - # Bootstrap operation - requires actual switch serial number - serial_number: Optional[str] = Field( + image_policy: Optional[str] = Field( default=None, - alias="serialNumber", - min_length=1, - description="Serial number of switch to Bootstrap" + alias="imagePolicy", + description="Name of the image policy to be applied on switch" ) - # Pre-provision operation - requires pre-provision serial number - preprovision_serial: Optional[str] = Field( - default=None, - alias="preprovisionSerial", + @model_validator(mode='after') + def validate_discovery_credentials_pair(self) -> Self: + """Validate that discovery_username and discovery_password are both set or both absent.""" + has_user = bool(self.discovery_username) + has_pass = bool(self.discovery_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" + ) + return self + + @field_validator('serial_number', mode='before') + @classmethod + def validate_serial_number_field(cls, v: str) -> str: + """Validate serial_number is not empty.""" + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("serial_number cannot be empty") + return result + + +class PreprovisionConfigModel(NDNestedModel): + """Pre-provision config for a single switch. + + Used when ``preprovision`` is specified alone. + All five fields — ``serial_number``, ``model``, ``version``, ``hostname``, + and ``config_data`` — are mandatory because the controller has no physical + switch to pull these values from. + """ + identifiers: ClassVar[List[str]] = [] + + # Mandatory + serial_number: str = Field( + ..., + alias="serialNumber", min_length=1, description="Serial number of switch to Pre-provision" ) - - # Common fields for both operations - model: Optional[str] = Field( - default=None, - description="Model of switch to Bootstrap/Pre-provision" - ) - version: Optional[str] = Field( - default=None, - description="Software version of switch to Bootstrap/Pre-provision" + model: str = Field( + ..., + min_length=1, + description="Model of switch to Pre-provision" ) - hostname: Optional[str] = Field( - default=None, - description="Hostname of switch to Bootstrap/Pre-provision" + version: str = Field( + ..., + min_length=1, + description="Software version of switch to Pre-provision" ) - image_policy: Optional[str] = Field( - default=None, - alias="imagePolicy", - description="Name of the image policy to be applied on switch" + hostname: str = Field( + ..., + description="Hostname for the switch during pre-provision" ) - config_data: Optional[ConfigDataModel] = Field( - default=None, + config_data: ConfigDataModel = Field( + ..., alias="configData", description=( - "Basic config data of switch to Bootstrap/Pre-provision. " + "Basic config data of switch to Pre-provision. " "'models' (list of module models) and 'gateway' (IP with mask) are mandatory." ), ) - @model_validator(mode='after') - def validate_operation_type(self) -> Self: - """Validate serial_number / preprovision_serial combinations. - - Allowed combinations: - - serial_number only → Bootstrap - - preprovision_serial only → Pre-provision - - both serial_number AND preprovision_serial → Swap (change serial - number of an existing pre-provisioned switch) - - neither → error - """ - has_serial = bool(self.serial_number) - has_preprov = bool(self.preprovision_serial) - - if not has_serial and not has_preprov: - raise ValueError( - "Either 'serial_number' (for Bootstrap / Swap) or 'preprovision_serial' " - "(for Pre-provision / Swap) must be provided." - ) - - return self - - @model_validator(mode='after') - def validate_required_fields_for_non_swap(self) -> Self: - """Validate model/version/hostname/config_data for pre-provision operations. - - Pre-provision (preprovision_serial only): - model, version, hostname, config_data are all mandatory because the - controller has no physical switch to pull these values from. - - Bootstrap (serial_number only): - These fields are optional — they can be omitted and the module will - pull them from the bootstrap GET API response at runtime. If - provided, they are validated against the bootstrap data before import. - - Swap (both serials present): - No check needed — the swap API only requires the new serial number. - """ - has_serial = bool(self.serial_number) - has_preprov = bool(self.preprovision_serial) - - # Pre-provision only: all four descriptor fields are mandatory - if has_preprov and not has_serial: - missing = [] - if not self.model: - missing.append("model") - if not self.version: - missing.append("version") - if not self.hostname: - missing.append("hostname") - if not self.config_data: - missing.append("config_data") - if missing: - raise ValueError( - f"model, version, hostname and config_data are required for " - f"Pre-provisioning a switch. Missing: {', '.join(missing)}" - ) - return self + # Optional + discovery_username: Optional[str] = Field( + default=None, + alias="discoveryUsername", + description="Username for device discovery during pre-provision" + ) + discovery_password: Optional[str] = Field( + default=None, + alias="discoveryPassword", + description="Password for device discovery during pre-provision" + ) + image_policy: Optional[str] = Field( + default=None, + alias="imagePolicy", + description="Image policy to apply during pre-provision" + ) @model_validator(mode='after') def validate_discovery_credentials_pair(self) -> Self: - """Validate that discovery_username and discovery_password are both set or both absent. - - Mirrors the dcnm_inventory.py bidirectional check: - - discovery_username set → discovery_password required - - discovery_password set → discovery_username required - """ + """Validate that discovery_username and discovery_password are both set or both absent.""" has_user = bool(self.discovery_username) has_pass = bool(self.discovery_password) if has_user and not has_pass: @@ -226,11 +220,14 @@ def validate_discovery_credentials_pair(self) -> Self: ) return self - @field_validator('serial_number', 'preprovision_serial', mode='before') + @field_validator('serial_number', mode='before') @classmethod - def validate_serial_numbers(cls, v: Optional[str]) -> Optional[str]: - """Validate serial numbers are not empty strings.""" - return SwitchValidators.validate_serial_number(v) + def validate_serial_number_field(cls, v: str) -> str: + """Validate serial_number is not empty.""" + result = SwitchValidators.validate_serial_number(v) + if result is None: + raise ValueError("serial_number cannot be empty") + return result class RMAConfigModel(NDNestedModel): @@ -330,15 +327,16 @@ class SwitchConfigModel(NDBaseModel): """ Per-switch configuration entry in the Ansible playbook config list. - Supports normal switch addition, POAP (Bootstrap and Pre-provision), and RMA - operations. The operation type is derived from the presence of poap or rma fields. + Supports normal switch addition, POAP (Bootstrap), Pre-provision, Swap + (both poap+preprovision), and RMA operations. The operation type is derived + from the presence of poap, preprovision, and/or rma fields. """ identifiers: ClassVar[List[str]] = ["seed_ip"] # Fields excluded from diff — only seed_ip + role are compared exclude_from_diff: ClassVar[List[str]] = [ "username", "password", "auth_proto", - "preserve_config", "platform_type", "poap", "rma", + "preserve_config", "platform_type", "poap", "preprovision", "rma", "operation_type", ] @@ -381,10 +379,14 @@ class SwitchConfigModel(NDBaseModel): description="Platform type of the switch (nx-os, ios-xe, etc.)" ) - # POAP and RMA configurations - poap: Optional[List[POAPConfigModel]] = Field( + # POAP, Pre-provision and RMA configurations + poap: Optional[POAPConfigModel] = Field( default=None, - description="POAP (PowerOn Auto Provisioning) configurations for Bootstrap/Pre-provision" + description="Bootstrap POAP config (serial_number + hostname mandatory)" + ) + preprovision: Optional[PreprovisionConfigModel] = Field( + default=None, + description="Pre-provision config (serial_number, model, version, hostname, config_data all mandatory)" ) rma: Optional[List[RMAConfigModel]] = Field( default=None, @@ -395,16 +397,22 @@ class SwitchConfigModel(NDBaseModel): @computed_field @property - def operation_type(self) -> Literal["normal", "poap", "rma"]: + def operation_type(self) -> Literal["normal", "poap", "preprovision", "swap", "rma"]: """Determine the operation type from this config. Returns: - ``'poap'`` if POAP configs are present, + ``'swap'`` if both poap and preprovision are present, + ``'poap'`` if only bootstrap poap is present, + ``'preprovision'`` if only preprovision is present, ``'rma'`` if RMA configs are present, ``'normal'`` otherwise. """ + if self.poap and self.preprovision: + return "swap" if self.poap: return "poap" + if self.preprovision: + return "preprovision" if self.rma: return "rma" return "normal" @@ -419,22 +427,24 @@ def to_config_dict(self) -> Dict[str, Any]: return self.to_config(exclude={ "username": True, "password": True, - "poap": {"__all__": {"discovery_username": True, "discovery_password": True}}, + "poap": {"discovery_username": True, "discovery_password": True}, + "preprovision": {"discovery_username": True, "discovery_password": True}, "rma": {"__all__": {"discovery_username": True, "discovery_password": True}}, }) @model_validator(mode='after') - def reject_auth_proto_for_poap_rma(self) -> Self: - """Reject non-MD5 auth_proto when POAP or RMA is configured. - - POAP, Pre-provision, and RMA operations always use MD5 internally. - By validating mode='after', all inputs (raw strings, enum instances, - or Ansible argspec-injected defaults) have already been coerced by - Pydantic into a typed SnmpV3AuthProtocol value, so a direct enum - comparison is safe and unambiguous. + def reject_auth_proto_for_special_ops(self) -> Self: + """Reject non-MD5 auth_proto when POAP, Pre-provision, Swap or RMA is configured. + + These operations always use MD5 internally. By validating mode='after', + all inputs have already been coerced by Pydantic into a typed + SnmpV3AuthProtocol value, so a direct enum comparison is safe. """ - if (self.poap or self.rma) and self.auth_proto != SnmpV3AuthProtocol.MD5: - op = "POAP" if self.poap else "RMA" + if (self.poap or self.preprovision or self.rma) and self.auth_proto != SnmpV3AuthProtocol.MD5: + if self.poap or self.preprovision: + op = "POAP/Pre-provision" + else: + op = "RMA" raise ValueError( f"'auth_proto' must not be specified for {op} operations. " f"The authentication protocol is always MD5 and is set " @@ -443,26 +453,36 @@ def reject_auth_proto_for_poap_rma(self) -> Self: return self @model_validator(mode='after') - def validate_poap_rma_mutual_exclusion(self) -> Self: - """Validate that POAP and RMA are mutually exclusive.""" - if self.poap and self.rma: - raise ValueError("Cannot specify both 'poap' and 'rma' configurations for the same switch") - + def validate_special_ops_exclusion(self) -> Self: + """Validate mutually exclusive operation combinations. + + Allowed: + - poap only (Bootstrap) + - preprovision only (Pre-provision) + - poap + preprovision (Swap) + - rma (RMA) + Not allowed: + - rma combined with poap or preprovision + """ + if self.rma and (self.poap or self.preprovision): + raise ValueError( + "Cannot specify 'rma' together with 'poap' or 'preprovision' " + "for the same switch" + ) return self @model_validator(mode='after') - def validate_poap_rma_credentials(self) -> Self: - """Validate credentials for POAP and RMA operations.""" - if self.poap or self.rma: - # POAP/RMA require credentials + def validate_special_ops_credentials(self) -> Self: + """Validate credentials for POAP, Pre-provision, Swap and RMA operations.""" + if self.poap or self.preprovision or self.rma: if not self.username or not self.password: raise ValueError( - "For POAP and RMA operations, username and password are required" + "For POAP, Pre-provision, and RMA operations, username and password are required" ) - # For POAP and RMA, username should be 'admin' if self.username != "admin": - raise ValueError("For POAP and RMA operations, username should be 'admin'") - + raise ValueError( + "For POAP, Pre-provision, and RMA operations, username should be 'admin'" + ) return self @model_validator(mode='after') @@ -478,10 +498,10 @@ def apply_state_defaults(self, info: ValidationInfo) -> Self: """ state = (info.context or {}).get("state") if info else None - # POAP only allowed with merged - if self.poap and state not in (None, "merged"): + # POAP/Pre-provision/Swap only allowed with merged + if (self.poap or self.preprovision) and state not in (None, "merged"): raise ValueError( - f"POAP operations require 'merged' state, " + f"POAP/Pre-provision operations require 'merged' state, " f"got '{state}' (switch: {self.seed_ip})" ) @@ -538,12 +558,12 @@ def validate_seed_ip(cls, v: str) -> str: f"'{v}' is not a valid IP address and could not be resolved via DNS" ) - @field_validator('poap', 'rma', mode='before') + @field_validator('rma', mode='before') @classmethod - def validate_lists_not_empty(cls, v: Optional[List]) -> Optional[List]: - """Validate that if POAP or RMA lists are provided, they are not empty.""" + def validate_rma_list_not_empty(cls, v: Optional[List]) -> Optional[List]: + """Validate that if RMA list is provided, it is not empty.""" if v is not None and len(v) == 0: - raise ValueError("POAP/RMA list cannot be empty if provided") + raise ValueError("RMA list cannot be empty if provided") return v @field_validator('auth_proto', mode='before') @@ -627,6 +647,7 @@ def to_gathered_dict(self) -> Dict[str, Any]: result = self.to_config(exclude={ "platform_type": True, "poap": True, + "preprovision": True, "rma": True, "operation_type": True, }) @@ -653,6 +674,7 @@ def get_argument_spec(cls) -> Dict[str, Any]: __all__ = [ "ConfigDataModel", "POAPConfigModel", + "PreprovisionConfigModel", "RMAConfigModel", "SwitchConfigModel", ] diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index c41c59ee..565bdf49 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -45,6 +45,7 @@ SwitchCredentialsRequestModel, ChangeSwitchSerialNumberRequestModel, POAPConfigModel, + PreprovisionConfigModel, RMAConfigModel, ) from ansible_collections.cisco.nd.plugins.module_utils.utils.manage_switches import ( @@ -1175,28 +1176,44 @@ def handle( # Classify entries first so check mode can report per-operation counts bootstrap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]] = [] - preprov_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]] = [] - swap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]] = [] + preprov_entries: List[Tuple[SwitchConfigModel, PreprovisionConfigModel]] = [] + swap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel, PreprovisionConfigModel]] = [] for switch_cfg in proposed_config: - if not switch_cfg.poap: - log.warning( - f"Switch config for {switch_cfg.seed_ip} has no POAP block — skipping" - ) - continue - - for poap_cfg in switch_cfg.poap: - if poap_cfg.serial_number and poap_cfg.preprovision_serial: - swap_entries.append((switch_cfg, poap_cfg)) - elif poap_cfg.preprovision_serial: - preprov_entries.append((switch_cfg, poap_cfg)) - elif poap_cfg.serial_number: - bootstrap_entries.append((switch_cfg, poap_cfg)) - else: + has_poap = bool(switch_cfg.poap) + has_preprov = bool(switch_cfg.preprovision) + + if has_poap and has_preprov: + # Swap: only serial_number is meaningful on each side; warn about extras + poap_extra = [ + f for f in ["hostname", "image_policy", "discovery_username", "discovery_password"] + if getattr(switch_cfg.poap, f, None) + ] + preprov_extra = [ + f for f in ["model", "version", "hostname", "config_data", + "image_policy", "discovery_username", "discovery_password"] + if getattr(switch_cfg.preprovision, f, None) + ] + if poap_extra: + log.warning( + f"Swap ({switch_cfg.seed_ip}): extra fields in 'poap' will be " + f"ignored during swap: {poap_extra}" + ) + if preprov_extra: log.warning( - f"POAP entry for {switch_cfg.seed_ip} has neither " - f"serial_number nor preprovision_serial — skipping" + f"Swap ({switch_cfg.seed_ip}): extra fields in 'preprovision' will be " + f"ignored during swap: {preprov_extra}" ) + swap_entries.append((switch_cfg, switch_cfg.poap, switch_cfg.preprovision)) + elif has_preprov: + preprov_entries.append((switch_cfg, switch_cfg.preprovision)) + elif has_poap: + bootstrap_entries.append((switch_cfg, switch_cfg.poap)) + else: + log.warning( + f"Switch config for {switch_cfg.seed_ip} has no poap or preprovision " + f"block — skipping" + ) log.info( f"POAP classification: {len(bootstrap_entries)} bootstrap, " @@ -1249,14 +1266,14 @@ def handle( bootstrap_entries = active_bootstrap active_preprov = [] - for switch_cfg, poap_cfg in preprov_entries: + for switch_cfg, preprov_cfg in preprov_entries: if switch_cfg.seed_ip in existing_by_ip: log.info( f"PreProvision: IP '{switch_cfg.seed_ip}' already in fabric " f"— idempotent, skipping" ) else: - active_preprov.append((switch_cfg, poap_cfg)) + active_preprov.append((switch_cfg, preprov_cfg)) preprov_entries = active_preprov # Handle swap entries (change serial number on pre-provisioned switches) @@ -1270,8 +1287,8 @@ def handle( # Handle pre-provision entries if preprov_entries: preprov_models: List[PreProvisionSwitchModel] = [] - for switch_cfg, poap_cfg in preprov_entries: - pp_model = self._build_preprovision_model(switch_cfg, poap_cfg) + for switch_cfg, preprov_cfg in preprov_entries: + pp_model = self._build_preprovision_model(switch_cfg, preprov_cfg) preprov_models.append(pp_model) log.info( f"Built pre-provision model for serial=" @@ -1335,20 +1352,6 @@ def _handle_poap_bootstrap( log.error(msg) nd.module.fail_json(msg=msg) - # Validate user-supplied fields against bootstrap data (if provided) - # and warn about any fields that will be pulled from the API. - SwitchDiffEngine.validate_switch_api_fields( - nd=nd, - serial=poap_cfg.serial_number, - model=poap_cfg.model, - version=poap_cfg.version, - config_data=poap_cfg.config_data, - bootstrap_data=bootstrap_data, - log=log, - context="Bootstrap", - hostname=poap_cfg.hostname, - ) - model = self._build_bootstrap_import_model( switch_cfg, poap_cfg, bootstrap_data ) @@ -1413,20 +1416,44 @@ def _build_bootstrap_import_model( discovery_username = getattr(poap_cfg, "discovery_username", None) discovery_password = getattr(poap_cfg, "discovery_password", None) - # Use user-provided values when available; fall back to bootstrap API data. - model = poap_cfg.model or bs.get("model", "") - version = poap_cfg.version or bs.get("softwareVersion", "") - hostname = poap_cfg.hostname or bs.get("hostname", "") + # model, version and config_data always come from the bootstrap API for + # bootstrap-only operations. POAP no longer carries these fields. + model = bs.get("model", "") + version = bs.get("softwareVersion", "") gateway_ip_mask = ( - (poap_cfg.config_data.gateway if poap_cfg.config_data else None) - or bs.get("gatewayIpMask") + bs.get("gatewayIpMask") or bs_data.get("gatewayIpMask") ) - data_models = ( - (poap_cfg.config_data.models if poap_cfg.config_data else None) - or bs_data.get("models", []) - ) + data_models = bs_data.get("models", []) + + # Hostname: user-provided via poap.hostname is the default; if the + # bootstrap API returns a different value, the API wins and we warn. + user_hostname = poap_cfg.hostname + api_hostname = bs.get("hostname", "") + if api_hostname and api_hostname != user_hostname: + log.warning( + f"Bootstrap ({serial_number}): API hostname '{api_hostname}' overrides " + f"user-provided hostname '{user_hostname}'. Using API value." + ) + hostname = api_hostname + else: + hostname = user_hostname + + # Role: switch_cfg.role is user-provided; if the bootstrap API carries a + # role and it differs, the API value wins and we warn. + api_role_raw = bs.get("switchRole") or bs_data.get("switchRole") + if api_role_raw: + try: + api_role = SwitchRole.normalize(api_role_raw) + if api_role and api_role != switch_role: + log.warning( + f"Bootstrap ({serial_number}): API role '{api_role_raw}' overrides " + f"user-provided role '{switch_role}'. Using API value." + ) + switch_role = api_role + except Exception: + pass # Build the data block from resolved values (replaces build_poap_data_block) data_block: Optional[Dict[str, Any]] = None @@ -1535,38 +1562,38 @@ def _import_bootstrap_switches( def _build_preprovision_model( self, switch_cfg: SwitchConfigModel, - poap_cfg: POAPConfigModel, + preprov_cfg: "PreprovisionConfigModel", ) -> PreProvisionSwitchModel: - """Build a pre-provision model from POAP configuration. + """Build a pre-provision model from PreprovisionConfigModel configuration. Args: switch_cfg: Parent switch config. - poap_cfg: POAP config entry. + preprov_cfg: Pre-provision config entry. Returns: Completed ``PreProvisionSwitchModel`` for API submission. """ log = self.ctx.log log.debug( - f"ENTER: _build_preprovision_model(serial={poap_cfg.preprovision_serial})" + f"ENTER: _build_preprovision_model(serial={preprov_cfg.serial_number})" ) - serial_number = poap_cfg.preprovision_serial - hostname = poap_cfg.hostname + serial_number = preprov_cfg.serial_number + hostname = preprov_cfg.hostname ip = switch_cfg.seed_ip - model_name = poap_cfg.model - version = poap_cfg.version - image_policy = poap_cfg.image_policy - gateway_ip_mask = poap_cfg.config_data.gateway if poap_cfg.config_data else None + model_name = preprov_cfg.model + version = preprov_cfg.version + image_policy = preprov_cfg.image_policy + gateway_ip_mask = preprov_cfg.config_data.gateway switch_role = switch_cfg.role password = switch_cfg.password auth_proto = SnmpV3AuthProtocol.MD5 # Pre-provision always uses MD5 - discovery_username = getattr(poap_cfg, "discovery_username", None) - discovery_password = getattr(poap_cfg, "discovery_password", None) + discovery_username = getattr(preprov_cfg, "discovery_username", None) + discovery_password = getattr(preprov_cfg, "discovery_password", None) - # Shared data block builder - data_block = build_poap_data_block(poap_cfg) + # Build data block from mandatory config_data + data_block = build_poap_data_block(preprov_cfg) preprov_model = PreProvisionSwitchModel( serialNumber=serial_number, @@ -1655,13 +1682,15 @@ def _preprovision_switches( def _handle_poap_swap( self, - swap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel]], + swap_entries: List[Tuple[SwitchConfigModel, POAPConfigModel, "PreprovisionConfigModel"]], existing: List[SwitchDataModel], ) -> None: """Process POAP serial-swap entries. Args: - swap_entries: ``(SwitchConfigModel, POAPConfigModel)`` swap pairs. + swap_entries: ``(SwitchConfigModel, POAPConfigModel, PreprovisionConfigModel)`` + swap triples where poap carries the new serial and preprovision + carries the old (pre-provisioned) serial. existing: Current fabric inventory snapshot. Returns: @@ -1688,8 +1717,8 @@ def _handle_poap_swap( f"{list(fabric_index.keys())}" ) - for switch_cfg, poap_cfg in swap_entries: - old_serial = poap_cfg.preprovision_serial + for switch_cfg, poap_cfg, preprov_cfg in swap_entries: + old_serial = preprov_cfg.serial_number if old_serial not in fabric_index: msg = ( f"Pre-provisioned serial '{old_serial}' not found in " @@ -1713,7 +1742,7 @@ def _handle_poap_swap( f"{list(bootstrap_index.keys())}" ) - for switch_cfg, poap_cfg in swap_entries: + for switch_cfg, poap_cfg, preprov_cfg in swap_entries: new_serial = poap_cfg.serial_number if new_serial not in bootstrap_index: msg = ( @@ -1732,8 +1761,8 @@ def _handle_poap_swap( # ------------------------------------------------------------------ # Step 3: Call changeSwitchSerialNumber for each swap entry # ------------------------------------------------------------------ - for switch_cfg, poap_cfg in swap_entries: - old_serial = poap_cfg.preprovision_serial + for switch_cfg, poap_cfg, preprov_cfg in swap_entries: + old_serial = preprov_cfg.serial_number new_serial = poap_cfg.serial_number log.info( @@ -1804,7 +1833,7 @@ def _handle_poap_swap( # Step 5: Build BootstrapImportSwitchModels and POST importBootstrap # ------------------------------------------------------------------ import_models: List[BootstrapImportSwitchModel] = [] - for switch_cfg, poap_cfg in swap_entries: + for switch_cfg, poap_cfg, preprov_cfg in swap_entries: new_serial = poap_cfg.serial_number bootstrap_data = post_swap_index.get(new_serial) @@ -1844,7 +1873,7 @@ def _handle_poap_swap( # Step 6: Wait for manageability, save credentials, finalize # ------------------------------------------------------------------ switch_actions: List[Tuple[str, SwitchConfigModel]] = [] - for switch_cfg, poap_cfg in swap_entries: + for switch_cfg, poap_cfg, preprov_cfg in swap_entries: switch_actions.append((poap_cfg.serial_number, switch_cfg)) self.fabric_ops.post_add_processing( diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index 7037c024..1019ff05 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -107,11 +107,28 @@ default: false poap: description: - - POAP (PowerOn Auto Provisioning) configurations for bootstrap/preprovision. + - Bootstrap POAP config for the switch. + - C(serial_number) and C(hostname) are mandatory. + - Model, version, and config data are sourced from the bootstrap API at runtime. + - If the bootstrap API reports a different hostname or role, the API value + overrides the user-provided value and a warning is logged. + - To perform a B(swap) operation, provide both C(poap) and C(preprovision) + under the same switch config. Only C(serial_number) is required in each. - POAP and DHCP must be enabled in fabric before using. - type: list - elements: dict + type: dict suboptions: + serial_number: + description: + - Serial number of the physical switch to Bootstrap. + - Required for bootstrap and swap operations. + type: str + required: true + hostname: + description: + - Hostname for the switch during bootstrap. + - Overridden by the bootstrap API value when they differ (warning logged). + type: str + required: true discovery_username: description: - Username for device discovery during POAP. @@ -121,53 +138,74 @@ - Password for device discovery during POAP. type: str no_log: true - serial_number: + image_policy: description: - - Serial number of the physical switch to Bootstrap. - - When used together with C(preprovision_serial), performs a swap operation - that changes the serial number of a pre-provisioned switch and then - imports it via bootstrap. + - Name of the image policy to be applied on the switch. type: str - preprovision_serial: + preprovision: + description: + - Pre-provision config for the switch. + - All five fields are mandatory since the controller has no physical switch + to pull values from. + - To perform a B(swap) operation, provide both C(poap) and C(preprovision) + under the same switch config. Only C(serial_number) is required in each; + extra fields are ignored with a warning. + - POAP and DHCP must be enabled in fabric before using. + type: dict + suboptions: + serial_number: description: - - Serial number of switch to Pre-provision. - - When used together with C(serial_number), performs a swap operation - that changes the serial number of this pre-provisioned switch to - C(serial_number) and then imports it via bootstrap. + - Serial number of the switch to Pre-provision. type: str + required: true model: description: - - Model of switch to Bootstrap/Pre-provision. + - Model of the switch to Pre-provision (e.g., N9K-C93180YC-EX). type: str + required: true version: description: - - Software version of switch. + - Software version of the switch to Pre-provision (e.g., 10.3(1)). type: str + required: true hostname: description: - - Hostname for the switch. - type: str - image_policy: - description: - - Image policy to apply. + - Hostname for the switch during pre-provision. type: str + required: true config_data: description: - - Basic configuration data for the switch during Bootstrap/Pre-provision. + - Basic configuration data for the switch during Pre-provision. - C(models) and C(gateway) are mandatory. - - C(models) is list of model of modules in switch to Bootstrap/Pre-provision. + - C(models) is a list of module models in the switch. - C(gateway) is the gateway IP with mask for the switch. type: dict + required: true suboptions: models: description: - - List of module models in the switch (e.g., N9K-X9364v, N9K-vSUP). + - List of module models in the switch (e.g., [N9K-X9364v, N9K-vSUP]). type: list elements: str + required: true gateway: description: - Gateway IP with subnet mask (e.g., 192.168.0.1/24). type: str + required: true + discovery_username: + description: + - Username for device discovery during pre-provision. + type: str + discovery_password: + description: + - Password for device discovery during pre-provision. + type: str + no_log: true + image_policy: + description: + - Image policy to apply during pre-provision. + type: str rma: description: - RMA an existing switch with a new one. @@ -243,9 +281,9 @@ treated as idempotent and will attempt the bootstrap again. - Idempotence for B(Pre-provision) - A pre-provision entry is considered idempotent when the C(seed_ip) already exists in the fabric inventory, regardless of the - C(preprovision_serial) value. Because the pre-provision serial is a placeholder - that may differ from the real hardware serial, only the IP address is used as - the stable identity for idempotency checks. + C(serial_number) value under C(preprovision). Because the pre-provision serial is + a placeholder that may differ from the real hardware serial, only the IP address + is used as the stable identity for idempotency checks. - Idempotence for B(normal discovery) - A switch is considered idempotent when its C(seed_ip) already exists in the fabric inventory with no configuration drift (same role). @@ -284,12 +322,15 @@ - seed_ip: 192.168.10.1 username: admin password: "{{ switch_password }}" - poap: - - preprovision_serial: SAL1234ABCD - model: N9K-C93180YC-EX - version: "10.3(1)" - hostname: leaf-preprov - gateway_ip: 192.168.10.1/24 + preprovision: + serial_number: SAL1234ABCD + model: N9K-C93180YC-EX + version: "10.3(1)" + hostname: leaf-preprov + config_data: + models: + - N9K-C93180YC-EX + gateway: 192.168.10.1/24 state: merged - name: Bootstrap a switch via POAP @@ -300,11 +341,8 @@ username: admin password: "{{ switch_password }}" poap: - - serial_number: SAL5678EFGH - model: N9K-C93180YC-EX - version: "10.3(1)" - hostname: leaf-bootstrap - gateway_ip: 192.168.10.1/24 + serial_number: SAL5678EFGH + hostname: leaf-bootstrap state: merged - name: Swap serial number on a pre-provisioned switch (POAP swap) @@ -315,8 +353,9 @@ username: admin password: "{{ switch_password }}" poap: - - serial_number: SAL5678EFGH - preprovision_serial: SAL1234ABCD + serial_number: SAL5678EFGH + preprovision: + serial_number: SAL1234ABCD state: merged - name: RMA - Replace a switch diff --git a/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 b/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 index 94af1f1b..9fbc38ce 100644 --- a/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 +++ b/tests/integration/targets/nd_manage_switches/templates/nd_manage_switches_conf.j2 @@ -19,34 +19,46 @@ {% if switch.role is defined %} {% set _ = switch_item.update({'role': switch.role | default('') }) %} {% endif %} -{% if switch.poap is defined %} -{% for sw_poap_item in switch.poap %} +{% if switch.poap is defined and switch.poap %} {% set poap_item = {} %} -{% if sw_poap_item.preprovision_serial is defined and sw_poap_item.preprovision_serial %} -{% set _ = poap_item.update({'preprovision_serial': sw_poap_item.preprovision_serial}) %} +{% set _ = poap_item.update({'serial_number': switch.poap.serial_number}) %} +{% set _ = poap_item.update({'hostname': switch.poap.hostname}) %} +{% if switch.poap.image_policy is defined and switch.poap.image_policy %} +{% set _ = poap_item.update({'image_policy': switch.poap.image_policy}) %} {% endif %} -{% if sw_poap_item.serial_number is defined and sw_poap_item.serial_number %} -{% set _ = poap_item.update({'serial_number': sw_poap_item.serial_number}) %} +{% if switch.poap.discovery_username is defined and switch.poap.discovery_username %} +{% set _ = poap_item.update({'discovery_username': switch.poap.discovery_username}) %} {% endif %} -{% if sw_poap_item.model is defined and sw_poap_item.model %} -{% set _ = poap_item.update({'model': sw_poap_item.model}) %} +{% if switch.poap.discovery_password is defined and switch.poap.discovery_password %} +{% set _ = poap_item.update({'discovery_password': switch.poap.discovery_password}) %} {% endif %} -{% if sw_poap_item.version is defined and sw_poap_item.version %} -{% set _ = poap_item.update({'version': sw_poap_item.version}) %} +{% set _ = switch_item.update({'poap': poap_item}) %} {% endif %} -{% if sw_poap_item.hostname is defined and sw_poap_item.hostname %} -{% set _ = poap_item.update({'hostname': sw_poap_item.hostname}) %} -{% endif %} -{% if sw_poap_item.config_data is defined %} -{% set poap_config_item = {} %} -{% for sw_poap_config_item in sw_poap_item.config_data %} -{% set _ = poap_config_item.update({sw_poap_config_item: sw_poap_item.config_data[sw_poap_config_item]}) %} +{% if switch.preprovision is defined and switch.preprovision %} +{% set preprov_item = {} %} +{% set _ = preprov_item.update({'serial_number': switch.preprovision.serial_number}) %} +{% set _ = preprov_item.update({'model': switch.preprovision.model}) %} +{% set _ = preprov_item.update({'version': switch.preprovision.version}) %} +{% set _ = preprov_item.update({'hostname': switch.preprovision.hostname}) %} +{% if switch.preprovision.config_data is defined %} +{% set preprov_config = {} %} +{% for k in switch.preprovision.config_data %} +{% set _ = preprov_config.update({k: switch.preprovision.config_data[k]}) %} {% endfor %} -{% set _ = poap_item.update({'config_data': poap_config_item}) %} +{% set _ = preprov_item.update({'config_data': preprov_config}) %} {% endif %} -{% set _ = switch_item.update({'poap': [poap_item]}) %} -{% endfor %} -{% else %} +{% if switch.preprovision.image_policy is defined and switch.preprovision.image_policy %} +{% set _ = preprov_item.update({'image_policy': switch.preprovision.image_policy}) %} +{% endif %} +{% if switch.preprovision.discovery_username is defined and switch.preprovision.discovery_username %} +{% set _ = preprov_item.update({'discovery_username': switch.preprovision.discovery_username}) %} +{% endif %} +{% if switch.preprovision.discovery_password is defined and switch.preprovision.discovery_password %} +{% set _ = preprov_item.update({'discovery_password': switch.preprovision.discovery_password}) %} +{% endif %} +{% set _ = switch_item.update({'preprovision': preprov_item}) %} +{% endif %} +{% if switch.poap is not defined and switch.preprovision is not defined %} {% if switch.auth_proto is defined %} {% set _ = switch_item.update({'auth_proto': switch.auth_proto | default('') }) %} {% endif %} diff --git a/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml b/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml index 62c3bd98..4b569004 100644 --- a/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml +++ b/tests/integration/targets/nd_manage_switches/tests/nd/poap.yaml @@ -121,14 +121,14 @@ username: '{{ switch_username }}' password: '{{ switch_password }}' role: border - poap: - - preprovision_serial: "{{ test_data.sw2_serial }}" - model: "{{ test_data.poap_model }}" - version: "{{ test_data.poap_version }}" - hostname: "{{ test_data.prepro_hostname }}" - config_data: - models: "{{ test_data.poap_configmodel }}" - gateway: "{{ test_data.poap_gateway }}" + preprovision: + serial_number: "{{ test_data.sw2_serial }}" + model: "{{ test_data.poap_model }}" + version: "{{ test_data.poap_version }}" + hostname: "{{ test_data.prepro_hostname }}" + config_data: + models: "{{ test_data.poap_configmodel }}" + gateway: "{{ test_data.poap_gateway }}" when: poap_enabled == True delegate_to: localhost tags: poap @@ -187,15 +187,12 @@ ansible.builtin.set_fact: switch_conf: - seed_ip: "{{ test_data.sw1 }}" + username: '{{ switch_username }}' + password: '{{ switch_password }}' role: leaf poap: - - serial_number: "{{ test_data.sw1_serial }}" - model: "{{ test_data.poap_model }}" - version: "{{ test_data.poap_version }}" - hostname: "{{ test_data.poap_hostname }}" - config_data: - models: "{{ test_data.poap_configmodel }}" - gateway: "{{ test_data.poap_gateway }}" + serial_number: "{{ test_data.sw1_serial }}" + hostname: "{{ test_data.poap_hostname }}" - seed_ip: "{{ test_data.sw3 }}" auth_proto: MD5 role: spine From c53e6fccc086bab3a6848bb13e1b233dd1a7fb84 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 27 Mar 2026 00:33:14 +0530 Subject: [PATCH 24/27] NDOutput Integration --- plugins/module_utils/nd_switch_resources.py | 37 +++++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/nd_switch_resources.py index 565bdf49..ec62900b 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/nd_switch_resources.py @@ -24,6 +24,7 @@ from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType from ansible_collections.cisco.nd.plugins.module_utils.nd_config_collection import NDConfigCollection +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.models.manage_switches import ( SwitchRole, @@ -2355,7 +2356,8 @@ def __init__( response_data=self._query_all_switches(), model_class=SwitchDataModel, ) - self.previous: NDConfigCollection = self.existing.copy() + self.before: NDConfigCollection = self.existing.copy() + self.sent: NDConfigCollection = NDConfigCollection(model_class=SwitchDataModel) except Exception as e: msg = ( f"Failed to query fabric '{self.fabric}' inventory " @@ -2366,6 +2368,8 @@ def __init__( # Operation tracking self.nd_logs: List[Dict[str, Any]] = [] + self.output: NDOutput = NDOutput(output_level=self.module.params.get("output_level", "normal")) + self.output.assign(before=self.before, after=self.existing) # Utility instances (SwitchWaitUtils / FabricUtils depend on self) self.fabric_utils = FabricUtils(self.nd, self.fabric, log) @@ -2406,16 +2410,17 @@ def exit_json(self) -> None: ) self.log.error(msg) self.nd.module.fail_json(msg=msg) - final["gathered"] = gathered + self.output.assign(after=self.existing) + final.update(self.output.format(gathered=gathered)) else: # Re-query the fabric to get the actual post-operation inventory so - # that "current" reflects real state rather than the pre-op snapshot. + # that "after" reflects real state rather than the pre-op snapshot. if True not in self.results.failed and not self.nd.module.check_mode: self.existing = NDConfigCollection.from_api_response( response_data=self._query_all_switches(), model_class=SwitchDataModel ) - final["previous"] = self.previous.to_ansible_config() - final["current"] = self.existing.to_ansible_config() + self.output.assign(after=self.existing, diff=self.sent) + final.update(self.output.format()) if True in self.results.failed: self.nd.module.fail_json(**final) @@ -2464,9 +2469,14 @@ def manage_state(self) -> None: self.config, self.state, self.nd, self.log ) # Partition configs by operation type - poap_configs = [c for c in proposed_config if c.operation_type == "poap"] + poap_configs = [c for c in proposed_config if c.operation_type in ("poap", "preprovision", "swap")] rma_configs = [c for c in proposed_config if c.operation_type == "rma"] - normal_configs = [c for c in proposed_config if c.operation_type not in ("poap", "rma")] + normal_configs = [c for c in proposed_config if c.operation_type == "normal"] + # Capture all proposed configs for NDOutput + output_proposed: NDConfigCollection = NDConfigCollection(model_class=SwitchConfigModel) + for cfg in proposed_config: + output_proposed.add(cfg) + self.output.assign(proposed=output_proposed) self.log.info( f"Config partition: {len(normal_configs)} normal, " @@ -2584,6 +2594,7 @@ def _handle_merged_state( # Collect (serial_number, SwitchConfigModel) pairs for post-processing switch_actions: List[Tuple[str, SwitchConfigModel]] = [] + _bulk_added_ips: set = set() # Phase 4: Bulk add new switches to fabric if switches_to_add and discovered_data: @@ -2622,6 +2633,7 @@ def _handle_merged_state( platform_type=platform_type, preserve_config=preserve_config, ) + _bulk_added_ips.update(cfg.seed_ip for cfg, _ in pairs) for cfg, disc in pairs: sn = disc.get("serialNumber") @@ -2631,6 +2643,13 @@ def _handle_merged_state( # Phase 5: Collect migration switches for post-processing # Migration mode switches get role updates during post-add processing. + # Track newly added switches in self.sent + if switches_to_add: + _sw_by_ip = {sw.fabric_management_ip: sw for sw in switches_to_add} + for ip in _bulk_added_ips: + sw_data = _sw_by_ip.get(ip) + if sw_data: + self.sent.add(sw_data) have_migration_switches = False if migration_switches: @@ -2826,6 +2845,8 @@ def _handle_overridden_state( ) self.log.error(msg) self.nd.module.fail_json(msg=msg) + for sw in switches_to_delete: + self.sent.add(sw) diff["to_update"] = [] @@ -2941,6 +2962,8 @@ def _handle_deleted_state( f"Proceeding to delete {len(switches_to_delete)} switch(es) from fabric" ) self.fabric_ops.bulk_delete(switches_to_delete) + for sw in switches_to_delete: + self.sent.add(sw) self.log.debug("EXIT: _handle_deleted_state()") # ===================================================================== From 91a05c6d66f1080702e77aca07f864fa75b3455a Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 27 Mar 2026 01:10:29 +0530 Subject: [PATCH 25/27] Utils restructuring --- .../module_utils/manage_switches/__init__.py | 34 ++ .../nd_switch_resources.py | 6 +- .../utils.py} | 317 +++++++++++++++++- plugins/module_utils/utils.py | 175 +++++++++- .../utils/manage_switches/__init__.py | 46 --- .../utils/manage_switches/bootstrap_utils.py | 111 ------ .../utils/manage_switches/exceptions.py | 20 -- .../utils/manage_switches/fabric_utils.py | 179 ---------- .../utils/manage_switches/payload_utils.py | 90 ----- .../utils/manage_switches/switch_helpers.py | 138 -------- plugins/modules/nd_manage_switches.py | 2 +- 11 files changed, 526 insertions(+), 592 deletions(-) create mode 100644 plugins/module_utils/manage_switches/__init__.py rename plugins/module_utils/{ => manage_switches}/nd_switch_resources.py (99%) rename plugins/module_utils/{utils/manage_switches/switch_wait_utils.py => manage_switches/utils.py} (70%) delete mode 100644 plugins/module_utils/utils/manage_switches/__init__.py delete mode 100644 plugins/module_utils/utils/manage_switches/bootstrap_utils.py delete mode 100644 plugins/module_utils/utils/manage_switches/exceptions.py delete mode 100644 plugins/module_utils/utils/manage_switches/fabric_utils.py delete mode 100644 plugins/module_utils/utils/manage_switches/payload_utils.py delete mode 100644 plugins/module_utils/utils/manage_switches/switch_helpers.py diff --git a/plugins/module_utils/manage_switches/__init__.py b/plugins/module_utils/manage_switches/__init__.py new file mode 100644 index 00000000..aa6dfd90 --- /dev/null +++ b/plugins/module_utils/manage_switches/__init__.py @@ -0,0 +1,34 @@ +# -*- 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_switches package. + +Re-exports the orchestrator and utility classes so that consumers can +import directly from the package. +""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ansible_collections.cisco.nd.plugins.module_utils.manage_switches.nd_switch_resources import ( # noqa: F401 + NDSwitchResourceModule, +) +from ansible_collections.cisco.nd.plugins.module_utils.utils import ( # noqa: F401 + SwitchOperationError, + FabricUtils, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_switches.utils import ( # noqa: F401 + PayloadUtils, + SwitchWaitUtils, + mask_password, + get_switch_field, + determine_operation_type, + group_switches_by_credentials, + query_bootstrap_switches, + build_bootstrap_index, + build_poap_data_block, +) diff --git a/plugins/module_utils/nd_switch_resources.py b/plugins/module_utils/manage_switches/nd_switch_resources.py similarity index 99% rename from plugins/module_utils/nd_switch_resources.py rename to plugins/module_utils/manage_switches/nd_switch_resources.py index ec62900b..6b9a1b99 100644 --- a/plugins/module_utils/nd_switch_resources.py +++ b/plugins/module_utils/manage_switches/nd_switch_resources.py @@ -49,10 +49,12 @@ PreprovisionConfigModel, RMAConfigModel, ) -from ansible_collections.cisco.nd.plugins.module_utils.utils.manage_switches import ( +from ansible_collections.cisco.nd.plugins.module_utils.utils import ( FabricUtils, - SwitchWaitUtils, SwitchOperationError, +) +from ansible_collections.cisco.nd.plugins.module_utils.manage_switches.utils import ( + SwitchWaitUtils, mask_password, get_switch_field, group_switches_by_credentials, diff --git a/plugins/module_utils/utils/manage_switches/switch_wait_utils.py b/plugins/module_utils/manage_switches/utils.py similarity index 70% rename from plugins/module_utils/utils/manage_switches/switch_wait_utils.py rename to plugins/module_utils/manage_switches/utils.py index 2d6e281d..ed47393c 100644 --- a/plugins/module_utils/utils/manage_switches/switch_wait_utils.py +++ b/plugins/module_utils/manage_switches/utils.py @@ -4,7 +4,10 @@ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) -"""Multi-phase wait utilities for switch lifecycle operations.""" +"""Utility helpers for nd_manage_switches: exceptions, fabric operations, +payload construction, credential grouping, bootstrap queries, and +multi-phase switch wait utilities. +""" from __future__ import absolute_import, division, print_function @@ -12,8 +15,12 @@ import logging import time -from typing import Any, Dict, List, Optional +from copy import deepcopy +from typing import Any, Dict, List, Optional, Tuple, Union +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_bootstrap import ( + EpManageFabricsBootstrapGet, +) from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_inventory import ( EpManageFabricsInventoryDiscoverGet, ) @@ -23,8 +30,300 @@ from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_switchactions import ( EpManageFabricsSwitchActionsRediscoverPost, ) +from ansible_collections.cisco.nd.plugins.module_utils.utils import ( + FabricUtils, + SwitchOperationError, +) + + +# ========================================================================= +# Payload Utilities +# ========================================================================= + + +def mask_password(payload: Dict[str, Any]) -> Dict[str, Any]: + """Return a deep copy of *payload* with password fields masked. + + Useful for safe logging of API payloads that contain credentials. + + Args: + payload: API payload dict (may contain ``password`` keys). + + Returns: + Copy with every ``password`` value replaced by ``"********"``. + """ + masked = deepcopy(payload) + if "password" in masked: + masked["password"] = "********" + if isinstance(masked.get("switches"), list): + for switch in masked["switches"]: + if isinstance(switch, dict) and "password" in switch: + switch["password"] = "********" + return masked + + +class PayloadUtils: + """Stateless helper for building ND Switch Resource API request payloads.""" + + def __init__(self, logger: Optional[logging.Logger] = None): + """Initialize PayloadUtils. + + Args: + logger: Optional logger; defaults to ``nd.PayloadUtils``. + """ + self.log = logger or logging.getLogger("nd.PayloadUtils") + + def build_credentials_payload( + self, + serial_numbers: List[str], + username: str, + password: str, + ) -> Dict[str, Any]: + """Build payload for saving switch credentials. + + Args: + serial_numbers: Switch serial numbers. + username: Switch username. + password: Switch password. -from .fabric_utils import FabricUtils + Returns: + Credentials API payload dict. + """ + return { + "switchIds": serial_numbers, + "username": username, + "password": password, + } + + def build_switch_ids_payload( + self, + serial_numbers: List[str], + ) -> Dict[str, Any]: + """Build payload with switch IDs for remove / batch operations. + + Args: + serial_numbers: Switch serial numbers. + + Returns: + ``{"switchIds": [...]}`` payload dict. + """ + return {"switchIds": serial_numbers} + + +# ========================================================================= +# Switch Helpers +# ========================================================================= + + +def get_switch_field( + switch, + field_names: List[str], +) -> Optional[Any]: + """Extract a field value from a switch config, trying multiple names. + + Supports Pydantic models and plain dicts with both snake_case and + camelCase key lookups. + + Args: + switch: Switch model or dict to extract from. + field_names: Candidate field names to try, in priority order. + + Returns: + First non-``None`` value found, or ``None``. + """ + for name in field_names: + if hasattr(switch, name): + value = getattr(switch, name) + if value is not None: + return value + elif isinstance(switch, dict): + if name in switch and switch[name] is not None: + return switch[name] + # Try camelCase variant + camel = ''.join( + word.capitalize() if i > 0 else word + for i, word in enumerate(name.split('_')) + ) + if camel in switch and switch[camel] is not None: + return switch[camel] + return None + + +def determine_operation_type(switch) -> str: + """Determine the operation type from switch configuration. + + Args: + switch: A ``SwitchConfigModel``, ``SwitchDiscoveryModel``, + or raw dict. + + Returns: + ``'normal'``, ``'poap'``, or ``'rma'``. + """ + # Pydantic model with .operation_type attribute + if hasattr(switch, 'operation_type'): + return switch.operation_type + + if isinstance(switch, dict): + if 'poap' in switch or 'bootstrap' in switch: + return 'poap' + if ( + 'rma' in switch + or 'old_serial' in switch + or 'oldSerial' in switch + ): + return 'rma' + + return 'normal' + + +def group_switches_by_credentials( + switches, + log: logging.Logger, +) -> Dict[Tuple, list]: + """Group switches by shared credentials for bulk API operations. + + Args: + switches: Validated ``SwitchConfigModel`` instances. + log: Logger. + + Returns: + Dict mapping a ``(username, password_hash, auth_proto, + platform_type, preserve_config)`` tuple to the list of switches + sharing those credentials. + """ + groups: Dict[Tuple, list] = {} + + for switch in switches: + password_hash = hash(switch.password) + group_key = ( + switch.username, + password_hash, + switch.auth_proto, + switch.platform_type, + switch.preserve_config, + ) + groups.setdefault(group_key, []).append(switch) + + log.info( + f"Grouped {len(switches)} switches into " + f"{len(groups)} credential group(s)" + ) + + for idx, (key, group_switches) in enumerate(groups.items(), 1): + username, _, auth_proto, platform_type, preserve_config = key + auth_value = ( + auth_proto.value + if hasattr(auth_proto, 'value') + else str(auth_proto) + ) + platform_value = ( + platform_type.value + if hasattr(platform_type, 'value') + else str(platform_type) + ) + log.debug( + f"Group {idx}: {len(group_switches)} switches with " + f"username={username}, auth={auth_value}, " + f"platform={platform_value}, " + f"preserve_config={preserve_config}" + ) + + return groups + + +# ========================================================================= +# Bootstrap Utilities +# ========================================================================= + + +def query_bootstrap_switches( + nd, + fabric: str, + log: logging.Logger, +) -> List[Dict[str, Any]]: + """GET switches currently in the bootstrap (POAP / PnP) loop. + + Args: + nd: NDModule instance (REST client). + fabric: Fabric name. + log: Logger. + + Returns: + List of raw switch dicts from the bootstrap API. + """ + log.debug("ENTER: query_bootstrap_switches()") + + endpoint = EpManageFabricsBootstrapGet() + endpoint.fabric_name = fabric + log.debug(f"Bootstrap endpoint: {endpoint.path}") + + try: + result = nd.request( + path=endpoint.path, verb=endpoint.verb, + ) + except Exception as e: + msg = ( + f"Failed to query bootstrap switches for " + f"fabric '{fabric}': {e}" + ) + log.error(msg) + nd.module.fail_json(msg=msg) + + if isinstance(result, dict): + switches = result.get("switches", []) + elif isinstance(result, list): + switches = result + else: + switches = [] + + log.info( + f"Bootstrap API returned {len(switches)} " + f"switch(es) in POAP loop" + ) + log.debug("EXIT: query_bootstrap_switches()") + return switches + + +def build_bootstrap_index( + bootstrap_switches: List[Dict[str, Any]], +) -> Dict[str, Dict[str, Any]]: + """Build a serial-number-keyed index from bootstrap API data. + + Args: + bootstrap_switches: Raw switch dicts from the bootstrap API. + + Returns: + Dict mapping ``serial_number`` -> switch dict. + """ + return { + sw.get("serialNumber", sw.get("serial_number", "")): sw + for sw in bootstrap_switches + } + + +def build_poap_data_block(poap_cfg) -> Optional[Dict[str, Any]]: + """Build optional data block for bootstrap and pre-provision models. + + Args: + poap_cfg: ``POAPConfigModel`` from the user playbook. + + Returns: + Data block dict, or ``None`` if no ``config_data`` is present. + """ + if not poap_cfg.config_data: + return None + data_block: Dict[str, Any] = {} + gateway = poap_cfg.config_data.gateway + if gateway: + data_block["gatewayIpMask"] = gateway + if poap_cfg.config_data.models: + data_block["models"] = poap_cfg.config_data.models + return data_block or None + + +# ========================================================================= +# Switch Wait Utilities +# ========================================================================= class SwitchWaitUtils: @@ -80,7 +379,7 @@ def __init__( fabric: Fabric name. logger: Optional logger; defaults to ``nd.SwitchWaitUtils``. max_attempts: Max polling iterations (default ``300``). - wait_interval: Seconds between polls (default ``5``). + wait_interval: Override interval in seconds (default ``5``). fabric_utils: Optional ``FabricUtils`` instance for fabric info queries. Created internally if not provided. """ @@ -680,5 +979,15 @@ def _is_greenfield_debug_enabled(self) -> bool: __all__ = [ + "SwitchOperationError", + "PayloadUtils", + "FabricUtils", "SwitchWaitUtils", + "mask_password", + "get_switch_field", + "determine_operation_type", + "group_switches_by_credentials", + "query_bootstrap_switches", + "build_bootstrap_index", + "build_poap_data_block", ] diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py index 7d05e4af..44a55195 100644 --- a/plugins/module_utils/utils.py +++ b/plugins/module_utils/utils.py @@ -4,8 +4,18 @@ from __future__ import absolute_import, division, print_function +import logging +import time from copy import deepcopy -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Optional, Union + +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics import ( + EpManageFabricConfigDeployPost, + EpManageFabricGet, +) +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions import ( + EpManageFabricsActionsConfigSavePost, +) def sanitize_dict(dict_to_sanitize, keys=None, values=None, recursive=True, remove_none_values=True): @@ -76,3 +86,166 @@ def remove_unwanted_keys(data: Dict, unwanted_keys: List[Union[str, List[str]]]) pass return data + + +# ========================================================================= +# Exceptions +# ========================================================================= + + +class SwitchOperationError(Exception): + """Raised when a switch operation fails.""" + + +# ========================================================================= +# Fabric Utilities +# ========================================================================= + + +class FabricUtils: + """Fabric-level operations: config save, deploy, and info retrieval.""" + + def __init__( + self, + nd_module, + fabric: str, + logger: Optional[logging.Logger] = None, + ): + """Initialize FabricUtils. + + Args: + nd_module: NDModule or NDNetworkResourceModule instance. + fabric: Fabric name. + logger: Optional logger; defaults to ``nd.FabricUtils``. + """ + self.nd = nd_module + self.fabric = fabric + self.log = logger or logging.getLogger("nd.FabricUtils") + + # Pre-configure endpoints + self.ep_config_save = EpManageFabricsActionsConfigSavePost() + self.ep_config_save.fabric_name = fabric + + self.ep_config_deploy = EpManageFabricConfigDeployPost() + self.ep_config_deploy.fabric_name = fabric + + self.ep_fabric_get = EpManageFabricGet() + self.ep_fabric_get.fabric_name = fabric + + # ----------------------------------------------------------------- + # Public API + # ----------------------------------------------------------------- + + def save_config( + self, + max_retries: int = 3, + retry_delay: int = 600, + ) -> Dict[str, Any]: + """Save (recalculate) fabric configuration. + + Retries up to ``max_retries`` times with ``retry_delay`` seconds + between attempts. + + Args: + max_retries: Maximum number of attempts (default ``3``). + retry_delay: Seconds to wait between failed attempts + (default ``600``). + + Returns: + API response dict from the first successful attempt. + + Raises: + SwitchOperationError: If all attempts fail. + """ + last_error: Exception = SwitchOperationError( + f"Config save produced no attempts for fabric {self.fabric}" + ) + for attempt in range(1, max_retries + 1): + try: + response = self._request_endpoint( + self.ep_config_save, action="Config save" + ) + self.log.info( + f"Config save succeeded on attempt " + f"{attempt}/{max_retries} for fabric {self.fabric}" + ) + return response + except SwitchOperationError as exc: + last_error = exc + self.log.warning( + f"Config save attempt {attempt}/{max_retries} failed " + f"for fabric {self.fabric}: {exc}" + ) + if attempt < max_retries: + self.log.info( + f"Retrying config save in {retry_delay}s " + f"(attempt {attempt + 1}/{max_retries})" + ) + time.sleep(retry_delay) + raise SwitchOperationError( + f"Config save failed after {max_retries} attempt(s) " + f"for fabric {self.fabric}: {last_error}" + ) + + def deploy_config(self) -> Dict[str, Any]: + """Deploy pending configuration to all switches in the fabric. + + The ``configDeploy`` endpoint requires no request body; it deploys + all pending changes for the fabric. + + Returns: + API response dict. + + Raises: + SwitchOperationError: If the deploy request fails. + """ + return self._request_endpoint( + self.ep_config_deploy, action="Config deploy" + ) + + def get_fabric_info(self) -> Dict[str, Any]: + """Retrieve fabric information. + + Returns: + Fabric information dict. + + Raises: + SwitchOperationError: If the request fails. + """ + return self._request_endpoint( + self.ep_fabric_get, action="Get fabric info" + ) + + # ----------------------------------------------------------------- + # Internal helpers + # ----------------------------------------------------------------- + + def _request_endpoint( + self, endpoint, action: str = "Request" + ) -> Dict[str, Any]: + """Execute a request against a pre-configured endpoint. + + Args: + endpoint: Endpoint object with ``.path`` and ``.verb``. + action: Human-readable label for log messages. + + Returns: + API response dict. + + Raises: + SwitchOperationError: On any request failure. + """ + self.log.info(f"{action} for fabric: {self.fabric}") + try: + response = self.nd.request(endpoint.path, verb=endpoint.verb) + self.log.info( + f"{action} completed for fabric: {self.fabric}" + ) + return response + except Exception as e: + self.log.error( + f"{action} failed for fabric {self.fabric}: {e}" + ) + raise SwitchOperationError( + f"{action} failed for fabric {self.fabric}: {e}" + ) from e diff --git a/plugins/module_utils/utils/manage_switches/__init__.py b/plugins/module_utils/utils/manage_switches/__init__.py deleted file mode 100644 index bb142fe1..00000000 --- a/plugins/module_utils/utils/manage_switches/__init__.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- 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_switches utilities package. - -Re-exports all utility classes, functions, and exceptions so that -consumers can import directly from the package: - -""" - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -from ansible_collections.cisco.nd.plugins.module_utils.utils.manage_switches.exceptions import SwitchOperationError # noqa: F401 -from .payload_utils import PayloadUtils, mask_password # noqa: F401 -from .fabric_utils import FabricUtils # noqa: F401 -from .switch_wait_utils import SwitchWaitUtils # noqa: F401 -from .switch_helpers import ( # noqa: F401 - get_switch_field, - determine_operation_type, - group_switches_by_credentials, -) -from .bootstrap_utils import ( # noqa: F401 - query_bootstrap_switches, - build_bootstrap_index, - build_poap_data_block, -) - - -__all__ = [ - "SwitchOperationError", - "PayloadUtils", - "FabricUtils", - "SwitchWaitUtils", - "mask_password", - "get_switch_field", - "determine_operation_type", - "group_switches_by_credentials", - "query_bootstrap_switches", - "build_bootstrap_index", - "build_poap_data_block", -] diff --git a/plugins/module_utils/utils/manage_switches/bootstrap_utils.py b/plugins/module_utils/utils/manage_switches/bootstrap_utils.py deleted file mode 100644 index d78d2531..00000000 --- a/plugins/module_utils/utils/manage_switches/bootstrap_utils.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- 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) - -"""Bootstrap API helpers for POAP switch queries, serial-number indexing, and payload construction.""" - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -import logging -from typing import Any, Dict, List, Optional - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_bootstrap import ( - EpManageFabricsBootstrapGet, -) - - -def query_bootstrap_switches( - nd, - fabric: str, - log: logging.Logger, -) -> List[Dict[str, Any]]: - """GET switches currently in the bootstrap (POAP / PnP) loop. - - Args: - nd: NDModule instance (REST client). - fabric: Fabric name. - log: Logger. - - Returns: - List of raw switch dicts from the bootstrap API. - """ - log.debug("ENTER: query_bootstrap_switches()") - - endpoint = EpManageFabricsBootstrapGet() - endpoint.fabric_name = fabric - log.debug(f"Bootstrap endpoint: {endpoint.path}") - - try: - result = nd.request( - path=endpoint.path, verb=endpoint.verb, - ) - except Exception as e: - msg = ( - f"Failed to query bootstrap switches for " - f"fabric '{fabric}': {e}" - ) - log.error(msg) - nd.module.fail_json(msg=msg) - - if isinstance(result, dict): - switches = result.get("switches", []) - elif isinstance(result, list): - switches = result - else: - switches = [] - - log.info( - f"Bootstrap API returned {len(switches)} " - f"switch(es) in POAP loop" - ) - log.debug("EXIT: query_bootstrap_switches()") - return switches - - -def build_bootstrap_index( - bootstrap_switches: List[Dict[str, Any]], -) -> Dict[str, Dict[str, Any]]: - """Build a serial-number-keyed index from bootstrap API data. - - Args: - bootstrap_switches: Raw switch dicts from the bootstrap API. - - Returns: - Dict mapping ``serial_number`` -> switch dict. - """ - return { - sw.get("serialNumber", sw.get("serial_number", "")): sw - for sw in bootstrap_switches - } - - -def build_poap_data_block(poap_cfg) -> Optional[Dict[str, Any]]: - """Build optional data block for bootstrap and pre-provision models. - - Args: - poap_cfg: ``POAPConfigModel`` from the user playbook. - - Returns: - Data block dict, or ``None`` if no ``config_data`` is present. - """ - if not poap_cfg.config_data: - return None - data_block: Dict[str, Any] = {} - gateway = poap_cfg.config_data.gateway - if gateway: - data_block["gatewayIpMask"] = gateway - if poap_cfg.config_data.models: - data_block["models"] = poap_cfg.config_data.models - return data_block or None - - -__all__ = [ - "query_bootstrap_switches", - "build_bootstrap_index", - "build_poap_data_block", -] diff --git a/plugins/module_utils/utils/manage_switches/exceptions.py b/plugins/module_utils/utils/manage_switches/exceptions.py deleted file mode 100644 index 8e5b0055..00000000 --- a/plugins/module_utils/utils/manage_switches/exceptions.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- 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) - -"""Custom exceptions for ND Switch Resource operations.""" - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - - -class SwitchOperationError(Exception): - """Raised when a switch operation fails.""" - - -__all__ = [ - "SwitchOperationError", -] diff --git a/plugins/module_utils/utils/manage_switches/fabric_utils.py b/plugins/module_utils/utils/manage_switches/fabric_utils.py deleted file mode 100644 index ab4557da..00000000 --- a/plugins/module_utils/utils/manage_switches/fabric_utils.py +++ /dev/null @@ -1,179 +0,0 @@ -# -*- 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) - -"""Fabric-level operations: config save, deploy, and info retrieval.""" - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -import logging -import time -from typing import Any, Dict, Optional - -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics import ( - EpManageFabricConfigDeployPost, - EpManageFabricGet, -) -from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_fabrics_actions import ( - EpManageFabricsActionsConfigSavePost, -) - -from .exceptions import SwitchOperationError - - -class FabricUtils: - """Fabric-level operations: config save, deploy, and info retrieval.""" - - def __init__( - self, - nd_module, - fabric: str, - logger: Optional[logging.Logger] = None, - ): - """Initialize FabricUtils. - - Args: - nd_module: NDModule or NDNetworkResourceModule instance. - fabric: Fabric name. - logger: Optional logger; defaults to ``nd.FabricUtils``. - """ - self.nd = nd_module - self.fabric = fabric - self.log = logger or logging.getLogger("nd.FabricUtils") - - # Pre-configure endpoints - self.ep_config_save = EpManageFabricsActionsConfigSavePost() - self.ep_config_save.fabric_name = fabric - - self.ep_config_deploy = EpManageFabricConfigDeployPost() - self.ep_config_deploy.fabric_name = fabric - - self.ep_fabric_get = EpManageFabricGet() - self.ep_fabric_get.fabric_name = fabric - - # ----------------------------------------------------------------- - # Public API - # ----------------------------------------------------------------- - - def save_config( - self, - max_retries: int = 3, - retry_delay: int = 600, - ) -> Dict[str, Any]: - """Save (recalculate) fabric configuration. - - Retries up to ``max_retries`` times with ``retry_delay`` seconds - between attempts. - - Args: - max_retries: Maximum number of attempts (default ``3``). - retry_delay: Seconds to wait between failed attempts - (default ``600``). - - Returns: - API response dict from the first successful attempt. - - Raises: - SwitchOperationError: If all attempts fail. - """ - last_error: Exception = SwitchOperationError( - f"Config save produced no attempts for fabric {self.fabric}" - ) - for attempt in range(1, max_retries + 1): - try: - response = self._request_endpoint( - self.ep_config_save, action="Config save" - ) - self.log.info( - f"Config save succeeded on attempt " - f"{attempt}/{max_retries} for fabric {self.fabric}" - ) - return response - except SwitchOperationError as exc: - last_error = exc - self.log.warning( - f"Config save attempt {attempt}/{max_retries} failed " - f"for fabric {self.fabric}: {exc}" - ) - if attempt < max_retries: - self.log.info( - f"Retrying config save in {retry_delay}s " - f"(attempt {attempt + 1}/{max_retries})" - ) - time.sleep(retry_delay) - raise SwitchOperationError( - f"Config save failed after {max_retries} attempt(s) " - f"for fabric {self.fabric}: {last_error}" - ) - - def deploy_config(self) -> Dict[str, Any]: - """Deploy pending configuration to all switches in the fabric. - - The ``configDeploy`` endpoint requires no request body; it deploys - all pending changes for the fabric. - - Returns: - API response dict. - - Raises: - SwitchOperationError: If the deploy request fails. - """ - return self._request_endpoint( - self.ep_config_deploy, action="Config deploy" - ) - - def get_fabric_info(self) -> Dict[str, Any]: - """Retrieve fabric information. - - Returns: - Fabric information dict. - - Raises: - SwitchOperationError: If the request fails. - """ - return self._request_endpoint( - self.ep_fabric_get, action="Get fabric info" - ) - - # ----------------------------------------------------------------- - # Internal helpers - # ----------------------------------------------------------------- - - def _request_endpoint( - self, endpoint, action: str = "Request" - ) -> Dict[str, Any]: - """Execute a request against a pre-configured endpoint. - - Args: - endpoint: Endpoint object with ``.path`` and ``.verb``. - action: Human-readable label for log messages. - - Returns: - API response dict. - - Raises: - SwitchOperationError: On any request failure. - """ - self.log.info(f"{action} for fabric: {self.fabric}") - try: - response = self.nd.request(endpoint.path, verb=endpoint.verb) - self.log.info( - f"{action} completed for fabric: {self.fabric}" - ) - return response - except Exception as e: - self.log.error( - f"{action} failed for fabric {self.fabric}: {e}" - ) - raise SwitchOperationError( - f"{action} failed for fabric {self.fabric}: {e}" - ) from e - - -__all__ = [ - "FabricUtils", -] diff --git a/plugins/module_utils/utils/manage_switches/payload_utils.py b/plugins/module_utils/utils/manage_switches/payload_utils.py deleted file mode 100644 index 84e99b99..00000000 --- a/plugins/module_utils/utils/manage_switches/payload_utils.py +++ /dev/null @@ -1,90 +0,0 @@ -# -*- 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) - -"""API payload builders for ND Switch Resource operations.""" - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -import logging -from copy import deepcopy -from typing import Any, Dict, List, Optional - - -def mask_password(payload: Dict[str, Any]) -> Dict[str, Any]: - """Return a deep copy of *payload* with password fields masked. - - Useful for safe logging of API payloads that contain credentials. - - Args: - payload: API payload dict (may contain ``password`` keys). - - Returns: - Copy with every ``password`` value replaced by ``"********"``. - """ - masked = deepcopy(payload) - if "password" in masked: - masked["password"] = "********" - if isinstance(masked.get("switches"), list): - for switch in masked["switches"]: - if isinstance(switch, dict) and "password" in switch: - switch["password"] = "********" - return masked - - -class PayloadUtils: - """Stateless helper for building ND Switch Resource API request payloads.""" - - def __init__(self, logger: Optional[logging.Logger] = None): - """Initialize PayloadUtils. - - Args: - logger: Optional logger; defaults to ``nd.PayloadUtils``. - """ - self.log = logger or logging.getLogger("nd.PayloadUtils") - - def build_credentials_payload( - self, - serial_numbers: List[str], - username: str, - password: str, - ) -> Dict[str, Any]: - """Build payload for saving switch credentials. - - Args: - serial_numbers: Switch serial numbers. - username: Switch username. - password: Switch password. - - Returns: - Credentials API payload dict. - """ - return { - "switchIds": serial_numbers, - "username": username, - "password": password, - } - - def build_switch_ids_payload( - self, - serial_numbers: List[str], - ) -> Dict[str, Any]: - """Build payload with switch IDs for remove / batch operations. - - Args: - serial_numbers: Switch serial numbers. - - Returns: - ``{"switchIds": [...]}`` payload dict. - """ - return {"switchIds": serial_numbers} - - -__all__ = [ - "mask_password", - "PayloadUtils", -] diff --git a/plugins/module_utils/utils/manage_switches/switch_helpers.py b/plugins/module_utils/utils/manage_switches/switch_helpers.py deleted file mode 100644 index 539309a7..00000000 --- a/plugins/module_utils/utils/manage_switches/switch_helpers.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- 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) - -"""Stateless utility helpers for switch field extraction, operation-type detection, and credential grouping.""" - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -import logging -from typing import Any, Dict, List, Optional, Tuple, Union - - -def get_switch_field( - switch, - field_names: List[str], -) -> Optional[Any]: - """Extract a field value from a switch config, trying multiple names. - - Supports Pydantic models and plain dicts with both snake_case and - camelCase key lookups. - - Args: - switch: Switch model or dict to extract from. - field_names: Candidate field names to try, in priority order. - - Returns: - First non-``None`` value found, or ``None``. - """ - for name in field_names: - if hasattr(switch, name): - value = getattr(switch, name) - if value is not None: - return value - elif isinstance(switch, dict): - if name in switch and switch[name] is not None: - return switch[name] - # Try camelCase variant - camel = ''.join( - word.capitalize() if i > 0 else word - for i, word in enumerate(name.split('_')) - ) - if camel in switch and switch[camel] is not None: - return switch[camel] - return None - - -def determine_operation_type(switch) -> str: - """Determine the operation type from switch configuration. - - Args: - switch: A ``SwitchConfigModel``, ``SwitchDiscoveryModel``, - or raw dict. - - Returns: - ``'normal'``, ``'poap'``, or ``'rma'``. - """ - # Pydantic model with .operation_type attribute - if hasattr(switch, 'operation_type'): - return switch.operation_type - - if isinstance(switch, dict): - if 'poap' in switch or 'bootstrap' in switch: - return 'poap' - if ( - 'rma' in switch - or 'old_serial' in switch - or 'oldSerial' in switch - ): - return 'rma' - - return 'normal' - - -def group_switches_by_credentials( - switches, - log: logging.Logger, -) -> Dict[Tuple, list]: - """Group switches by shared credentials for bulk API operations. - - Args: - switches: Validated ``SwitchConfigModel`` instances. - log: Logger. - - Returns: - Dict mapping a ``(username, password_hash, auth_proto, - platform_type, preserve_config)`` tuple to the list of switches - sharing those credentials. - """ - groups: Dict[Tuple, list] = {} - - for switch in switches: - password_hash = hash(switch.password) - group_key = ( - switch.username, - password_hash, - switch.auth_proto, - switch.platform_type, - switch.preserve_config, - ) - groups.setdefault(group_key, []).append(switch) - - log.info( - f"Grouped {len(switches)} switches into " - f"{len(groups)} credential group(s)" - ) - - for idx, (key, group_switches) in enumerate(groups.items(), 1): - username, _, auth_proto, platform_type, preserve_config = key - auth_value = ( - auth_proto.value - if hasattr(auth_proto, 'value') - else str(auth_proto) - ) - platform_value = ( - platform_type.value - if hasattr(platform_type, 'value') - else str(platform_type) - ) - log.debug( - f"Group {idx}: {len(group_switches)} switches with " - f"username={username}, auth={auth_value}, " - f"platform={platform_value}, " - f"preserve_config={preserve_config}" - ) - - return groups - - -__all__ = [ - "get_switch_field", - "determine_operation_type", - "group_switches_by_credentials", -] diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index 1019ff05..3942f93e 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -429,7 +429,7 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.cisco.nd.plugins.module_utils.common.log import Log from ansible_collections.cisco.nd.plugins.module_utils.models.manage_switches.config_models import SwitchConfigModel -from ansible_collections.cisco.nd.plugins.module_utils.nd_switch_resources import NDSwitchResourceModule +from ansible_collections.cisco.nd.plugins.module_utils.manage_switches.nd_switch_resources import NDSwitchResourceModule from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( NDModule, NDModuleError, From d87293738095e2f992da58e41fb9d0714243a9c5 Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 27 Mar 2026 01:14:35 +0530 Subject: [PATCH 26/27] Documentation updates --- plugins/modules/nd_manage_switches.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index 3942f93e..9bf88c5e 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -209,8 +209,8 @@ rma: description: - RMA an existing switch with a new one. - - Please note that the existing switch should be configured and deployed in maintenance mode. - - Please note that the existing switch being replaced should be shutdown state or out of network. + - Please note that the existing switch being replaced should be configured, deployed in maintenance mode + and then shutdown (unreachable state). type: list elements: dict suboptions: @@ -271,7 +271,7 @@ - cisco.nd.modules - cisco.nd.check_mode notes: -- This module requires ND 12.x or higher. +- This module requires ND 4.2 or higher. - POAP operations require POAP and DHCP to be enabled in fabric settings. - RMA operations require the old switch to be in a replaceable state. - Idempotence for B(Bootstrap) - A bootstrap entry is considered idempotent when From 5621cb4f52dfceef3cd1e4653f8dd97eae80492f Mon Sep 17 00:00:00 2001 From: AKDRG Date: Fri, 27 Mar 2026 01:16:45 +0530 Subject: [PATCH 27/27] Doc update --- plugins/modules/nd_manage_switches.py | 46 +++++++++++++-------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/plugins/modules/nd_manage_switches.py b/plugins/modules/nd_manage_switches.py index 9bf88c5e..877ac868 100644 --- a/plugins/modules/nd_manage_switches.py +++ b/plugins/modules/nd_manage_switches.py @@ -158,6 +158,15 @@ - Serial number of the switch to Pre-provision. type: str required: true + discovery_username: + description: + - Username for device discovery during pre-provision. + type: str + discovery_password: + description: + - Password for device discovery during pre-provision. + type: str + no_log: true model: description: - Model of the switch to Pre-provision (e.g., N9K-C93180YC-EX). @@ -173,6 +182,10 @@ - Hostname for the switch during pre-provision. type: str required: true + image_policy: + description: + - Image policy to apply during pre-provision. + type: str config_data: description: - Basic configuration data for the switch during Pre-provision. @@ -193,19 +206,6 @@ - Gateway IP with subnet mask (e.g., 192.168.0.1/24). type: str required: true - discovery_username: - description: - - Username for device discovery during pre-provision. - type: str - discovery_password: - description: - - Password for device discovery during pre-provision. - type: str - no_log: true - image_policy: - description: - - Image policy to apply during pre-provision. - type: str rma: description: - RMA an existing switch with a new one. @@ -214,14 +214,6 @@ type: list elements: dict suboptions: - discovery_username: - description: - - Username for device discovery during POAP and RMA discovery. - type: str - discovery_password: - description: - - Password for device discovery during POAP and RMA discovery. - type: str new_serial_number: description: - Serial number of switch to Bootstrap for RMA. @@ -232,6 +224,15 @@ - Serial number of switch to be replaced by RMA. type: str required: true + discovery_username: + description: + - Username for device discovery during POAP and RMA discovery. + type: str + discovery_password: + description: + - Password for device discovery during POAP and RMA discovery. + type: str + no_log: true model: description: - Model of switch to Bootstrap for RMA. @@ -263,9 +264,6 @@ - Gateway IP with subnet mask (e.g., 192.168.0.1/24). type: str required: true - - Serial number of new replacement switch. - type: str - required: true extends_documentation_fragment: - cisco.nd.modules