diff --git a/plugins/module_utils/endpoints/v1/manage/manage_tor.py b/plugins/module_utils/endpoints/v1/manage/manage_tor.py new file mode 100644 index 00000000..5a6623f5 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_tor.py @@ -0,0 +1,117 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +ND Manage Access/ToR Association endpoint models. + +Endpoints for access or ToR switch association operations +in the ND Manage API. + +Endpoints: +- EpManageTorAssociatePost - Associate access/ToR switches + (POST /api/v1/manage/fabrics/{fabricName}/accessAssociationActions/associate) +- EpManageTorDisassociatePost - Disassociate access/ToR switches + (POST /api/v1/manage/fabrics/{fabricName}/accessAssociationActions/disassociate) +- EpManageTorAssociationsGet - List access/ToR associations + (GET /api/v1/manage/fabrics/{fabricName}/accessAssociations) +""" + +from __future__ import absolute_import, annotations, division, print_function + +from typing import ClassVar, Literal, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.base_path import BasePath +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.mixins import FabricNameMixin +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import Field +from ansible_collections.cisco.nd.plugins.module_utils.types import IdentifierKey + + +class _EpManageTorBase(FabricNameMixin, NDEndpointBaseModel): + """ + Base class for ND Manage Access/ToR Association endpoints. + + All ToR association endpoints require a fabric_name path parameter. + """ + + _path_suffix: ClassVar[Optional[str]] = None + + def set_identifiers(self, identifier: IdentifierKey = None): + if isinstance(identifier, tuple) and len(identifier) >= 1: + self.fabric_name = identifier[0] + elif isinstance(identifier, str): + self.fabric_name = identifier + + def _build_path(self, *segments: str) -> str: + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + return BasePath.path("fabrics", self.fabric_name, *segments) + + +class EpManageTorAssociatePost(_EpManageTorBase): + """ + POST /api/v1/manage/fabrics/{fabricName}/accessAssociationActions/associate + + Associate access or ToR switches with aggregation/leaf switches or VPC pairs. + Request body is an array of accessPairWithResources objects. + """ + + class_name: Literal["EpManageTorAssociatePost"] = Field( + default="EpManageTorAssociatePost", + frozen=True, + description="Class name for backward compatibility", + ) + + @property + def path(self) -> str: + return self._build_path("accessAssociationActions", "associate") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +class EpManageTorDisassociatePost(_EpManageTorBase): + """ + POST /api/v1/manage/fabrics/{fabricName}/accessAssociationActions/disassociate + + Disassociate access or ToR switches from aggregation/leaf switches or VPC pairs. + Request body is an array of aggregationAccessSwitchIds objects. + """ + + class_name: Literal["EpManageTorDisassociatePost"] = Field( + default="EpManageTorDisassociatePost", + frozen=True, + description="Class name for backward compatibility", + ) + + @property + def path(self) -> str: + return self._build_path("accessAssociationActions", "disassociate") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.POST + + +class EpManageTorAssociationsGet(_EpManageTorBase): + """ + GET /api/v1/manage/fabrics/{fabricName}/accessAssociations + + List access or ToR switch associations for a fabric. + """ + + class_name: Literal["EpManageTorAssociationsGet"] = Field( + default="EpManageTorAssociationsGet", + frozen=True, + description="Class name for backward compatibility", + ) + + @property + def path(self) -> str: + return self._build_path("accessAssociations") + + @property + def verb(self) -> HttpVerbEnum: + return HttpVerbEnum.GET diff --git a/plugins/module_utils/models/manage_tor/manage_tor.py b/plugins/module_utils/models/manage_tor/manage_tor.py new file mode 100644 index 00000000..1d3a6b59 --- /dev/null +++ b/plugins/module_utils/models/manage_tor/manage_tor.py @@ -0,0 +1,135 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from typing import List, Dict, Any, Optional, ClassVar, Literal, Set + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + Field, + model_validator, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel + + +class ManageTorModel(NDBaseModel): + """ + Access/ToR switch association configuration for Nexus Dashboard. + + Identifier: composite (fabric_name, access_or_tor_switch_id, aggregation_or_leaf_switch_id) + + Serialization notes: + - fabric_name is excluded from API payload (path parameter only). + - Port channel and VPC ID fields are nested under "resources" in + payload mode but remain flat in config mode. + """ + + # --- Identifier Configuration --- + + identifiers: ClassVar[Optional[List[str]]] = [ + "fabric_name", + "access_or_tor_switch_id", + "aggregation_or_leaf_switch_id", + ] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "composite" + + # --- Serialization Configuration --- + + # fabric_name is a path parameter; hostname fields are read-only from API responses + payload_exclude_fields: ClassVar[Set[str]] = { + "fabric_name", + "access_or_tor_switch_name", + "access_or_tor_peer_switch_name", + } + exclude_from_diff: ClassVar[Set[str]] = { + "access_or_tor_switch_name", + "access_or_tor_peer_switch_name", + } + + # In payload mode, nest these fields under "resources" + payload_nested_fields: ClassVar[Dict[str, List[str]]] = { + "resources": [ + "access_or_tor_port_channel_id", + "aggregation_or_leaf_port_channel_id", + "access_or_tor_peer_port_channel_id", + "access_or_tor_vpc_id", + "aggregation_or_leaf_peer_port_channel_id", + "aggregation_or_leaf_vpc_id", + ], + } + + # --- Fields --- + + # Path parameter / scope + fabric_name: str = Field(alias="fabricName") + + # Required switch identifiers + access_or_tor_switch_id: str = Field(alias="accessOrTorSwitchId") + aggregation_or_leaf_switch_id: str = Field(alias="aggregationOrLeafSwitchId") + + # Optional VPC peer switch identifiers + access_or_tor_peer_switch_id: Optional[str] = Field(default=None, alias="accessOrTorPeerSwitchId") + aggregation_or_leaf_peer_switch_id: Optional[str] = Field(default=None, alias="aggregationOrLeafPeerSwitchId") + + # Read-only hostname fields (returned by API, never sent in payloads) + access_or_tor_switch_name: Optional[str] = Field(default=None, alias="accessOrTorSwitchName") + access_or_tor_peer_switch_name: Optional[str] = Field(default=None, alias="accessOrTorPeerSwitchName") + + # Resource fields (nested under "resources" in API payload) + access_or_tor_port_channel_id: Optional[int] = Field(default=None, alias="accessOrTorPortChannelId") + aggregation_or_leaf_port_channel_id: Optional[int] = Field(default=None, alias="aggregationOrLeafPortChannelId") + access_or_tor_peer_port_channel_id: Optional[int] = Field(default=None, alias="accessOrTorPeerPortChannelId") + access_or_tor_vpc_id: Optional[int] = Field(default=None, alias="accessOrTorVpcId") + aggregation_or_leaf_peer_port_channel_id: Optional[int] = Field(default=None, alias="aggregationOrLeafPeerPortChannelId") + aggregation_or_leaf_vpc_id: Optional[int] = Field(default=None, alias="aggregationOrLeafVpcId") + + # --- Validators (Deserialization) --- + + @model_validator(mode="before") + @classmethod + def flatten_resources(cls, data: Any) -> Any: + """ + Flatten nested resources from API response into top-level fields. + This is the inverse of the payload_nested_fields nesting. + """ + if not isinstance(data, dict): + return data + + resources = data.pop("resources", None) + if isinstance(resources, dict): + for key, val in resources.items(): + data.setdefault(key, val) + + return data + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> Dict: + return dict( + fabric_name=dict(type="str", required=True), + config=dict( + type="list", + elements="dict", + options=dict( + access_or_tor_switch_id=dict(type="str"), + aggregation_or_leaf_switch_id=dict(type="str", required=True), + access_or_tor_peer_switch_id=dict(type="str"), + aggregation_or_leaf_peer_switch_id=dict(type="str"), + access_or_tor_port_channel_id=dict(type="int"), + aggregation_or_leaf_port_channel_id=dict(type="int"), + access_or_tor_peer_port_channel_id=dict(type="int"), + access_or_tor_vpc_id=dict(type="int"), + aggregation_or_leaf_peer_port_channel_id=dict(type="int"), + aggregation_or_leaf_vpc_id=dict(type="int"), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "deleted", "gathered"], + ), + # gathered also requires config to provide aggregation_or_leaf_switch_id + # for the ND API query parameter (enforced via required_if in the module). + ) diff --git a/plugins/module_utils/nd.py b/plugins/module_utils/nd.py index f8f14e5d..8d66361d 100644 --- a/plugins/module_utils/nd.py +++ b/plugins/module_utils/nd.py @@ -276,8 +276,8 @@ def request( elif info.get("modified") == "true": self.result["changed"] = True - # 200: OK, 201: Created, 202: Accepted, 204: No Content - if self.status in (200, 201, 202, 204): + # 200: OK, 201: Created, 202: Accepted, 204: No Content, 207: Multi-Status + if self.status in (200, 201, 202, 204, 207): if output_format == "raw": return info.get("raw") return info.get("body") diff --git a/plugins/module_utils/orchestrators/manage_tor.py b/plugins/module_utils/orchestrators/manage_tor.py new file mode 100644 index 00000000..00223606 --- /dev/null +++ b/plugins/module_utils/orchestrators/manage_tor.py @@ -0,0 +1,145 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +from typing import Type, ClassVar, List +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.base import NDBaseOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_tor.manage_tor import ManageTorModel +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.base import NDEndpointBaseModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.types import ResponseType +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_tor import ( + EpManageTorAssociatePost, + EpManageTorDisassociatePost, + EpManageTorAssociationsGet, +) + + +class ManageTorOrchestrator(NDBaseOrchestrator[ManageTorModel]): + """ + Orchestrator for access/ToR switch associations. + + This API uses a non-standard pattern: + - Associate: POST array of switch pairs with resources + - Disassociate: POST array of switch pair IDs + - List: GET returns associations array + + There is no individual GET, PUT, or DELETE. All write operations + accept arrays and return 207 Multi-Status. + """ + + model_class: ClassVar[Type[NDBaseModel]] = ManageTorModel + + # Associate endpoint used for both create and update + create_endpoint: Type[NDEndpointBaseModel] = EpManageTorAssociatePost + update_endpoint: Type[NDEndpointBaseModel] = EpManageTorAssociatePost + # Disassociate endpoint used for delete + delete_endpoint: Type[NDEndpointBaseModel] = EpManageTorDisassociatePost + # List endpoint used for both query_one and query_all + query_one_endpoint: Type[NDEndpointBaseModel] = EpManageTorAssociationsGet + query_all_endpoint: Type[NDEndpointBaseModel] = EpManageTorAssociationsGet + + # Bulk operation support + supports_bulk_create: ClassVar[bool] = True + supports_bulk_delete: ClassVar[bool] = True + create_bulk_endpoint: Type[NDEndpointBaseModel] = EpManageTorAssociatePost + delete_bulk_endpoint: Type[NDEndpointBaseModel] = EpManageTorDisassociatePost + + def _get_fabric_name(self) -> str: + """Extract fabric_name from module parameters.""" + return self.sender.params.get("fabric_name", "") + + def create_bulk(self, model_instances: List[ManageTorModel], **kwargs) -> ResponseType: + """Associate multiple access/ToR switch pairs in a single API call.""" + try: + api_endpoint = self.create_bulk_endpoint() + api_endpoint.fabric_name = model_instances[0].fabric_name + data = [instance.to_payload() for instance in model_instances] + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=data) + except Exception as e: + raise Exception(f"Bulk associate failed: {e}") from e + + def update(self, model_instance: ManageTorModel, **kwargs) -> ResponseType: + """Re-associate an access/ToR switch pair (same as create for this API).""" + try: + api_endpoint = self.update_endpoint() + api_endpoint.fabric_name = model_instance.fabric_name + data = [model_instance.to_payload()] + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=data) + except Exception as e: + raise Exception(f"Update failed for {model_instance.get_identifier_value()}: {e}") from e + + def delete_bulk(self, model_instances: List[ManageTorModel], **kwargs) -> ResponseType: + """Disassociate multiple access/ToR switch pairs in a single API call.""" + try: + api_endpoint = self.delete_bulk_endpoint() + api_endpoint.fabric_name = model_instances[0].fabric_name + data = [] + for instance in model_instances: + disassociate_payload = { + "accessOrTorSwitchId": instance.access_or_tor_switch_id, + "aggregationOrLeafSwitchId": instance.aggregation_or_leaf_switch_id, + } + if instance.access_or_tor_peer_switch_id is not None: + disassociate_payload["accessOrTorPeerSwitchId"] = instance.access_or_tor_peer_switch_id + if instance.aggregation_or_leaf_peer_switch_id is not None: + disassociate_payload["aggregationOrLeafPeerSwitchId"] = instance.aggregation_or_leaf_peer_switch_id + data.append(disassociate_payload) + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=data) + except Exception as e: + raise Exception(f"Bulk disassociate failed: {e}") from e + + def query_all(self, model_instance=None, **kwargs) -> ResponseType: + """ + List all access/ToR associations for the fabric. + + The ND API requires aggregationOrLeafSwitchId as a query parameter + despite the spec marking it optional — omitting it returns HTTP 400. + In practice ND returns ALL associations for the fabric regardless of + which leaf ID is supplied, so a single request with the first leaf ID + from the module config is sufficient. + + fabric_name is injected into each returned association so the model + can be constructed properly. + """ + try: + fabric_name = self._get_fabric_name() + api_endpoint = self.query_all_endpoint() + api_endpoint.fabric_name = fabric_name + + # Pick the first leaf switch ID from config to satisfy the API's + # required-in-practice query parameter. + config = self.sender.params.get("config") or [] + leaf_switch_id = None + for item in config: + leaf_switch_id = item.get("aggregation_or_leaf_switch_id") or item.get("aggregation_or_leaf_peer_switch_id") + if leaf_switch_id: + break + + if not leaf_switch_id: + raise Exception( + "aggregation_or_leaf_switch_id is required in config to query ToR associations." + ) + + result = self.sender.request( + path=api_endpoint.path, + method="GET", + qs={"aggregationOrLeafSwitchId": leaf_switch_id}, + ) + associations = (result or {}).get("associations", []) or [] + + # The API returns all ToR switches for the leaf — both paired + # and unpaired candidates. Only associations marked as + # isRecommended=true are actually configured with this leaf. + # Entries with isRecommended=false may have populated resources + # from a pairing with a different leaf switch. + configured = [] + for assoc in associations: + if assoc.get("isRecommended") is True: + assoc["fabricName"] = fabric_name + configured.append(assoc) + return configured + except Exception as e: + raise Exception(f"Query all failed: {e}") from e diff --git a/plugins/modules/nd_manage_tor.py b/plugins/modules/nd_manage_tor.py new file mode 100644 index 00000000..dbe10eb5 --- /dev/null +++ b/plugins/modules/nd_manage_tor.py @@ -0,0 +1,216 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_manage_tor +version_added: "1.7.0" +short_description: Manage access or ToR switch associations on Cisco Nexus Dashboard +description: +- Manage access or ToR (Top of Rack) switch associations with aggregation or leaf switches on Cisco Nexus Dashboard (ND). +- It supports associating, disassociating, and querying ToR switch pairings within a fabric. +- Four association topologies are supported - single (1:1), aggregation VPC, back-to-back VPC, and bulk mixed. +author: +- Matt Tarkington (@mtarking) +options: + fabric_name: + description: + - The name of the fabric containing the switches. + type: str + required: true + config: + description: + - The list of access or ToR switch associations to configure. + - Required when O(state=merged) or O(state=deleted). + type: list + elements: dict + suboptions: + access_or_tor_switch_id: + description: + - The serial number of the access or ToR switch. + - Required when O(state=merged) or O(state=deleted). + - Optional when O(state=gathered); if omitted, all associations for the + specified O(config[].aggregation_or_leaf_switch_id) are returned. + type: str + aggregation_or_leaf_switch_id: + description: + - The serial number of the aggregation or leaf switch. + type: str + required: true + access_or_tor_peer_switch_id: + description: + - The serial number of the access or ToR VPC peer switch. + - Required for back-to-back VPC topologies. + type: str + aggregation_or_leaf_peer_switch_id: + description: + - The serial number of the aggregation or leaf VPC peer switch. + - Required for aggregation VPC and back-to-back VPC topologies. + type: str + access_or_tor_port_channel_id: + description: + - The port channel number on the access or ToR switch. + - Value must be between 1 and 4096. + - Required when O(state=merged). + type: int + aggregation_or_leaf_port_channel_id: + description: + - The port channel number on the aggregation or leaf switch. + - Value must be between 1 and 4096. + - Required when O(state=merged). + type: int + access_or_tor_peer_port_channel_id: + description: + - The port channel number on the access or ToR VPC peer switch. + - Value must be between 1 and 4096. + type: int + access_or_tor_vpc_id: + description: + - The VPC ID of the VPC pair of access or ToR switches. + - Value must be between 1 and 4096. + type: int + aggregation_or_leaf_peer_port_channel_id: + description: + - The port channel number on the aggregation or leaf VPC peer switch. + - Value must be between 1 and 4096. + type: int + aggregation_or_leaf_vpc_id: + description: + - The VPC ID of the VPC pair of aggregation or leaf switches. + - Value must be between 1 and 4096. + type: int + state: + description: + - The desired state of the access or ToR switch associations on the Cisco Nexus Dashboard. + - Use O(state=merged) to associate access or ToR switches with aggregation or leaf switches. + Existing associations not specified in the configuration will be left unchanged. + - Use O(state=deleted) to disassociate the access or ToR switches specified in the configuration. + - Use O(state=gathered) to retrieve current access or ToR switch associations from the fabric without making changes. + O(config) is required with at least one O(config[].aggregation_or_leaf_switch_id) to satisfy the ND API. + type: str + default: merged + choices: [ merged, deleted, gathered ] +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +notes: +- This module is only supported on Nexus Dashboard having version 4.2.1 or higher. +- The associate and disassociate API operations are bulk operations that return per-item status. +""" + +EXAMPLES = r""" +- name: Associate a ToR switch with a leaf switch + cisco.nd.nd_manage_tor: + fabric_name: my-fabric + config: + - access_or_tor_switch_id: "98AFDSD8V0" + aggregation_or_leaf_switch_id: "98AM4FFFFV0" + access_or_tor_port_channel_id: 501 + aggregation_or_leaf_port_channel_id: 502 + state: merged + +- name: Associate a ToR VPC pair with a leaf VPC pair (back-to-back VPC) + cisco.nd.nd_manage_tor: + fabric_name: my-fabric + config: + - access_or_tor_switch_id: "98AFDSD8V0" + aggregation_or_leaf_switch_id: "98AM4FFFFV0" + access_or_tor_peer_switch_id: "98AWSETG8V0" + aggregation_or_leaf_peer_switch_id: "98AMDDDD8V0" + access_or_tor_port_channel_id: 501 + aggregation_or_leaf_port_channel_id: 502 + access_or_tor_peer_port_channel_id: 503 + aggregation_or_leaf_peer_port_channel_id: 504 + access_or_tor_vpc_id: 1 + aggregation_or_leaf_vpc_id: 2 + state: merged + +- name: Disassociate a ToR switch + cisco.nd.nd_manage_tor: + fabric_name: my-fabric + config: + - access_or_tor_switch_id: "98AFDSD8V0" + aggregation_or_leaf_switch_id: "98AM4FFFFV0" + state: deleted + +- name: Gather all ToR associations for a fabric + cisco.nd.nd_manage_tor: + fabric_name: my-fabric + config: + - aggregation_or_leaf_switch_id: "98AM4FFFFV0" + state: gathered +""" + +RETURN = r""" +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec +from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine +from ansible_collections.cisco.nd.plugins.module_utils.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.common.pydantic_compat import require_pydantic +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_tor.manage_tor import ManageTorModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_tor import ManageTorOrchestrator +from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update(ManageTorModel.get_argument_spec()) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ["state", "merged", ["config"]], + ["state", "deleted", ["config"]], + ["state", "gathered", ["config"]], + ], + ) + require_pydantic(module) + + state = module.params["state"] + fabric_name = module.params["fabric_name"] + + # Inject fabric_name into each config item for model construction + config = module.params.get("config") or [] + for item in config: + item["fabric_name"] = fabric_name + if state in ("merged", "deleted") and not item.get("access_or_tor_switch_id"): + module.fail_json(msg="config[].access_or_tor_switch_id is required when state is '{0}'.".format(state)) + + try: + if state == "gathered": + # Handle gathered state: query and return without changes + nd_module = NDModule(module) + orchestrator = ManageTorOrchestrator(sender=nd_module) + response_data = orchestrator.query_all() + gathered = NDConfigCollection.from_api_response( + response_data=response_data, + model_class=ManageTorModel, + ) + output = NDOutput(output_level=module.params.get("output_level", "normal")) + output.assign(before=gathered, after=gathered) + module.exit_json(**output.format()) + else: + # Handle merged/deleted states via the state machine + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=ManageTorOrchestrator, + ) + nd_state_machine.manage_state() + module.exit_json(**nd_state_machine.output.format()) + + except Exception as e: + import traceback + module.fail_json(msg="Module execution failed: {0}".format(str(e)), exception=traceback.format_exc()) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/inventory.networking b/tests/integration/inventory.networking index 6b37d8f3..54275f65 100644 --- a/tests/integration/inventory.networking +++ b/tests/integration/inventory.networking @@ -1,31 +1,10 @@ [nd] -nd ansible_host= +nd1 ansible_host=10.15.0.146 [nd:vars] ansible_connection=ansible.netcommon.httpapi -ansible_python_interpreter=/usr/bin/python3.9 ansible_network_os=cisco.nd.nd -ansible_httpapi_validate_certs=False -ansible_httpapi_use_ssl=True -ansible_httpapi_use_proxy=True -ansible_user=ansible_github_ci -ansible_password= -insights_group= -site_name= -site_host= -site_username= -site_password= -dns_server= -app_network= -service_network= -ntp_server= -serial_number= -management_ip_address= -deployment_password= -management_ip= -management_gateway= -external_management_service_ip= -external_data_service_ip= -data_ip= -data_gateway= -service_package_host=173.36.219.254 +ansible_httpapi_use_ssl=true +ansible_httpapi_validate_certs=false +ansible_user=admin +ansible_password=cisco.123 diff --git a/tests/integration/targets/nd_manage_tor/tasks/main.yml b/tests/integration/targets/nd_manage_tor/tasks/main.yml new file mode 100644 index 00000000..7bf35d5c --- /dev/null +++ b/tests/integration/targets/nd_manage_tor/tasks/main.yml @@ -0,0 +1,269 @@ +# Test code for the nd_manage_tor module +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Test that we have a Nexus Dashboard host, username and password + ansible.builtin.fail: + msg: 'Please define the following variables: ansible_host, ansible_user and ansible_password.' + when: ansible_host is not defined or ansible_user is not defined or ansible_password is not defined + +- name: Set vars + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + test_fabric_name: '{{ test_tor_fabric_name | default("ansible_tor_test_fabric") }}' + test_tor_switch_id: '{{ test_tor_switch_serial | default("FDO123TOR01") }}' + test_leaf_switch_id: '{{ test_leaf_switch_serial | default("FDO456LEAF01") }}' + test_tor_peer_switch_id: '{{ test_tor_peer_serial | default("FDO789TOR02") }}' + test_leaf_peer_switch_id: '{{ test_leaf_peer_serial | default("FDO111LEAF02") }}' + +# ============================================================================= +# CLEANUP — Remove any existing test associations +# ============================================================================= + +- name: Ensure test ToR associations do not exist before test starts + cisco.nd.nd_manage_tor: &clean_tor_associations + <<: *nd_info + fabric_name: "{{ test_fabric_name }}" + config: + - access_or_tor_switch_id: "{{ test_tor_switch_id }}" + aggregation_or_leaf_switch_id: "{{ test_leaf_switch_id }}" + - access_or_tor_switch_id: "{{ test_tor_peer_switch_id }}" + aggregation_or_leaf_switch_id: "{{ test_leaf_switch_id }}" + state: deleted + ignore_errors: true + +# ============================================================================= +# GATHERED STATE TESTS +# ============================================================================= + +- name: Gather all ToR associations for the fabric (gathered state) + cisco.nd.nd_manage_tor: + <<: *nd_info + fabric_name: "{{ test_fabric_name }}" + config: + - aggregation_or_leaf_switch_id: "{{ test_leaf_switch_id }}" + state: gathered + register: gathered_tor_associations + +- name: Asserts for gathered state + ansible.builtin.assert: + that: + - gathered_tor_associations is not changed + - gathered_tor_associations.before is defined + - gathered_tor_associations.after is defined + - gathered_tor_associations.before == gathered_tor_associations.after + +# ============================================================================= +# MERGED STATE TESTS: CREATE +# ============================================================================= + +- name: Associate a ToR switch with a leaf switch (merged state - check mode) + cisco.nd.nd_manage_tor: &create_tor_association_merged + <<: *nd_info + fabric_name: "{{ test_fabric_name }}" + config: + - access_or_tor_switch_id: "{{ test_tor_switch_id }}" + aggregation_or_leaf_switch_id: "{{ test_leaf_switch_id }}" + access_or_tor_port_channel_id: 501 + aggregation_or_leaf_port_channel_id: 502 + state: merged + check_mode: true + register: cm_merged_create_tor + +- name: Associate a ToR switch with a leaf switch (merged state - normal mode) + cisco.nd.nd_manage_tor: + <<: *create_tor_association_merged + register: nm_merged_create_tor + +- name: Associate a ToR switch with a leaf switch (merged state - idempotency) + cisco.nd.nd_manage_tor: + <<: *create_tor_association_merged + register: nm_merged_create_tor_again + +- name: Asserts for ToR association merged state creation tasks + ansible.builtin.assert: + that: + # Check mode + - cm_merged_create_tor is changed + # Normal mode + - nm_merged_create_tor is changed + # Idempotency + - nm_merged_create_tor_again is not changed + +# ============================================================================= +# MERGED STATE TESTS: CREATE VPC back-to-back +# ============================================================================= + +- name: Associate ToR VPC pair with leaf VPC pair (merged state - check mode) + cisco.nd.nd_manage_tor: &create_vpc_tor_association_merged + <<: *nd_info + fabric_name: "{{ test_fabric_name }}" + config: + - access_or_tor_switch_id: "{{ test_tor_peer_switch_id }}" + aggregation_or_leaf_switch_id: "{{ test_leaf_switch_id }}" + access_or_tor_peer_switch_id: "{{ test_tor_switch_id }}" + aggregation_or_leaf_peer_switch_id: "{{ test_leaf_peer_switch_id }}" + access_or_tor_port_channel_id: 601 + aggregation_or_leaf_port_channel_id: 602 + access_or_tor_peer_port_channel_id: 603 + aggregation_or_leaf_peer_port_channel_id: 604 + access_or_tor_vpc_id: 10 + aggregation_or_leaf_vpc_id: 20 + state: merged + check_mode: true + register: cm_merged_create_vpc_tor + +- name: Associate ToR VPC pair with leaf VPC pair (merged state - normal mode) + cisco.nd.nd_manage_tor: + <<: *create_vpc_tor_association_merged + register: nm_merged_create_vpc_tor + +- name: Associate ToR VPC pair with leaf VPC pair (merged state - idempotency) + cisco.nd.nd_manage_tor: + <<: *create_vpc_tor_association_merged + register: nm_merged_create_vpc_tor_again + +- name: Asserts for VPC ToR association merged state creation tasks + ansible.builtin.assert: + that: + # Check mode + - cm_merged_create_vpc_tor is changed + # Normal mode + - nm_merged_create_vpc_tor is changed + # Idempotency + - nm_merged_create_vpc_tor_again is not changed + +# ============================================================================= +# GATHERED STATE TESTS: After associations created +# ============================================================================= + +- name: Gather ToR associations after creation (gathered state) + cisco.nd.nd_manage_tor: + <<: *nd_info + fabric_name: "{{ test_fabric_name }}" + config: + - aggregation_or_leaf_switch_id: "{{ test_leaf_switch_id }}" + state: gathered + register: gathered_after_create + +- name: Asserts for gathered state after creation + ansible.builtin.assert: + that: + - gathered_after_create is not changed + - gathered_after_create.before | length >= 2 + +# ============================================================================= +# MERGED STATE TESTS: UPDATE +# ============================================================================= + +- name: Update ToR association port channels (merged state - check mode) + cisco.nd.nd_manage_tor: &update_tor_association_merged + <<: *nd_info + fabric_name: "{{ test_fabric_name }}" + config: + - access_or_tor_switch_id: "{{ test_tor_switch_id }}" + aggregation_or_leaf_switch_id: "{{ test_leaf_switch_id }}" + access_or_tor_port_channel_id: 511 + aggregation_or_leaf_port_channel_id: 512 + state: merged + check_mode: true + register: cm_merged_update_tor + +- name: Update ToR association port channels (merged state - normal mode) + cisco.nd.nd_manage_tor: + <<: *update_tor_association_merged + register: nm_merged_update_tor + +- name: Update ToR association port channels (merged state - idempotency) + cisco.nd.nd_manage_tor: + <<: *update_tor_association_merged + register: nm_merged_update_tor_again + +- name: Asserts for ToR association merged state update tasks + ansible.builtin.assert: + that: + # Check mode + - cm_merged_update_tor is changed + # Normal mode + - nm_merged_update_tor is changed + # Idempotency + - nm_merged_update_tor_again is not changed + +# ============================================================================= +# DELETED STATE TESTS +# ============================================================================= + +- name: Disassociate the single ToR switch (deleted state - check mode) + cisco.nd.nd_manage_tor: &delete_single_tor + <<: *nd_info + fabric_name: "{{ test_fabric_name }}" + config: + - access_or_tor_switch_id: "{{ test_tor_switch_id }}" + aggregation_or_leaf_switch_id: "{{ test_leaf_switch_id }}" + state: deleted + check_mode: true + register: cm_deleted_single_tor + +- name: Disassociate the single ToR switch (deleted state - normal mode) + cisco.nd.nd_manage_tor: + <<: *delete_single_tor + register: nm_deleted_single_tor + +- name: Disassociate the single ToR switch (deleted state - idempotency) + cisco.nd.nd_manage_tor: + <<: *delete_single_tor + register: nm_deleted_single_tor_again + +- name: Asserts for single ToR disassociation + ansible.builtin.assert: + that: + # Check mode + - cm_deleted_single_tor is changed + # Normal mode + - nm_deleted_single_tor is changed + # Idempotency — already deleted, should not change + - nm_deleted_single_tor_again is not changed + +- name: Disassociate the VPC ToR pair (deleted state - normal mode) + cisco.nd.nd_manage_tor: + <<: *nd_info + fabric_name: "{{ test_fabric_name }}" + config: + - access_or_tor_switch_id: "{{ test_tor_peer_switch_id }}" + aggregation_or_leaf_switch_id: "{{ test_leaf_switch_id }}" + state: deleted + register: nm_deleted_vpc_tor + +- name: Asserts for VPC ToR disassociation + ansible.builtin.assert: + that: + - nm_deleted_vpc_tor is changed + +# ============================================================================= +# GATHERED STATE TESTS: After all deletions +# ============================================================================= + +- name: Gather ToR associations after all deletions (gathered state) + cisco.nd.nd_manage_tor: + <<: *nd_info + fabric_name: "{{ test_fabric_name }}" + config: + - aggregation_or_leaf_switch_id: "{{ test_leaf_switch_id }}" + state: gathered + register: gathered_after_delete + +- name: Asserts for gathered state after all deletions + ansible.builtin.assert: + that: + - gathered_after_delete is not changed + +# ============================================================================= +# FINAL CLEANUP +# ============================================================================= + +- name: Final cleanup — remove any remaining test associations + cisco.nd.nd_manage_tor: + <<: *clean_tor_associations + ignore_errors: true diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tor.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tor.py new file mode 100644 index 00000000..8bb004ab --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_tor.py @@ -0,0 +1,258 @@ +# Copyright: (c) 2026, Matt Tarkington (@mtarking) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for manage_tor.py + +Tests the ND Manage Access/ToR Association endpoint classes. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.endpoints.v1.manage.manage_tor import ( + EpManageTorAssociatePost, + EpManageTorDisassociatePost, + EpManageTorAssociationsGet, +) +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: EpManageTorAssociatePost +# ============================================================================= + + +def test_endpoints_api_v1_manage_tor_00010(): + """ + # Summary + + Verify EpManageTorAssociatePost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + """ + with does_not_raise(): + instance = EpManageTorAssociatePost() + assert instance.class_name == "EpManageTorAssociatePost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_tor_00020(): + """ + # Summary + + Verify EpManageTorAssociatePost path with fabric_name + + ## Test + + - path returns correct associate endpoint when fabric_name is set + """ + with does_not_raise(): + instance = EpManageTorAssociatePost() + instance.fabric_name = "my-fabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/accessAssociationActions/associate" + + +def test_endpoints_api_v1_manage_tor_00030(): + """ + # Summary + + Verify EpManageTorAssociatePost path without fabric_name raises ValueError + + ## Test + + - Accessing path without setting fabric_name raises ValueError + """ + with pytest.raises(ValueError): + instance = EpManageTorAssociatePost() + result = instance.path # noqa: F841 + + +def test_endpoints_api_v1_manage_tor_00040(): + """ + # Summary + + Verify EpManageTorAssociatePost set_identifiers with composite tuple + + ## Test + + - set_identifiers extracts fabric_name from first element of composite tuple + """ + with does_not_raise(): + instance = EpManageTorAssociatePost() + instance.set_identifiers(("test-fabric", "SERIAL1", "SERIAL2")) + result = instance.path + assert instance.fabric_name == "test-fabric" + assert result == "/api/v1/manage/fabrics/test-fabric/accessAssociationActions/associate" + + +def test_endpoints_api_v1_manage_tor_00050(): + """ + # Summary + + Verify EpManageTorAssociatePost set_identifiers with string + + ## Test + + - set_identifiers accepts a plain string as fabric_name + """ + with does_not_raise(): + instance = EpManageTorAssociatePost() + instance.set_identifiers("simple-fabric") + result = instance.path + assert instance.fabric_name == "simple-fabric" + assert result == "/api/v1/manage/fabrics/simple-fabric/accessAssociationActions/associate" + + +# ============================================================================= +# Test: EpManageTorDisassociatePost +# ============================================================================= + + +def test_endpoints_api_v1_manage_tor_00100(): + """ + # Summary + + Verify EpManageTorDisassociatePost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + """ + with does_not_raise(): + instance = EpManageTorDisassociatePost() + assert instance.class_name == "EpManageTorDisassociatePost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_api_v1_manage_tor_00110(): + """ + # Summary + + Verify EpManageTorDisassociatePost path with fabric_name + + ## Test + + - path returns correct disassociate endpoint when fabric_name is set + """ + with does_not_raise(): + instance = EpManageTorDisassociatePost() + instance.fabric_name = "my-fabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/accessAssociationActions/disassociate" + + +def test_endpoints_api_v1_manage_tor_00120(): + """ + # Summary + + Verify EpManageTorDisassociatePost path without fabric_name raises ValueError + + ## Test + + - Accessing path without setting fabric_name raises ValueError + """ + with pytest.raises(ValueError): + instance = EpManageTorDisassociatePost() + result = instance.path # noqa: F841 + + +def test_endpoints_api_v1_manage_tor_00130(): + """ + # Summary + + Verify EpManageTorDisassociatePost set_identifiers with composite tuple + + ## Test + + - set_identifiers extracts fabric_name from first element of composite tuple + """ + with does_not_raise(): + instance = EpManageTorDisassociatePost() + instance.set_identifiers(("prod-fabric", "SN1", "SN2")) + assert instance.fabric_name == "prod-fabric" + assert instance.path == "/api/v1/manage/fabrics/prod-fabric/accessAssociationActions/disassociate" + + +# ============================================================================= +# Test: EpManageTorAssociationsGet +# ============================================================================= + + +def test_endpoints_api_v1_manage_tor_00200(): + """ + # Summary + + Verify EpManageTorAssociationsGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + """ + with does_not_raise(): + instance = EpManageTorAssociationsGet() + assert instance.class_name == "EpManageTorAssociationsGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_api_v1_manage_tor_00210(): + """ + # Summary + + Verify EpManageTorAssociationsGet path with fabric_name + + ## Test + + - path returns correct associations endpoint when fabric_name is set + """ + with does_not_raise(): + instance = EpManageTorAssociationsGet() + instance.fabric_name = "my-fabric" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-fabric/accessAssociations" + + +def test_endpoints_api_v1_manage_tor_00220(): + """ + # Summary + + Verify EpManageTorAssociationsGet path without fabric_name raises ValueError + + ## Test + + - Accessing path without setting fabric_name raises ValueError + """ + with pytest.raises(ValueError): + instance = EpManageTorAssociationsGet() + result = instance.path # noqa: F841 + + +def test_endpoints_api_v1_manage_tor_00230(): + """ + # Summary + + Verify EpManageTorAssociationsGet set_identifiers with composite tuple + + ## Test + + - set_identifiers extracts fabric_name from first element of composite tuple + """ + with does_not_raise(): + instance = EpManageTorAssociationsGet() + instance.set_identifiers(("query-fabric", "SN1", "SN2")) + assert instance.fabric_name == "query-fabric" + assert instance.path == "/api/v1/manage/fabrics/query-fabric/accessAssociations"