From 3b301fdb1838be70c1015a730a7d903759465e97 Mon Sep 17 00:00:00 2001 From: Brent Lim Date: Mon, 11 May 2026 00:22:11 -0600 Subject: [PATCH] Tgroup Ansible Module implementation This change adds topology group (tgroup) support to our Ansible Collection. With the purefa_tgroup module, users will now be able to create, rename, delete tgroups, and to add/remove tgroups or arrays into existing tgroups purefa_info has also been updated with a "tgroups" subset to list topology group information --- README.md | 1 + .../fragments/1000_add_tgroup_support.yaml | 2 + plugins/modules/purefa_info.py | 45 +- plugins/modules/purefa_tgroup.py | 534 ++++++++++++++++++ .../unit/plugins/modules/test_purefa_info.py | 82 +++ .../plugins/modules/test_purefa_tgroup.py | 389 +++++++++++++ 6 files changed, 1051 insertions(+), 2 deletions(-) create mode 100644 changelogs/fragments/1000_add_tgroup_support.yaml create mode 100644 plugins/modules/purefa_tgroup.py create mode 100644 tests/unit/plugins/modules/test_purefa_tgroup.py diff --git a/README.md b/README.md index ed98bff2..991f78d7 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ All modules are idempotent with the exception of modules that change or set pass - purefa_subnet - manage network subnets on the FlashArray - purefa_syslog - manage the Syslog settings on the FlashArray - purefa_syslog_settings - manage the global syslog server settings on the FlashArray +- purefa_tgroup - manage topology groups and their memberships on the FlashArray - purefa_token - manage FlashArray user API tokens - purefa_timeout - manage the GUI idle timeout on the FlashArray - purefa_user - manage local user accounts on the FlashArray diff --git a/changelogs/fragments/1000_add_tgroup_support.yaml b/changelogs/fragments/1000_add_tgroup_support.yaml new file mode 100644 index 00000000..7cb213a7 --- /dev/null +++ b/changelogs/fragments/1000_add_tgroup_support.yaml @@ -0,0 +1,2 @@ +minor_changes: + - purefa_info - Add the tgroups gather_subset for topology group reporting. diff --git a/plugins/modules/purefa_info.py b/plugins/modules/purefa_info.py index 7a2fd319..e725b913 100644 --- a/plugins/modules/purefa_info.py +++ b/plugins/modules/purefa_info.py @@ -35,8 +35,8 @@ capacity, network, subnet, interfaces, hgroups, pgroups, hosts, admins, volumes, snapshots, pods, replication, vgroups, offload, apps, arrays, certs, kmip, clients, policies, dir_snaps, filesystems, - alerts, virtual_machines, subscriptions, realms, fleet, presets and - workloads. + alerts, virtual_machines, subscriptions, realms, fleet, presets, + workloads and tgroups. type: list elements: str required: false @@ -123,6 +123,7 @@ CONTEXT_API_VERSION = "2.38" QUOTA_API_VERSION = "2.42" TAGS_API_VERSION = "2.39" +TGROUP_API_VERSION = "2.54" def _is_cbs(array): @@ -3009,6 +3010,41 @@ def generate_fleet_dict(array): return fleet_info +def generate_tgroups_dict(array): + tgroups_info = {} + tgroups = list(array.get_topology_groups().items) + for tgroup in tgroups: + tgroups_info[tgroup.name] = { + "id": getattr(tgroup, "id", None), + "context": tgroup.context.name, + "parent_topology_group": tgroup.parent_topology_group.name, + "arrays": [], + "tgroups": [], + } + + if not tgroups_info: + return tgroups_info + + members = list(array.get_topology_groups_members().items) + for member in members: + group_name = member.topology_group.name + member_ref = member.member + member_name = member_ref.name + member_type = member_ref.resource_type + if group_name not in tgroups_info or not member_name: + continue + member_info = { + "name": member_name, + "status": member.status, + "status_details": member.status_details, + } + if member_type == "remote-arrays": + tgroups_info[group_name]["arrays"].append(member_info) + elif member_type == "topology-groups": + tgroups_info[group_name]["tgroups"].append(member_info) + return tgroups_info + + def generate_preset_dict(array): def to_plain(value): @@ -3249,6 +3285,7 @@ def main(): "fleet", "presets", "workloads", + "tgroups", ) subset_test = (test in valid_subsets for test in subset) if not all(subset_test): @@ -3349,6 +3386,10 @@ def main(): info["presets"] = generate_preset_dict(array) if "workloads" in subset or "all" in subset: info["workloads"] = generate_workload_dict(array) + if LooseVersion(TGROUP_API_VERSION) <= LooseVersion(api_version) and ( + "tgroups" in subset or "all" in subset + ): + info["tgroups"] = generate_tgroups_dict(array) module.exit_json(changed=False, purefa_info=info) diff --git a/plugins/modules/purefa_tgroup.py b/plugins/modules/purefa_tgroup.py new file mode 100644 index 00000000..f4764260 --- /dev/null +++ b/plugins/modules/purefa_tgroup.py @@ -0,0 +1,534 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2026, Simon Dodsley (simon@purestorage.com) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +ANSIBLE_METADATA = { + "metadata_version": "1.1", + "status": ["preview"], + "supported_by": "community", +} + +DOCUMENTATION = r""" +--- +module: purefa_tgroup +version_added: '1.43.0' +short_description: Manage topology groups on Everpure FlashArrays +description: +- Create, delete or modify topology groups on Everpure FlashArrays. +- Supports topology group rename, parent moves, and direct member management. +author: +- Everpure Ansible Team (@sdodsley) +options: + name: + description: + - The name of the topology group. + type: str + required: true + state: + description: + - Define whether the topology group should exist or not. + type: str + default: present + choices: [ absent, present ] + rename: + description: + - New name of the topology group. + type: str + parent: + description: + - Parent topology group name. + type: str + array: + description: + - List of existing arrays to add to or remove from the topology group. + type: list + elements: str + tgroup: + description: + - List of existing child topology groups to add to or remove from + the topology group. + type: list + elements: str + context: + description: + - Name of fleet member on which to perform the operation. + - This requires the array receiving the request is a member of a fleet + and the context name to be a member of the same fleet. + type: str + default: "" +extends_documentation_fragment: +- purestorage.flasharray.purestorage.fa +""" + +EXAMPLES = r""" +- name: Create a topology group + purestorage.flasharray.purefa_tgroup: + name: app-stack + fa_url: 10.10.10.2 + api_token: 1234-5678-9012-3456 + +- name: Create a child topology group under an existing parent + purestorage.flasharray.purefa_tgroup: + name: app-stack-dev + parent: app-stack + fa_url: 10.10.10.2 + api_token: 1234-5678-9012-3456 + +- name: Rename a topology group + purestorage.flasharray.purefa_tgroup: + name: app-stack-dev + rename: app-stack-qa + fa_url: 10.10.10.2 + api_token: 1234-5678-9012-3456 + +- name: Add array and child topology group members + purestorage.flasharray.purefa_tgroup: + name: app-stack + array: + - array-a + - array-b + tgroup: + - app-stack-qa + fa_url: 10.10.10.2 + api_token: 1234-5678-9012-3456 + +- name: Remove specific members from a topology group + purestorage.flasharray.purefa_tgroup: + name: app-stack + array: + - array-b + tgroup: + - app-stack-qa + state: absent + fa_url: 10.10.10.2 + api_token: 1234-5678-9012-3456 + +- name: Delete a topology group + purestorage.flasharray.purefa_tgroup: + name: app-stack-dev + state: absent + fa_url: 10.10.10.2 + api_token: 1234-5678-9012-3456 +""" + +RETURN = r""" +""" + +HAS_PURESTORAGE = True +try: + from pypureclient.flasharray import ( + NewName, + ReferenceWithType, + TgroupMembersPost, + TgroupMembersPostMember, + TgroupPatch, + ) +except ImportError: + HAS_PURESTORAGE = False + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.purestorage.flasharray.plugins.module_utils.api_helpers import ( + check_api_version, + check_response, + delete_with_context, + get_with_context, + patch_with_context, + post_with_context, +) +from ansible_collections.purestorage.flasharray.plugins.module_utils.purefa import ( + get_array, + purefa_argument_spec, +) + +CONTEXT_API_VERSION = "2.38" +TGROUP_API_VERSION = "2.54" + + +def _extract_members(members, resource_type): + return [ + member.member.name + for member in members + if getattr(getattr(member, "member", None), "resource_type", None) + == resource_type + and getattr(getattr(member, "member", None), "name", None) + ] + + +def _missing_names(requested_names, items): + found_names = { + getattr(item, "name", None) for item in items if getattr(item, "name", None) + } + return sorted(set(requested_names or []).difference(found_names)) + + +def get_tgroup(module, array, name=None): + res = get_with_context( + array, + "get_topology_groups", + CONTEXT_API_VERSION, + module, + names=[name or module.params["name"]], + ) + items = list(getattr(res, "items", []) or []) + if res.status_code == 200 and items: + return items[0] + return None + + +def get_tgroup_members(module, array, name=None): + res = get_with_context( + array, + "get_topology_groups_members", + CONTEXT_API_VERSION, + module, + topology_group_names=[name or module.params["name"]], + ) + if res.status_code == 200: + return list(res.items) + return [] + + +def rename_exists(module, array): + res = get_with_context( + array, + "get_topology_groups", + CONTEXT_API_VERSION, + module, + names=[module.params["rename"]], + ) + return bool(res.status_code == 200 and list(getattr(res, "items", []) or [])) + + +def _build_members_payload(array_members=None, child_tgroups=None): + members = [] + for array_member in sorted(set(array_members or [])): + members.append( + TgroupMembersPostMember( + member=ReferenceWithType( + name=array_member, + resource_type="remote-arrays", + ) + ) + ) + for child_tgroup in sorted(set(child_tgroups or [])): + members.append( + TgroupMembersPostMember( + member=ReferenceWithType( + name=child_tgroup, resource_type="topology-groups" + ) + ) + ) + return TgroupMembersPost(members=members) + + +def add_members(module, array, tgroup_name, array_members=None, child_tgroups=None): + if not (array_members or child_tgroups): + return + res = post_with_context( + array, + "post_topology_groups_members", + CONTEXT_API_VERSION, + module, + topology_group_names=[tgroup_name], + members=_build_members_payload(array_members, child_tgroups), + ) + check_response( + res, + module, + f"Failed to add members to topology group {tgroup_name}", + ) + + +def remove_members(module, array, tgroup_name, member_names): + if not member_names: + return + res = delete_with_context( + array, + "delete_topology_groups_members", + CONTEXT_API_VERSION, + module, + topology_group_names=[tgroup_name], + member_names=member_names, + ) + check_response( + res, + module, + f"Failed to remove members from topology group {tgroup_name}", + ) + + +def validate_inputs(module, array, current_tgroup=None, current_members=None): + requested_children = module.params["tgroup"] or [] + requested_parent = module.params["parent"] + invalid_self_refs = {module.params["name"]} + if module.params["rename"]: + invalid_self_refs.add(module.params["rename"]) + + if requested_parent: + if requested_parent in invalid_self_refs: + module.fail_json(msg="A topology group cannot be its own parent.") + if requested_parent in requested_children: + module.fail_json( + msg=( + "A topology group cannot use the same topology group " + "as both parent and child member." + ) + ) + if not get_tgroup(module, array, requested_parent): + module.fail_json( + msg=f"Parent topology group {requested_parent} does not exist." + ) + if current_tgroup: + current_child_tgroups = _extract_members( + current_members or [], "topology-groups" + ) + if requested_parent in current_child_tgroups: + module.fail_json( + msg=( + f"Cannot move topology group {module.params['name']} " + f"under direct child topology group {requested_parent}." + ) + ) + + if requested_children: + for child_tgroup in requested_children: + if child_tgroup in invalid_self_refs: + module.fail_json( + msg="A topology group cannot be a child member of itself." + ) + res = get_with_context( + array, + "get_topology_groups", + CONTEXT_API_VERSION, + module, + names=requested_children, + ) + check_response(res, module, "Child topology group not found") + missing_children = _missing_names(requested_children, getattr(res, "items", [])) + if missing_children: + module.fail_json( + msg=f"Child topology group not found: {', '.join(missing_children)}" + ) + + if module.params["array"]: + res = array.get_remote_arrays(current_fleet_only=True) + check_response(res, module, "Array not found") + missing_arrays = _missing_names( + module.params["array"], getattr(res, "items", []) + ) + if missing_arrays: + module.fail_json(msg=f"Array not found: {', '.join(missing_arrays)}") + + +def create_tgroup(module, array): + if module.params["rename"]: + module.fail_json( + msg=( + f"Topology group {module.params['name']} does not exist - " + "rename failed." + ) + ) + + changed = True + if not module.check_mode: + kwargs = {"names": [module.params["name"]]} + if module.params["parent"]: + kwargs["parent_topology_group_names"] = [module.params["parent"]] + res = post_with_context( + array, + "post_topology_groups", + CONTEXT_API_VERSION, + module, + **kwargs, + ) + check_response( + res, + module, + f"Failed to create topology group {module.params['name']}", + ) + add_members( + module, + array, + module.params["name"], + module.params["array"], + module.params["tgroup"], + ) + module.exit_json(changed=changed) + + +def update_tgroup(module, array, tgroup=None, members=None): + changed = False + renamed = False + current_tgroup = tgroup or get_tgroup(module, array) + current_members = ( + members if members is not None else get_tgroup_members(module, array) + ) + current_name = current_tgroup.name + current_parent = getattr( + getattr(current_tgroup, "parent_topology_group", None), "name", None + ) + current_arrays = _extract_members(current_members, "remote-arrays") + current_child_tgroups = _extract_members(current_members, "topology-groups") + + if module.params["state"] == "present": + if module.params["rename"] and module.params["rename"] != current_name: + if not rename_exists(module, array): + changed = True + if not module.check_mode: + res = patch_with_context( + array, + "patch_topology_groups", + CONTEXT_API_VERSION, + module, + names=[current_name], + topology_group=TgroupPatch( + topology_group=NewName(name=module.params["rename"]) + ), + ) + check_response( + res, + module, + f"Rename to {module.params['rename']} failed", + ) + current_name = module.params["rename"] + renamed = True + else: + module.warn( + ( + f"Rename failed. Topology group " + f"{module.params['rename']} already exists. " + "Continuing with other changes..." + ) + ) + + if module.params["parent"] and module.params["parent"] != current_parent: + changed = True + if not module.check_mode: + res = patch_with_context( + array, + "patch_topology_groups", + CONTEXT_API_VERSION, + module, + names=[current_name], + topology_group=TgroupPatch(), + to_parent_topology_group_names=[module.params["parent"]], + ) + check_response( + res, + module, + ( + f"Failed to move topology group {current_name} " + f"under parent {module.params['parent']}" + ), + ) + + new_arrays = sorted( + set(module.params["array"] or []).difference(set(current_arrays)) + ) + new_tgroups = sorted( + set(module.params["tgroup"] or []).difference(set(current_child_tgroups)) + ) + if new_arrays or new_tgroups: + changed = True + if not module.check_mode: + add_members( + module, + array, + current_name, + new_arrays, + new_tgroups, + ) + else: + old_arrays = sorted( + set(module.params["array"] or []).intersection(set(current_arrays)) + ) + old_tgroups = sorted( + set(module.params["tgroup"] or []).intersection(set(current_child_tgroups)) + ) + if old_arrays or old_tgroups: + changed = True + if not module.check_mode: + remove_members( + module, + array, + current_name, + old_arrays + old_tgroups, + ) + + module.exit_json(changed=changed or renamed) + + +def delete_tgroup(module, array): + changed = True + if not module.check_mode: + res = delete_with_context( + array, + "delete_topology_groups", + CONTEXT_API_VERSION, + module, + names=[module.params["name"]], + ) + check_response( + res, + module, + f"Failed to delete topology group {module.params['name']}", + ) + module.exit_json(changed=changed) + + +def main(): + argument_spec = purefa_argument_spec() + argument_spec.update( + dict( + name=dict(type="str", required=True), + state=dict( + type="str", + default="present", + choices=["absent", "present"], + ), + rename=dict(type="str"), + parent=dict(type="str"), + array=dict(type="list", elements="str"), + tgroup=dict(type="list", elements="str"), + context=dict(type="str", default=""), + ) + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + + if not HAS_PURESTORAGE: + module.fail_json(msg="py-pure-client sdk is required.") + + array = get_array(module) + check_api_version(array, TGROUP_API_VERSION, module, "Topology groups") + + current_tgroup = get_tgroup(module, array) + current_members = get_tgroup_members(module, array) if current_tgroup else [] + validate_inputs(module, array, current_tgroup, current_members) + + if current_tgroup and module.params["state"] == "present": + update_tgroup(module, array, current_tgroup, current_members) + elif ( + current_tgroup + and module.params["state"] == "absent" + and (module.params["array"] or module.params["tgroup"]) + ): + update_tgroup(module, array, current_tgroup, current_members) + elif current_tgroup and module.params["state"] == "absent": + delete_tgroup(module, array) + elif current_tgroup is None and module.params["state"] == "absent": + module.exit_json(changed=False) + else: + create_tgroup(module, array) + + module.exit_json(changed=False) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/modules/test_purefa_info.py b/tests/unit/plugins/modules/test_purefa_info.py index c44f9d4f..76be1452 100644 --- a/tests/unit/plugins/modules/test_purefa_info.py +++ b/tests/unit/plugins/modules/test_purefa_info.py @@ -73,6 +73,7 @@ generate_vmsnap_dict, generate_subs_dict, generate_fleet_dict, + generate_tgroups_dict, generate_preset_dict, generate_workload_dict, generate_realms_dict, @@ -511,6 +512,34 @@ def test_main_admins_subset( call_args = mock_module.exit_json.call_args[1] assert "admins" in call_args["purefa_info"] + @patch("plugins.modules.purefa_info.LooseVersion") + @patch("plugins.modules.purefa_info.generate_tgroups_dict") + @patch("plugins.modules.purefa_info.get_array") + @patch("plugins.modules.purefa_info.AnsibleModule") + def test_main_tgroups_subset( + self, mock_ansible_module, mock_get_array, mock_gen_tgroups, mock_loose_version + ): + """Test main with tgroups subset""" + mock_loose_version.side_effect = float + mock_module = Mock() + mock_module.params = { + "gather_subset": ["tgroups"], + } + mock_ansible_module.return_value = mock_module + + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.54" + mock_get_array.return_value = mock_array + + mock_gen_tgroups.return_value = {"app-stack": {"arrays": [], "tgroups": []}} + + main() + + mock_gen_tgroups.assert_called_once_with(mock_array) + mock_module.exit_json.assert_called_once() + call_args = mock_module.exit_json.call_args[1] + assert "tgroups" in call_args["purefa_info"] + class TestGenerateDefaultDict: """Test cases for generate_default_dict function""" @@ -1494,6 +1523,59 @@ def test_generate_fleet_dict_success(self): assert "members" in result["test-fleet"] +class TestGenerateTgroupsDict: + """Test cases for generate_tgroups_dict function""" + + def test_generate_tgroups_dict_success(self): + """Test topology groups dict generation""" + from types import SimpleNamespace + + mock_array = Mock() + + mock_tgroup = SimpleNamespace( + name="app-stack", + id="tg-1", + context=SimpleNamespace(name="array-a"), + parent_topology_group=SimpleNamespace(name="parent-stack"), + ) + array_member = SimpleNamespace( + topology_group=SimpleNamespace(name="app-stack"), + member=SimpleNamespace(name="array-b", resource_type="remote-arrays"), + status="joined", + status_details="", + ) + child_member = SimpleNamespace( + topology_group=SimpleNamespace(name="app-stack"), + member=SimpleNamespace( + name="app-stack-dev", resource_type="topology-groups" + ), + status="joined", + status_details="", + ) + + mock_array.get_topology_groups.return_value = Mock(items=[mock_tgroup]) + mock_array.get_topology_groups_members.return_value = Mock( + items=[array_member, child_member] + ) + + result = generate_tgroups_dict(mock_array) + + assert "app-stack" in result + assert result["app-stack"]["id"] == "tg-1" + assert result["app-stack"]["context"] == "array-a" + assert result["app-stack"]["parent_topology_group"] == "parent-stack" + assert result["app-stack"]["arrays"] == [ + {"name": "array-b", "status": "joined", "status_details": ""} + ] + assert result["app-stack"]["tgroups"] == [ + { + "name": "app-stack-dev", + "status": "joined", + "status_details": "", + } + ] + + class TestGeneratePresetDict: """Test cases for generate_preset_dict function""" diff --git a/tests/unit/plugins/modules/test_purefa_tgroup.py b/tests/unit/plugins/modules/test_purefa_tgroup.py new file mode 100644 index 00000000..cc81a70a --- /dev/null +++ b/tests/unit/plugins/modules/test_purefa_tgroup.py @@ -0,0 +1,389 @@ +# Copyright: (c) 2026, Pure Storage Ansible Team +# +# GNU General Public License v3.0+ (see COPYING.GPLv3 or +# https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Unit tests for purefa_tgroup module.""" + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import sys +from types import SimpleNamespace +from unittest.mock import Mock, patch, MagicMock +from packaging.version import Version as LooseVersion + +sys.modules["grp"] = MagicMock() +sys.modules["pwd"] = MagicMock() +sys.modules["fcntl"] = MagicMock() +sys.modules["ansible"] = MagicMock() +sys.modules["ansible.module_utils"] = MagicMock() +sys.modules["ansible.module_utils.basic"] = MagicMock() +sys.modules["pypureclient"] = MagicMock() +sys.modules["pypureclient.flasharray"] = MagicMock() +sys.modules["ansible_collections"] = MagicMock() +sys.modules["ansible_collections.purestorage"] = MagicMock() +sys.modules["ansible_collections.purestorage.flasharray"] = MagicMock() +sys.modules["ansible_collections.purestorage.flasharray.plugins"] = MagicMock() +sys.modules["ansible_collections.purestorage.flasharray.plugins.module_utils"] = ( + MagicMock() +) +sys.modules[ + "ansible_collections.purestorage.flasharray.plugins.module_utils.purefa" +] = MagicMock() +mock_version_module = MagicMock() +mock_version_module.LooseVersion = LooseVersion +sys.modules[ + "ansible_collections.purestorage.flasharray.plugins.module_utils.version" +] = mock_version_module +sys.modules[ + "ansible_collections.purestorage.flasharray.plugins.module_utils.api_helpers" +] = MagicMock() + +from plugins.modules.purefa_tgroup import ( + _build_members_payload, + create_tgroup, + get_tgroup, + main, + rename_exists, + update_tgroup, + validate_inputs, +) + + +def _member(resource_type, name): + return SimpleNamespace( + member=SimpleNamespace(resource_type=resource_type, name=name), + topology_group=SimpleNamespace(name="app-stack"), + status="healthy", + status_details=None, + ) + + +class TestMemberPayloads: + @patch("plugins.modules.purefa_tgroup.TgroupMembersPost") + @patch("plugins.modules.purefa_tgroup.TgroupMembersPostMember") + @patch("plugins.modules.purefa_tgroup.ReferenceWithType") + def test_build_members_payload_uses_remote_array_resource_type( + self, + mock_reference_with_type, + mock_tgroup_member_post_member, + mock_tgroup_members_post, + ): + _build_members_payload(["array-a"], ["child-a"]) + + assert mock_reference_with_type.call_args_list[0][1] == { + "name": "array-a", + "resource_type": "remote-arrays", + } + assert mock_reference_with_type.call_args_list[1][1] == { + "name": "child-a", + "resource_type": "topology-groups", + } + mock_tgroup_member_post_member.assert_called() + mock_tgroup_members_post.assert_called_once() + + +class TestValidateInputs: + @patch("plugins.modules.purefa_tgroup.get_tgroup", return_value=None) + def test_validate_inputs_self_parent_fails(self, _mock_get_tgroup): + mock_module = Mock() + mock_module.params = { + "name": "app-stack", + "rename": None, + "parent": "app-stack", + "tgroup": [], + "array": [], + } + mock_module.fail_json.side_effect = SystemExit("fail_json called") + + try: + validate_inputs(mock_module, Mock()) + except SystemExit: + pass + + mock_module.fail_json.assert_called_once() + + @patch("plugins.modules.purefa_tgroup.get_with_context") + def test_validate_inputs_missing_child_tgroup_fails(self, mock_get_with_context): + mock_module = Mock() + mock_module.params = { + "name": "app-stack", + "rename": None, + "parent": None, + "tgroup": ["child-a", "missing-child"], + "array": [], + } + mock_module.fail_json.side_effect = SystemExit("fail_json called") + mock_get_with_context.return_value = Mock( + status_code=200, + items=[SimpleNamespace(name="child-a")], + ) + + try: + validate_inputs(mock_module, Mock()) + except SystemExit: + pass + + mock_module.fail_json.assert_called_once() + assert "missing-child" in mock_module.fail_json.call_args[1]["msg"] + + def test_validate_inputs_missing_array_fails(self): + mock_module = Mock() + mock_module.params = { + "name": "app-stack", + "rename": None, + "parent": None, + "tgroup": [], + "array": ["array-a", "missing-array"], + } + mock_module.fail_json.side_effect = SystemExit("fail_json called") + mock_array = Mock() + mock_array.get_remote_arrays.return_value = Mock( + status_code=200, + items=[SimpleNamespace(name="array-a")], + ) + + try: + validate_inputs(mock_module, mock_array) + except SystemExit: + pass + + mock_array.get_remote_arrays.assert_called_once_with(current_fleet_only=True) + mock_module.fail_json.assert_called_once() + assert "missing-array" in mock_module.fail_json.call_args[1]["msg"] + + +class TestLookups: + @patch("plugins.modules.purefa_tgroup.get_with_context") + def test_get_tgroup_returns_none_for_empty_items(self, mock_get_with_context): + mock_module = Mock() + mock_module.params = {"name": "missing-tg", "context": ""} + mock_get_with_context.return_value = Mock(status_code=200, items=[]) + + assert get_tgroup(mock_module, Mock()) is None + + @patch("plugins.modules.purefa_tgroup.get_with_context") + def test_rename_exists_false_for_empty_items(self, mock_get_with_context): + mock_module = Mock() + mock_module.params = {"rename": "missing-tg", "context": ""} + mock_get_with_context.return_value = Mock(status_code=200, items=[]) + + assert rename_exists(mock_module, Mock()) is False + + @patch("plugins.modules.purefa_tgroup.get_with_context") + def test_rename_exists_true_when_item_returned(self, mock_get_with_context): + mock_module = Mock() + mock_module.params = {"rename": "existing-tg", "context": ""} + mock_get_with_context.return_value = Mock( + status_code=200, + items=[SimpleNamespace(name="existing-tg")], + ) + + assert rename_exists(mock_module, Mock()) is True + + +class TestCreateTgroup: + @patch("plugins.modules.purefa_tgroup.add_members") + @patch("plugins.modules.purefa_tgroup.check_response") + @patch("plugins.modules.purefa_tgroup.post_with_context") + def test_create_tgroup_with_parent_and_members( + self, + mock_post_with_context, + _mock_check_response, + mock_add_members, + ): + mock_module = Mock() + mock_module.check_mode = False + mock_module.params = { + "name": "app-stack", + "rename": None, + "parent": "parent-stack", + "array": ["array-a"], + "tgroup": ["child-a"], + } + mock_array = Mock() + + create_tgroup(mock_module, mock_array) + + mock_post_with_context.assert_called_once() + assert mock_post_with_context.call_args[1]["names"] == ["app-stack"] + assert mock_post_with_context.call_args[1]["parent_topology_group_names"] == [ + "parent-stack" + ] + mock_add_members.assert_called_once_with( + mock_module, + mock_array, + "app-stack", + ["array-a"], + ["child-a"], + ) + mock_module.exit_json.assert_called_once_with(changed=True) + + +class TestUpdateTgroup: + @patch("plugins.modules.purefa_tgroup.add_members") + @patch("plugins.modules.purefa_tgroup.check_response") + @patch("plugins.modules.purefa_tgroup.patch_with_context") + @patch("plugins.modules.purefa_tgroup.rename_exists", return_value=False) + def test_update_tgroup_rename_move_and_add_members( + self, + _mock_rename_exists, + mock_patch_with_context, + _mock_check_response, + mock_add_members, + ): + mock_module = Mock() + mock_module.check_mode = False + mock_module.params = { + "name": "app-stack", + "state": "present", + "rename": "app-stack-qa", + "parent": "parent-stack", + "array": ["array-a"], + "tgroup": ["child-a"], + } + mock_array = Mock() + current_tgroup = SimpleNamespace( + name="app-stack", + parent_topology_group=SimpleNamespace(name=None), + ) + + update_tgroup(mock_module, mock_array, current_tgroup, []) + + assert mock_patch_with_context.call_count == 2 + assert mock_patch_with_context.call_args_list[0][1]["names"] == ["app-stack"] + assert mock_patch_with_context.call_args_list[1][1]["names"] == ["app-stack-qa"] + assert "topology_group" in mock_patch_with_context.call_args_list[1][1] + assert mock_patch_with_context.call_args_list[1][1][ + "to_parent_topology_group_names" + ] == ["parent-stack"] + mock_add_members.assert_called_once_with( + mock_module, + mock_array, + "app-stack-qa", + ["array-a"], + ["child-a"], + ) + mock_module.exit_json.assert_called_once_with(changed=True) + + @patch("plugins.modules.purefa_tgroup.add_members") + @patch("plugins.modules.purefa_tgroup.check_response") + @patch("plugins.modules.purefa_tgroup.patch_with_context") + def test_update_tgroup_move_only_includes_topology_group_body( + self, + mock_patch_with_context, + _mock_check_response, + mock_add_members, + ): + mock_module = Mock() + mock_module.check_mode = False + mock_module.params = { + "name": "app-stack", + "state": "present", + "rename": None, + "parent": "parent-stack", + "array": [], + "tgroup": [], + } + mock_array = Mock() + current_tgroup = SimpleNamespace( + name="app-stack", + parent_topology_group=SimpleNamespace(name=None), + ) + + update_tgroup(mock_module, mock_array, current_tgroup, []) + + mock_patch_with_context.assert_called_once() + assert mock_patch_with_context.call_args[1]["names"] == ["app-stack"] + assert "topology_group" in mock_patch_with_context.call_args[1] + assert mock_patch_with_context.call_args[1][ + "to_parent_topology_group_names" + ] == ["parent-stack"] + mock_add_members.assert_not_called() + mock_module.exit_json.assert_called_once_with(changed=True) + + @patch("plugins.modules.purefa_tgroup.remove_members") + def test_update_tgroup_absent_removes_only_existing_members( + self, mock_remove_members + ): + mock_module = Mock() + mock_module.check_mode = False + mock_module.params = { + "name": "app-stack", + "state": "absent", + "rename": None, + "parent": None, + "array": ["array-b", "array-c"], + "tgroup": ["child-a"], + } + mock_array = Mock() + current_tgroup = SimpleNamespace( + name="app-stack", + parent_topology_group=None, + ) + current_members = [ + _member("remote-arrays", "array-a"), + _member("remote-arrays", "array-b"), + _member("topology-groups", "child-a"), + ] + + update_tgroup(mock_module, mock_array, current_tgroup, current_members) + + mock_remove_members.assert_called_once_with( + mock_module, + mock_array, + "app-stack", + ["array-b", "child-a"], + ) + mock_module.exit_json.assert_called_once_with(changed=True) + + +class TestMain: + @patch("plugins.modules.purefa_tgroup.validate_inputs") + @patch("plugins.modules.purefa_tgroup.get_tgroup_members") + @patch("plugins.modules.purefa_tgroup.get_tgroup") + @patch("plugins.modules.purefa_tgroup.check_api_version") + @patch("plugins.modules.purefa_tgroup.get_array") + @patch("plugins.modules.purefa_tgroup.AnsibleModule") + def test_main_absent_missing_group_is_unchanged( + self, + mock_ansible_module, + mock_get_array, + _mock_check_api_version, + mock_get_tgroup, + mock_get_tgroup_members, + mock_validate_inputs, + ): + mock_module = Mock() + mock_module.params = { + "name": "missing-tg", + "state": "absent", + "rename": None, + "parent": None, + "array": [], + "tgroup": [], + "context": "", + } + mock_module.exit_json.side_effect = SystemExit(0) + mock_ansible_module.return_value = mock_module + + mock_array = Mock() + mock_array.get_rest_version.return_value = "2.54" + mock_get_array.return_value = mock_array + mock_get_tgroup.return_value = None + + try: + main() + except SystemExit: + pass + + mock_get_tgroup_members.assert_not_called() + mock_validate_inputs.assert_called_once_with( + mock_module, + mock_array, + None, + [], + ) + mock_module.exit_json.assert_called_once_with(changed=False)