From 4562d0dd5c71bbaf03674ec97f8223840e940fb9 Mon Sep 17 00:00:00 2001 From: Matt Tarkington Date: Thu, 9 Apr 2026 19:56:43 -0400 Subject: [PATCH 1/3] initial fabric group members module --- .../v1/manage/manage_fabric_group_members.py | 227 +++++++++++++++ .../manage_fabric_group_members.py | 67 +++++ .../manage_fabric_group_members.py | 95 ++++++ .../modules/nd_manage_fabric_group_members.py | 136 +++++++++ .../tasks/main.yml | 154 ++++++++++ ...ints_api_v1_manage_fabric_group_members.py | 273 ++++++++++++++++++ 6 files changed, 952 insertions(+) create mode 100644 plugins/module_utils/endpoints/v1/manage/manage_fabric_group_members.py create mode 100644 plugins/module_utils/models/manage_fabric_group/manage_fabric_group_members.py create mode 100644 plugins/module_utils/orchestrators/manage_fabric_group_members.py create mode 100644 plugins/modules/nd_manage_fabric_group_members.py create mode 100644 tests/integration/targets/nd_manage_fabric_group_members/tasks/main.yml create mode 100644 tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabric_group_members.py diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabric_group_members.py b/plugins/module_utils/endpoints/v1/manage/manage_fabric_group_members.py new file mode 100644 index 00000000..4ac06a49 --- /dev/null +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabric_group_members.py @@ -0,0 +1,227 @@ +# 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 Fabric Group Members endpoint models. + +This module contains endpoint definitions for fabric group member operations +in the ND Manage API. + +## Endpoints + +- `EpManageFabricGroupMembersGet` - List members of a fabric group + (GET /api/v1/manage/fabrics/{fabric_name}/members) +- `EpManageFabricGroupMembersAddPost` - Add members to a fabric group + (POST /api/v1/manage/fabrics/{fabric_name}/actions/addMembers) +- `EpManageFabricGroupMembersRemovePost` - Remove members from a fabric group + (POST /api/v1/manage/fabrics/{fabric_name}/actions/removeMembers) +""" + +from __future__ import annotations + +__metaclass__ = type + +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.endpoints.query_params import EndpointQueryParams +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 _EpManageFabricGroupMembersBase(FabricNameMixin, NDEndpointBaseModel): + """ + Base class for ND Manage Fabric Group Members endpoints. + + Provides common functionality for all HTTP methods on the + /api/v1/manage/fabrics/{fabric_name}/members and + /api/v1/manage/fabrics/{fabric_name}/actions/addMembers|removeMembers endpoints. + + Subclasses override ``_path_suffix`` to build the correct path. + """ + + _path_suffix: ClassVar[str] = "members" + + endpoint_params: EndpointQueryParams = Field(default_factory=EndpointQueryParams, description="Endpoint-specific query parameters") + + def set_identifiers(self, identifier: IdentifierKey = None): + self.fabric_name = identifier + + @property + def path(self) -> str: + """ + Build the endpoint path including fabric name and path suffix. + + Raises ValueError if fabric_name is not set. + """ + if self.fabric_name is None: + raise ValueError(f"{type(self).__name__}.path: fabric_name must be set before accessing path.") + base_path = BasePath.path("fabrics", self.fabric_name, self._path_suffix) + query_string = self.endpoint_params.to_query_string() + if query_string: + return f"{base_path}?{query_string}" + return base_path + + +class EpManageFabricGroupMembersGet(_EpManageFabricGroupMembersBase): + """ + # Summary + + ND Manage Fabric Group Members GET Endpoint + + ## Description + + Endpoint to retrieve members of a fabric group from the ND Manage service. + The fabric name (group name) is a required path parameter. + + ## Path + + - /api/v1/manage/fabrics/{fabric_name}/members + + ## Verb + + - GET + + ## Raises + + - `ValueError` if `fabric_name` is not set when accessing `path` + + ## Usage + + ```python + request = EpManageFabricGroupMembersGet() + request.fabric_name = "my-fabric-group" + path = request.path + verb = request.verb + # Path: /api/v1/manage/fabrics/my-fabric-group/members + ``` + """ + + _path_suffix: ClassVar[str] = "members" + + class_name: Literal["EpManageFabricGroupMembersGet"] = Field( + default="EpManageFabricGroupMembersGet", + description="Class name for backward compatibility", + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.GET + + +class EpManageFabricGroupMembersAddPost(_EpManageFabricGroupMembersBase): + """ + # Summary + + ND Manage Fabric Group Members Add POST Endpoint + + ## Description + + Endpoint to add members to a fabric group via the ND Manage service. + The fabric name (group name) is a required path parameter. + + ## Path + + - /api/v1/manage/fabrics/{fabric_name}/actions/addMembers + + ## Verb + + - POST + + ## Request Body (application/json) + + ```json + { + "members": [ + { "name": "member-fabric-name" } + ] + } + ``` + + ## Raises + + - `ValueError` if `fabric_name` is not set when accessing `path` + + ## Usage + + ```python + request = EpManageFabricGroupMembersAddPost() + request.fabric_name = "my-fabric-group" + path = request.path + verb = request.verb + # Path: /api/v1/manage/fabrics/my-fabric-group/actions/addMembers + ``` + """ + + _path_suffix: ClassVar[str] = "actions/addMembers" + + class_name: Literal["EpManageFabricGroupMembersAddPost"] = Field( + default="EpManageFabricGroupMembersAddPost", + description="Class name for backward compatibility", + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST + + +class EpManageFabricGroupMembersRemovePost(_EpManageFabricGroupMembersBase): + """ + # Summary + + ND Manage Fabric Group Members Remove POST Endpoint + + ## Description + + Endpoint to remove members from a fabric group via the ND Manage service. + The fabric name (group name) is a required path parameter. + + ## Path + + - /api/v1/manage/fabrics/{fabric_name}/actions/removeMembers + + ## Verb + + - POST + + ## Request Body (application/json) + + ```json + { + "members": [ + { "name": "member-fabric-name" } + ] + } + ``` + + ## Raises + + - `ValueError` if `fabric_name` is not set when accessing `path` + + ## Usage + + ```python + request = EpManageFabricGroupMembersRemovePost() + request.fabric_name = "my-fabric-group" + path = request.path + verb = request.verb + # Path: /api/v1/manage/fabrics/my-fabric-group/actions/removeMembers + ``` + """ + + _path_suffix: ClassVar[str] = "actions/removeMembers" + + class_name: Literal["EpManageFabricGroupMembersRemovePost"] = Field( + default="EpManageFabricGroupMembersRemovePost", + description="Class name for backward compatibility", + ) + + @property + def verb(self) -> HttpVerbEnum: + """Return the HTTP verb for this endpoint.""" + return HttpVerbEnum.POST diff --git a/plugins/module_utils/models/manage_fabric_group/manage_fabric_group_members.py b/plugins/module_utils/models/manage_fabric_group/manage_fabric_group_members.py new file mode 100644 index 00000000..586d267d --- /dev/null +++ b/plugins/module_utils/models/manage_fabric_group/manage_fabric_group_members.py @@ -0,0 +1,67 @@ +# 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, + ConfigDict, +) +from ansible_collections.cisco.nd.plugins.module_utils.models.base import NDBaseModel + + +class FabricGroupMemberModel(NDBaseModel): + """ + Fabric group member configuration for Nexus Dashboard. + + Represents a single member fabric within a fabric group. + + Identifier: name (single) + + API details: + - Members are added via POST /fabrics/{fabricName}/actions/addMembers + - Members are removed via POST /fabrics/{fabricName}/actions/removeMembers + - Members are queried via GET /fabrics/{fabricName}/members + - The parent fabric group name is a module-level parameter, not part of + the member model itself. + """ + + model_config = ConfigDict(populate_by_name=True) + + # --- Identifier Configuration --- + + identifiers: ClassVar[Optional[List[str]]] = ["name"] + identifier_strategy: ClassVar[Optional[Literal["single", "composite", "hierarchical", "singleton"]]] = "single" + + # --- Serialization Configuration --- + + exclude_from_diff: ClassVar[Set[str]] = set() + payload_exclude_fields: ClassVar[Set[str]] = {"fabric_type"} + + # --- Fields --- + + name: str = Field(alias="name") + fabric_type: Optional[str] = Field(default=None, alias="type") + + # --- Argument Spec --- + + @classmethod + def get_argument_spec(cls) -> Dict[str, Any]: + return dict( + fabric_name=dict(type="str", required=True), + config=dict( + type="list", + elements="dict", + required=True, + options=dict( + name=dict(type="str", required=True), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "deleted", "gathered"], + ), + ) diff --git a/plugins/module_utils/orchestrators/manage_fabric_group_members.py b/plugins/module_utils/orchestrators/manage_fabric_group_members.py new file mode 100644 index 00000000..ab31e0a6 --- /dev/null +++ b/plugins/module_utils/orchestrators/manage_fabric_group_members.py @@ -0,0 +1,95 @@ +# 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 +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_fabric_group.manage_fabric_group_members import FabricGroupMemberModel +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_fabric_group_members import ( + EpManageFabricGroupMembersGet, + EpManageFabricGroupMembersAddPost, + EpManageFabricGroupMembersRemovePost, +) + + +class ManageFabricGroupMembersOrchestrator(NDBaseOrchestrator[FabricGroupMemberModel]): + model_class: ClassVar[Type[NDBaseModel]] = FabricGroupMemberModel + + create_endpoint: Type[NDEndpointBaseModel] = EpManageFabricGroupMembersAddPost + update_endpoint: Type[NDEndpointBaseModel] = EpManageFabricGroupMembersAddPost + delete_endpoint: Type[NDEndpointBaseModel] = EpManageFabricGroupMembersRemovePost + query_one_endpoint: Type[NDEndpointBaseModel] = EpManageFabricGroupMembersGet + query_all_endpoint: Type[NDEndpointBaseModel] = EpManageFabricGroupMembersGet + + def _get_fabric_group_name(self) -> str: + """Extract fabric_name from module params.""" + return self.sender.params.get("fabric_name") + + def create(self, model_instance: FabricGroupMemberModel, **kwargs) -> ResponseType: + """ + Add a member to the fabric group. + + Wraps the member name in the required API payload format: + {"members": [{"name": "..."}]} + """ + try: + api_endpoint = self.create_endpoint() + api_endpoint.fabric_name = self._get_fabric_group_name() + payload = {"members": [{"name": model_instance.name}]} + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) + except Exception as e: + raise Exception(f"Add member failed for {model_instance.name}: {e}") from e + + def update(self, model_instance: FabricGroupMemberModel, **kwargs) -> ResponseType: + """ + Update is not applicable for fabric group members. + Members are either present or absent — redirect to create (add). + """ + return self.create(model_instance, **kwargs) + + def delete(self, model_instance: FabricGroupMemberModel, **kwargs) -> ResponseType: + """ + Remove a member from the fabric group. + + Wraps the member name in the required API payload format: + {"members": [{"name": "..."}]} + """ + try: + api_endpoint = self.delete_endpoint() + api_endpoint.fabric_name = self._get_fabric_group_name() + payload = {"members": [{"name": model_instance.name}]} + return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) + except Exception as e: + raise Exception(f"Remove member failed for {model_instance.name}: {e}") from e + + def query_one(self, model_instance: FabricGroupMemberModel, **kwargs) -> ResponseType: + """ + Query a specific member of the fabric group by checking the full members list. + """ + try: + all_members = self.query_all() + for member in all_members: + if member.get("name") == model_instance.name: + return member + return None + except Exception as e: + raise Exception(f"Query member failed for {model_instance.name}: {e}") from e + + def query_all(self, model_instance=None, **kwargs) -> ResponseType: + """ + Query all members of the fabric group. + + Extracts 'fabrics' from the API response. + """ + try: + api_endpoint = self.query_all_endpoint() + api_endpoint.fabric_name = self._get_fabric_group_name() + result = self.sender.query_obj(api_endpoint.path) + return result.get("fabrics", []) or [] + except Exception as e: + raise Exception(f"Query all members failed: {e}") from e diff --git a/plugins/modules/nd_manage_fabric_group_members.py b/plugins/modules/nd_manage_fabric_group_members.py new file mode 100644 index 00000000..c33f3287 --- /dev/null +++ b/plugins/modules/nd_manage_fabric_group_members.py @@ -0,0 +1,136 @@ +# 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_fabric_group_members +version_added: "1.6.0" +short_description: Manage fabric group members on Cisco Nexus Dashboard +description: +- Manage fabric group members on Cisco Nexus Dashboard (ND). +- Add or remove member fabrics from a fabric group. +- This module does not create or delete fabric groups themselves, only manages the membership. +author: +- Matt Tarkington (@mtarking) +options: + fabric_name: + description: + - The name of the fabric group to manage members for. + - This is the parent fabric group, not the member fabric name. + type: str + required: true + config: + description: + - The list of member fabrics to manage within the fabric group. + type: list + elements: dict + required: true + suboptions: + name: + description: + - The name of the member fabric to add or remove from the fabric group. + type: str + required: true + state: + description: + - The desired state of the fabric group members on the Cisco Nexus Dashboard. + - Use O(state=merged) to add member fabrics to the fabric group. + Members already in the group will be left unchanged. + - Use O(state=deleted) to remove the specified member fabrics from the fabric group. + - Use O(state=gathered) to retrieve the current members of the fabric group without making changes. + 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. +- Members are identified solely by their fabric name. +- The O(fabric_name) must refer to an existing fabric group. +""" + +EXAMPLES = r""" +- name: Add members to a fabric group + cisco.nd.nd_manage_fabric_group_members: + fabric_name: my-fabric-group + config: + - name: member-fabric-1 + - name: member-fabric-2 + state: merged + register: result + +- name: Remove members from a fabric group + cisco.nd.nd_manage_fabric_group_members: + fabric_name: my-fabric-group + config: + - name: member-fabric-1 + state: deleted + register: result + +- name: Gather current members of a fabric group + cisco.nd.nd_manage_fabric_group_members: + fabric_name: my-fabric-group + config: [] + state: gathered + register: result +""" + +RETURN = r""" +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.nd import nd_argument_spec +from ansible_collections.cisco.nd.plugins.module_utils.nd_state_machine import NDStateMachine +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic +from ansible_collections.cisco.nd.plugins.module_utils.models.manage_fabric_group.manage_fabric_group_members import FabricGroupMemberModel +from ansible_collections.cisco.nd.plugins.module_utils.orchestrators.manage_fabric_group_members import ManageFabricGroupMembersOrchestrator + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update(FabricGroupMemberModel.get_argument_spec()) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + require_pydantic(module) + + try: + if module.params["state"] == "gathered": + from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule + from ansible_collections.cisco.nd.plugins.module_utils.nd_output import NDOutput + + nd_module = NDModule(module) + orchestrator = ManageFabricGroupMembersOrchestrator(sender=nd_module) + output = NDOutput(output_level=module.params.get("output_level", "normal")) + + members_data = orchestrator.query_all() + gathered = [] + for member in members_data: + model = FabricGroupMemberModel.from_response(member) + gathered.append(model.to_config()) + + module.exit_json(changed=False, gathered=gathered) + else: + nd_state_machine = NDStateMachine( + module=module, + model_orchestrator=ManageFabricGroupMembersOrchestrator, + ) + + nd_state_machine.manage_state() + + module.exit_json(**nd_state_machine.output.format()) + + except Exception as e: + module.fail_json(msg=f"Module execution failed: {str(e)}") + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_manage_fabric_group_members/tasks/main.yml b/tests/integration/targets/nd_manage_fabric_group_members/tasks/main.yml new file mode 100644 index 00000000..76dd425f --- /dev/null +++ b/tests/integration/targets/nd_manage_fabric_group_members/tasks/main.yml @@ -0,0 +1,154 @@ +--- +# Test code for the ND modules +# 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_group: "ansible_test_fabric_group" + test_member_1: "ansible_member_fabric_1" + test_member_2: "ansible_member_fabric_2" + test_member_3: "ansible_member_fabric_3" + +############################################################################# +# CLEANUP - Ensure clean state before tests +############################################################################# +- name: Remove all test members from fabric group before test starts + cisco.nd.nd_manage_fabric_group_members: &clean_all_members + <<: *nd_info + fabric_name: "{{ test_fabric_group }}" + config: + - name: "{{ test_member_1 }}" + - name: "{{ test_member_2 }}" + - name: "{{ test_member_3 }}" + state: deleted + +############################################################################# +# GATHERED STATE TESTS +############################################################################# +- name: "GATHERED: Retrieve current members of the fabric group" + cisco.nd.nd_manage_fabric_group_members: + <<: *nd_info + fabric_name: "{{ test_fabric_group }}" + config: [] + state: gathered + register: gathered_result + +- name: "GATHERED: Verify gathered state returns members without changes" + ansible.builtin.assert: + that: + - gathered_result is not changed + - gathered_result.gathered is defined + +############################################################################# +# MERGED STATE TESTS - ADD MEMBERS +############################################################################# +- name: "MERGED CREATE: Add members to fabric group (check mode)" + cisco.nd.nd_manage_fabric_group_members: &add_members_merged + <<: *nd_info + fabric_name: "{{ test_fabric_group }}" + config: + - name: "{{ test_member_1 }}" + - name: "{{ test_member_2 }}" + state: merged + check_mode: true + register: cm_merged_add_members + +- name: "MERGED CREATE: Add members to fabric group (normal mode)" + cisco.nd.nd_manage_fabric_group_members: + <<: *add_members_merged + register: nm_merged_add_members + +- name: "MERGED CREATE: Verify members were added" + ansible.builtin.assert: + that: + - cm_merged_add_members is changed + - nm_merged_add_members is changed + +- name: "MERGED IDEMPOTENT: Add same members again (idempotency test)" + cisco.nd.nd_manage_fabric_group_members: + <<: *add_members_merged + register: nm_merged_add_members_again + +- name: "MERGED IDEMPOTENT: Verify no changes on rerun" + ansible.builtin.assert: + that: + - nm_merged_add_members_again is not changed + +- name: "MERGED ADD: Add a third member to fabric group" + cisco.nd.nd_manage_fabric_group_members: + <<: *nd_info + fabric_name: "{{ test_fabric_group }}" + config: + - name: "{{ test_member_3 }}" + state: merged + register: nm_merged_add_third_member + +- name: "MERGED ADD: Verify third member was added" + ansible.builtin.assert: + that: + - nm_merged_add_third_member is changed + +############################################################################# +# DELETED STATE TESTS - REMOVE MEMBERS +############################################################################# +- name: "DELETED: Remove one member from fabric group (check mode)" + cisco.nd.nd_manage_fabric_group_members: &delete_member + <<: *nd_info + fabric_name: "{{ test_fabric_group }}" + config: + - name: "{{ test_member_3 }}" + state: deleted + check_mode: true + register: cm_deleted_remove_member + +- name: "DELETED: Remove one member from fabric group (normal mode)" + cisco.nd.nd_manage_fabric_group_members: + <<: *delete_member + register: nm_deleted_remove_member + +- name: "DELETED: Verify member was removed" + ansible.builtin.assert: + that: + - cm_deleted_remove_member is changed + - nm_deleted_remove_member is changed + +- name: "DELETED IDEMPOTENT: Remove same member again (idempotency)" + cisco.nd.nd_manage_fabric_group_members: + <<: *delete_member + register: nm_deleted_remove_member_again + +- name: "DELETED IDEMPOTENT: Verify no changes on rerun" + ansible.builtin.assert: + that: + - nm_deleted_remove_member_again is not changed + +- name: "DELETED: Remove remaining test members" + cisco.nd.nd_manage_fabric_group_members: + <<: *nd_info + fabric_name: "{{ test_fabric_group }}" + config: + - name: "{{ test_member_1 }}" + - name: "{{ test_member_2 }}" + state: deleted + register: nm_deleted_remove_remaining + +- name: "DELETED: Verify remaining members were removed" + ansible.builtin.assert: + that: + - nm_deleted_remove_remaining is changed + +############################################################################# +# CLEANUP +############################################################################# +- name: Final cleanup - remove all test members + cisco.nd.nd_manage_fabric_group_members: + <<: *clean_all_members diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabric_group_members.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabric_group_members.py new file mode 100644 index 00000000..459941f1 --- /dev/null +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabric_group_members.py @@ -0,0 +1,273 @@ +# 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_fabric_group_members.py + +Tests the ND Manage Fabric Group Members 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_fabric_group_members import ( + EpManageFabricGroupMembersGet, + EpManageFabricGroupMembersAddPost, + EpManageFabricGroupMembersRemovePost, +) +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: EpManageFabricGroupMembersGet +# ============================================================================= + + +def test_endpoints_manage_fabric_group_members_00010(): + """ + # Summary + + Verify EpManageFabricGroupMembersGet basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is GET + """ + with does_not_raise(): + instance = EpManageFabricGroupMembersGet() + assert instance.class_name == "EpManageFabricGroupMembersGet" + assert instance.verb == HttpVerbEnum.GET + + +def test_endpoints_manage_fabric_group_members_00020(): + """ + # Summary + + Verify EpManageFabricGroupMembersGet path with fabric_name + + ## Test + + - path returns "/api/v1/manage/fabrics/my-group/members" when fabric_name is set + """ + with does_not_raise(): + instance = EpManageFabricGroupMembersGet() + instance.fabric_name = "my-group" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-group/members" + + +def test_endpoints_manage_fabric_group_members_00030(): + """ + # Summary + + Verify EpManageFabricGroupMembersGet path without fabric_name raises ValueError + + ## Test + + - Accessing path without setting fabric_name raises ValueError + """ + with pytest.raises(ValueError): + instance = EpManageFabricGroupMembersGet() + _ = instance.path + + +def test_endpoints_manage_fabric_group_members_00040(): + """ + # Summary + + Verify EpManageFabricGroupMembersGet set_identifiers + + ## Test + + - set_identifiers sets fabric_name correctly + """ + with does_not_raise(): + instance = EpManageFabricGroupMembersGet() + instance.set_identifiers("my-group") + result = instance.path + assert result == "/api/v1/manage/fabrics/my-group/members" + assert instance.fabric_name == "my-group" + + +# ============================================================================= +# Test: EpManageFabricGroupMembersAddPost +# ============================================================================= + + +def test_endpoints_manage_fabric_group_members_00100(): + """ + # Summary + + Verify EpManageFabricGroupMembersAddPost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + """ + with does_not_raise(): + instance = EpManageFabricGroupMembersAddPost() + assert instance.class_name == "EpManageFabricGroupMembersAddPost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_manage_fabric_group_members_00110(): + """ + # Summary + + Verify EpManageFabricGroupMembersAddPost path with fabric_name + + ## Test + + - path returns "/api/v1/manage/fabrics/my-group/actions/addMembers" + """ + with does_not_raise(): + instance = EpManageFabricGroupMembersAddPost() + instance.fabric_name = "my-group" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-group/actions/addMembers" + + +def test_endpoints_manage_fabric_group_members_00120(): + """ + # Summary + + Verify EpManageFabricGroupMembersAddPost path without fabric_name raises ValueError + + ## Test + + - Accessing path without setting fabric_name raises ValueError + """ + with pytest.raises(ValueError): + instance = EpManageFabricGroupMembersAddPost() + _ = instance.path + + +def test_endpoints_manage_fabric_group_members_00130(): + """ + # Summary + + Verify EpManageFabricGroupMembersAddPost set_identifiers + + ## Test + + - set_identifiers sets fabric_name correctly + """ + with does_not_raise(): + instance = EpManageFabricGroupMembersAddPost() + instance.set_identifiers("my-group") + result = instance.path + assert result == "/api/v1/manage/fabrics/my-group/actions/addMembers" + assert instance.fabric_name == "my-group" + + +# ============================================================================= +# Test: EpManageFabricGroupMembersRemovePost +# ============================================================================= + + +def test_endpoints_manage_fabric_group_members_00200(): + """ + # Summary + + Verify EpManageFabricGroupMembersRemovePost basic instantiation + + ## Test + + - Instance can be created + - class_name is set correctly + - verb is POST + """ + with does_not_raise(): + instance = EpManageFabricGroupMembersRemovePost() + assert instance.class_name == "EpManageFabricGroupMembersRemovePost" + assert instance.verb == HttpVerbEnum.POST + + +def test_endpoints_manage_fabric_group_members_00210(): + """ + # Summary + + Verify EpManageFabricGroupMembersRemovePost path with fabric_name + + ## Test + + - path returns "/api/v1/manage/fabrics/my-group/actions/removeMembers" + """ + with does_not_raise(): + instance = EpManageFabricGroupMembersRemovePost() + instance.fabric_name = "my-group" + result = instance.path + assert result == "/api/v1/manage/fabrics/my-group/actions/removeMembers" + + +def test_endpoints_manage_fabric_group_members_00220(): + """ + # Summary + + Verify EpManageFabricGroupMembersRemovePost path without fabric_name raises ValueError + + ## Test + + - Accessing path without setting fabric_name raises ValueError + """ + with pytest.raises(ValueError): + instance = EpManageFabricGroupMembersRemovePost() + _ = instance.path + + +def test_endpoints_manage_fabric_group_members_00230(): + """ + # Summary + + Verify EpManageFabricGroupMembersRemovePost set_identifiers + + ## Test + + - set_identifiers sets fabric_name correctly + """ + with does_not_raise(): + instance = EpManageFabricGroupMembersRemovePost() + instance.set_identifiers("my-group") + result = instance.path + assert result == "/api/v1/manage/fabrics/my-group/actions/removeMembers" + assert instance.fabric_name == "my-group" + + +# ============================================================================= +# Test: Path with special characters in fabric group name +# ============================================================================= + + +def test_endpoints_manage_fabric_group_members_00300(): + """ + # Summary + + Verify endpoints handle fabric group names with hyphens and underscores + + ## Test + + - All endpoints correctly build paths with complex fabric group names + """ + complex_name = "my-fabric-group_v2" + + with does_not_raise(): + get_ep = EpManageFabricGroupMembersGet() + get_ep.fabric_name = complex_name + assert get_ep.path == f"/api/v1/manage/fabrics/{complex_name}/members" + + add_ep = EpManageFabricGroupMembersAddPost() + add_ep.fabric_name = complex_name + assert add_ep.path == f"/api/v1/manage/fabrics/{complex_name}/actions/addMembers" + + remove_ep = EpManageFabricGroupMembersRemovePost() + remove_ep.fabric_name = complex_name + assert remove_ep.path == f"/api/v1/manage/fabrics/{complex_name}/actions/removeMembers" From 8f7cbfc4593e8bdb75be786364b90b57840abf58 Mon Sep 17 00:00:00 2001 From: Matt Tarkington Date: Thu, 9 Apr 2026 20:42:27 -0400 Subject: [PATCH 2/3] resolve pipeline errors --- .../endpoints/v1/manage/manage_fabric_group_members.py | 2 +- .../test_endpoints_api_v1_manage_fabric_group_members.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/module_utils/endpoints/v1/manage/manage_fabric_group_members.py b/plugins/module_utils/endpoints/v1/manage/manage_fabric_group_members.py index 4ac06a49..ccf00d5f 100644 --- a/plugins/module_utils/endpoints/v1/manage/manage_fabric_group_members.py +++ b/plugins/module_utils/endpoints/v1/manage/manage_fabric_group_members.py @@ -21,7 +21,7 @@ __metaclass__ = type -from typing import ClassVar, Literal, Optional +from typing import ClassVar, Literal 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 diff --git a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabric_group_members.py b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabric_group_members.py index 459941f1..8a7ed579 100644 --- a/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabric_group_members.py +++ b/tests/unit/module_utils/endpoints/test_endpoints_api_v1_manage_fabric_group_members.py @@ -75,7 +75,7 @@ def test_endpoints_manage_fabric_group_members_00030(): """ with pytest.raises(ValueError): instance = EpManageFabricGroupMembersGet() - _ = instance.path + result = instance.path def test_endpoints_manage_fabric_group_members_00040(): @@ -148,7 +148,7 @@ def test_endpoints_manage_fabric_group_members_00120(): """ with pytest.raises(ValueError): instance = EpManageFabricGroupMembersAddPost() - _ = instance.path + result = instance.path def test_endpoints_manage_fabric_group_members_00130(): @@ -221,7 +221,7 @@ def test_endpoints_manage_fabric_group_members_00220(): """ with pytest.raises(ValueError): instance = EpManageFabricGroupMembersRemovePost() - _ = instance.path + result = instance.path def test_endpoints_manage_fabric_group_members_00230(): From 65ec848f31f405e70a9fe79c5e6662b5ad25b6c1 Mon Sep 17 00:00:00 2001 From: Matt Tarkington Date: Fri, 10 Apr 2026 04:31:53 -0400 Subject: [PATCH 3/3] update member mgmt to use bulk methods --- .../manage_fabric_group_members.py | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/plugins/module_utils/orchestrators/manage_fabric_group_members.py b/plugins/module_utils/orchestrators/manage_fabric_group_members.py index ab31e0a6..c68c1dc8 100644 --- a/plugins/module_utils/orchestrators/manage_fabric_group_members.py +++ b/plugins/module_utils/orchestrators/manage_fabric_group_members.py @@ -4,7 +4,7 @@ from __future__ import absolute_import, division, print_function -from typing import Type, ClassVar +from typing import Type, ClassVar, List, Optional 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_fabric_group.manage_fabric_group_members import FabricGroupMemberModel @@ -19,53 +19,52 @@ class ManageFabricGroupMembersOrchestrator(NDBaseOrchestrator[FabricGroupMemberModel]): model_class: ClassVar[Type[NDBaseModel]] = FabricGroupMemberModel + supports_bulk_create: ClassVar[bool] = True + supports_bulk_delete: ClassVar[bool] = True create_endpoint: Type[NDEndpointBaseModel] = EpManageFabricGroupMembersAddPost update_endpoint: Type[NDEndpointBaseModel] = EpManageFabricGroupMembersAddPost delete_endpoint: Type[NDEndpointBaseModel] = EpManageFabricGroupMembersRemovePost query_one_endpoint: Type[NDEndpointBaseModel] = EpManageFabricGroupMembersGet query_all_endpoint: Type[NDEndpointBaseModel] = EpManageFabricGroupMembersGet + create_bulk_endpoint: Optional[Type[NDEndpointBaseModel]] = EpManageFabricGroupMembersAddPost + delete_bulk_endpoint: Optional[Type[NDEndpointBaseModel]] = EpManageFabricGroupMembersRemovePost def _get_fabric_group_name(self) -> str: """Extract fabric_name from module params.""" return self.sender.params.get("fabric_name") - def create(self, model_instance: FabricGroupMemberModel, **kwargs) -> ResponseType: + def create_bulk(self, model_instances: List[FabricGroupMemberModel], **kwargs) -> ResponseType: """ - Add a member to the fabric group. + Add members to the fabric group in a single API call. - Wraps the member name in the required API payload format: - {"members": [{"name": "..."}]} + Wraps all member names in the required API payload format: + {"members": [{"name": "m1"}, {"name": "m2"}, ...]} """ try: - api_endpoint = self.create_endpoint() + api_endpoint = self.create_bulk_endpoint() api_endpoint.fabric_name = self._get_fabric_group_name() - payload = {"members": [{"name": model_instance.name}]} + payload = {"members": [{"name": instance.name} for instance in model_instances]} return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) except Exception as e: - raise Exception(f"Add member failed for {model_instance.name}: {e}") from e + names = [instance.name for instance in model_instances] + raise Exception(f"Add members failed for {names}: {e}") from e - def update(self, model_instance: FabricGroupMemberModel, **kwargs) -> ResponseType: + def delete_bulk(self, model_instances: List[FabricGroupMemberModel], **kwargs) -> ResponseType: """ - Update is not applicable for fabric group members. - Members are either present or absent — redirect to create (add). - """ - return self.create(model_instance, **kwargs) - - def delete(self, model_instance: FabricGroupMemberModel, **kwargs) -> ResponseType: - """ - Remove a member from the fabric group. + Remove members from the fabric group in a single API call. - Wraps the member name in the required API payload format: - {"members": [{"name": "..."}]} + Wraps all member names in the required API payload format: + {"members": [{"name": "m1"}, {"name": "m2"}, ...]} """ try: - api_endpoint = self.delete_endpoint() + api_endpoint = self.delete_bulk_endpoint() api_endpoint.fabric_name = self._get_fabric_group_name() - payload = {"members": [{"name": model_instance.name}]} + payload = {"members": [{"name": instance.name} for instance in model_instances]} return self.sender.request(path=api_endpoint.path, method=api_endpoint.verb, data=payload) except Exception as e: - raise Exception(f"Remove member failed for {model_instance.name}: {e}") from e + names = [instance.name for instance in model_instances] + raise Exception(f"Remove members failed for {names}: {e}") from e def query_one(self, model_instance: FabricGroupMemberModel, **kwargs) -> ResponseType: """