Coverage for plugins/modules/purefb_local_group.py: 88%
182 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 17:06 -0400
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 17:06 -0400
1#!/usr/bin/python
2# -*- coding: utf-8 -*-
4# (c) 2026, Simon Dodsley (simon@everpuredata.com)
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
7from __future__ import absolute_import, division, print_function
9__metaclass__ = type
11ANSIBLE_METADATA = {
12 "metadata_version": "1.1",
13 "status": ["preview"],
14 "supported_by": "community",
15}
17DOCUMENTATION = r"""
18---
19module: purefb_local_group
20version_added: '1.28.0'
21short_description: Manage FlashBlade Local Directory Service groups
22description:
23- Create, update, rename or delete a local group in a FlashBlade Local
24 Directory Service (LDS). These are the SMB/NFS local identities that live
25 inside an LDS attached to a FlashBlade server.
26- Requires FlashBlade REST API version 2.24 or later (Purity//FB 4.6.8+).
27- User memberships can be managed inline via the I(local_users) option. If
28 you also drive membership from the user side (via
29 M(everpure.flashblade.purefb_local_user)), only manage a given (user,group)
30 edge from one side in a single playbook run.
31author:
32- Pure Storage Ansible Team (@avk) <pure-ansible-team@everpuredata.com>
33options:
34 name:
35 description:
36 - Name of the local group inside the LDS.
37 type: str
38 required: true
39 state:
40 description:
41 - Whether the local group should exist or not.
42 type: str
43 default: present
44 choices: [ absent, present ]
45 local_directory_service:
46 description:
47 - Name of the Local Directory Service that owns this group.
48 - Attach LDSes to servers via M(everpure.flashblade.purefb_server).
49 type: str
50 required: true
51 new_name:
52 description:
53 - New name for the group. Uses the C(LocalGroupPatch.name) field.
54 - Rejected for built-in groups.
55 type: str
56 gid:
57 description:
58 - Numeric GID for the group. Auto-assigned by the array if omitted at
59 create time. Patchable.
60 type: int
61 email:
62 description:
63 - Email address for the group.
64 type: str
65 local_users:
66 description:
67 - List of local-user names that should be members of this group.
68 - When set, the module reconciles the group's user members against this
69 list via C(/directory-services/local/groups/members) filtered to user
70 members (nested-group and external members are unaffected).
71 - The option is named I(local_users) rather than I(members) to reserve
72 option space for future I(external_members) and I(group_members)
73 without a breaking rename.
74 type: list
75 elements: str
76 force:
77 description:
78 - Only used when I(state=absent).
79 - When the group has members and I(force=false), the module fails with
80 a clear message rather than surfacing the raw API error.
81 - When I(force=true), the module removes all current memberships before
82 deleting the group. Refused on built-in groups regardless.
83 type: bool
84 default: false
85 context:
86 description:
87 - Name of fleet member on which to perform the operation.
88 - This requires the array receiving the request is a member of a fleet
89 and the context name to be a member of the same fleet.
90 type: str
91 default: ""
92extends_documentation_fragment:
93- everpure.flashblade.everpure.fb
94"""
96EXAMPLES = r"""
97- name: Create local group 'developers'
98 everpure.flashblade.purefb_local_group:
99 name: developers
100 local_directory_service: myserver_local
101 gid: 6001
102 email: developers@example.com
103 fb_url: 10.10.10.2
104 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
106- name: Ensure 'developers' has these local user members
107 everpure.flashblade.purefb_local_group:
108 name: developers
109 local_directory_service: myserver_local
110 local_users:
111 - alice
112 - bob
113 fb_url: 10.10.10.2
114 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
116- name: Rename local group
117 everpure.flashblade.purefb_local_group:
118 name: developers
119 new_name: devs
120 local_directory_service: myserver_local
121 fb_url: 10.10.10.2
122 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
124- name: Delete an empty local group
125 everpure.flashblade.purefb_local_group:
126 name: devs
127 local_directory_service: myserver_local
128 state: absent
129 fb_url: 10.10.10.2
130 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
132- name: Force-delete a group that still has members
133 everpure.flashblade.purefb_local_group:
134 name: devs
135 local_directory_service: myserver_local
136 state: absent
137 force: true
138 fb_url: 10.10.10.2
139 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
140"""
142RETURN = r"""
143"""
146HAS_PYPURECLIENT = True
147try:
148 from pypureclient.flashblade import (
149 LocalGroupPost,
150 LocalGroupPatch,
151 LocalGroupMembershipPost,
152 LocalGroupMembershipPostMembers,
153 ReferenceWritable,
154 )
155except ImportError:
156 HAS_PYPURECLIENT = False
159from ansible.module_utils.basic import AnsibleModule
160from ansible_collections.everpure.flashblade.plugins.module_utils.purefb import (
161 get_system,
162 purefb_argument_spec,
163)
164from ansible_collections.everpure.flashblade.plugins.module_utils.common import (
165 get_error_message,
166 get_rest_api_version,
167)
168from ansible_collections.everpure.flashblade.plugins.module_utils.version import (
169 LooseVersion,
170)
172MIN_REQUIRED_API_VERSION = "2.24"
175def _context_kwargs(module):
176 """Return context_names kwargs when a context is set.
178 Local groups require REST 2.24, which is well above the 2.17 fleet-context
179 floor, so context_names is always accepted here.
180 """
181 if module.params["context"]:
182 return {"context_names": [module.params["context"]]}
183 return {}
186def _lds_filter(local_ds_name, extra=None):
187 """Build a filter expression that scopes to the given LDS.
189 The FB 2.24 GET endpoints for local groups / members do not accept
190 ``local_directory_service_names``; scoping must go through ``filter``.
191 """
192 expr = "local_directory_service.name='{0}'".format(
193 local_ds_name.replace("'", "\\'")
194 )
195 if extra:
196 expr = "({0}) and ({1})".format(expr, extra)
197 return expr
200def _get_group(module, blade, local_ds_name):
201 ctx = _context_kwargs(module)
202 res = blade.get_directory_services_local_groups(
203 names=[module.params["name"]],
204 filter=_lds_filter(local_ds_name),
205 **ctx,
206 )
207 if res.status_code == 200:
208 items = list(res.items)
209 if items:
210 return items[0]
211 return None
214def _get_current_user_members(module, blade, local_ds_name, group_name=None):
215 """Return the set of user-member names currently attached to this group."""
216 ctx = _context_kwargs(module)
217 res = blade.get_directory_services_local_groups_members(
218 group_names=[group_name or module.params["name"]],
219 filter=_lds_filter(local_ds_name),
220 member_types=["User"],
221 **ctx,
222 )
223 if res.status_code != 200:
224 module.fail_json(
225 msg="Failed to fetch memberships for group {0}. Error: {1}".format(
226 module.params["name"], get_error_message(res)
227 )
228 )
229 result = set()
230 for edge in list(res.items):
231 member = getattr(edge, "member", None)
232 if member is not None:
233 mname = getattr(member, "name", None)
234 if mname:
235 result.add(mname)
236 return result
239def _get_all_current_members(module, blade, local_ds_name):
240 """Return the full list of member edges for this group (any type)."""
241 ctx = _context_kwargs(module)
242 res = blade.get_directory_services_local_groups_members(
243 group_names=[module.params["name"]],
244 filter=_lds_filter(local_ds_name),
245 **ctx,
246 )
247 if res.status_code != 200:
248 module.fail_json(
249 msg="Failed to fetch memberships for group {0}. Error: {1}".format(
250 module.params["name"], get_error_message(res)
251 )
252 )
253 return list(res.items)
256def _add_user_members(module, blade, local_ds_name, users, group_name):
257 ctx = _context_kwargs(module)
258 body = LocalGroupMembershipPost(
259 members=[
260 LocalGroupMembershipPostMembers(member=ReferenceWritable(name=u))
261 for u in users
262 ]
263 )
264 res = blade.post_directory_services_local_groups_members(
265 local_membership=body,
266 group_names=[group_name],
267 local_directory_service_names=[local_ds_name],
268 **ctx,
269 )
270 if res.status_code != 200:
271 module.fail_json(
272 msg=("Failed to add user members {0} to group {1}. Error: {2}").format(
273 sorted(users), group_name, get_error_message(res)
274 )
275 )
278def _remove_user_members(module, blade, local_ds_name, users, group_name):
279 ctx = _context_kwargs(module)
280 for u in users:
281 res = blade.delete_directory_services_local_groups_members(
282 group_names=[group_name],
283 member_names=[u],
284 member_types=["User"],
285 local_directory_service_names=[local_ds_name],
286 **ctx,
287 )
288 if res.status_code != 200:
289 module.fail_json(
290 msg=("Failed to remove user {0} from group {1}. Error: {2}").format(
291 u, group_name, get_error_message(res)
292 )
293 )
296def _reconcile_user_members(module, blade, local_ds_name, group_name):
297 """Reconcile user members against module.params['local_users']."""
298 desired = set(module.params["local_users"] or [])
299 current = _get_current_user_members(
300 module, blade, local_ds_name, group_name=group_name
301 )
302 add = desired - current
303 remove = current - desired
304 changed = bool(add or remove)
305 if changed and not module.check_mode:
306 if add:
307 _add_user_members(module, blade, local_ds_name, add, group_name)
308 if remove:
309 _remove_user_members(module, blade, local_ds_name, remove, group_name)
310 return changed
313def delete_group(module, blade, local_ds_name, group):
314 if getattr(group, "built_in", False):
315 module.fail_json(
316 msg="Refusing to delete built-in local group {0}".format(
317 module.params["name"]
318 )
319 )
321 current_edges = _get_all_current_members(module, blade, local_ds_name)
322 if current_edges:
323 if not module.params["force"]:
324 module.fail_json(
325 msg=(
326 "Local group {0} has {1} member(s); "
327 "remove them or set force: true to auto-clear before delete."
328 ).format(module.params["name"], len(current_edges))
329 )
330 if not module.check_mode:
331 ctx = _context_kwargs(module)
332 for edge in current_edges:
333 member = getattr(edge, "member", None)
334 mname = getattr(member, "name", None) if member else None
335 if not mname:
336 continue
337 mtype = getattr(member, "resource_type", None)
338 delete_kwargs = {
339 "group_names": [module.params["name"]],
340 "member_names": [mname],
341 "local_directory_service_names": [local_ds_name],
342 }
343 if mtype:
344 # resource_type is a hint like 'local-users' or 'local-groups';
345 # translate to the member_types filter when we can.
346 if "user" in mtype:
347 delete_kwargs["member_types"] = ["User"]
348 elif "group" in mtype:
349 delete_kwargs["member_types"] = ["Group"]
350 delete_kwargs.update(ctx)
351 res = blade.delete_directory_services_local_groups_members(
352 **delete_kwargs
353 )
354 if res.status_code != 200:
355 module.fail_json(
356 msg=(
357 "Failed to clear member {0} from group {1}. Error: {2}"
358 ).format(mname, module.params["name"], get_error_message(res))
359 )
361 changed = True
362 if not module.check_mode:
363 ctx = _context_kwargs(module)
364 res = blade.delete_directory_services_local_groups(
365 names=[module.params["name"]],
366 local_directory_service_names=[local_ds_name],
367 **ctx,
368 )
369 if res.status_code != 200:
370 module.fail_json(
371 msg="Failed to delete local group {0}. Error: {1}".format(
372 module.params["name"], get_error_message(res)
373 )
374 )
375 module.exit_json(changed=changed)
378def create_group(module, blade, local_ds_name):
379 changed = True
380 if not module.check_mode:
381 body_kwargs = {}
382 if module.params["email"] is not None:
383 body_kwargs["email"] = module.params["email"]
384 if module.params["gid"] is not None:
385 body_kwargs["gid"] = module.params["gid"]
387 ctx = _context_kwargs(module)
388 res = blade.post_directory_services_local_groups(
389 names=[module.params["name"]],
390 local_directory_service_names=[local_ds_name],
391 local_group=LocalGroupPost(**body_kwargs),
392 **ctx,
393 )
394 if res.status_code != 200:
395 module.fail_json(
396 msg="Failed to create local group {0}. Error: {1}".format(
397 module.params["name"], get_error_message(res)
398 )
399 )
401 if module.params["local_users"]:
402 _reconcile_user_members(module, blade, local_ds_name, module.params["name"])
403 module.exit_json(changed=changed)
406def update_group(module, blade, local_ds_name, group):
407 ctx = _context_kwargs(module)
408 built_in = bool(getattr(group, "built_in", False))
409 patch_kwargs = {}
410 identity_changes = []
412 if module.params["new_name"] and module.params["new_name"] != module.params["name"]:
413 if built_in:
414 module.fail_json(
415 msg="Refusing to rename built-in local group {0}".format(
416 module.params["name"]
417 )
418 )
419 clash = blade.get_directory_services_local_groups(
420 names=[module.params["new_name"]],
421 filter=_lds_filter(local_ds_name),
422 **ctx,
423 )
424 if clash.status_code == 200 and list(clash.items):
425 module.fail_json(
426 msg=(
427 "Cannot rename local group {0} to {1}: target name already exists"
428 ).format(module.params["name"], module.params["new_name"])
429 )
430 patch_kwargs["name"] = module.params["new_name"]
431 identity_changes.append("name")
433 if module.params["email"] is not None and module.params["email"] != getattr(
434 group, "email", None
435 ):
436 patch_kwargs["email"] = module.params["email"]
437 identity_changes.append("email")
439 if module.params["gid"] is not None and module.params["gid"] != getattr(
440 group, "gid", None
441 ):
442 patch_kwargs["gid"] = module.params["gid"]
443 identity_changes.append("gid")
445 if identity_changes and built_in:
446 module.fail_json(
447 msg="Refusing to modify built-in local group {0}".format(
448 module.params["name"]
449 )
450 )
452 patch_changed = bool(patch_kwargs)
453 if patch_changed and not module.check_mode:
454 res = blade.patch_directory_services_local_groups(
455 names=[module.params["name"]],
456 local_directory_service_names=[local_ds_name],
457 local_group=LocalGroupPatch(**patch_kwargs),
458 **ctx,
459 )
460 if res.status_code != 200:
461 module.fail_json(
462 msg="Failed to update local group {0}. Error: {1}".format(
463 module.params["name"], get_error_message(res)
464 )
465 )
467 membership_changed = False
468 if module.params["local_users"] is not None:
469 # After a successful rename the group is now known by new_name; in
470 # check mode the rename was skipped, so it keeps its current name.
471 if module.check_mode:
472 effective_name = module.params["name"]
473 else:
474 effective_name = patch_kwargs.get("name") or module.params["name"]
475 membership_changed = _reconcile_user_members(
476 module, blade, local_ds_name, effective_name
477 )
479 module.exit_json(changed=patch_changed or membership_changed)
482def main():
483 argument_spec = purefb_argument_spec()
484 argument_spec.update(
485 dict(
486 name=dict(type="str", required=True),
487 state=dict(type="str", default="present", choices=["absent", "present"]),
488 local_directory_service=dict(type="str", required=True),
489 new_name=dict(type="str"),
490 gid=dict(type="int"),
491 email=dict(type="str"),
492 local_users=dict(type="list", elements="str"),
493 force=dict(type="bool", default=False),
494 context=dict(type="str", default=""),
495 )
496 )
498 module = AnsibleModule(argument_spec, supports_check_mode=True)
500 if not HAS_PYPURECLIENT:
501 module.fail_json(msg="py-pure-client sdk is required for this module")
503 blade = get_system(module)
504 api_version = get_rest_api_version(blade)
505 if LooseVersion(MIN_REQUIRED_API_VERSION) > LooseVersion(api_version):
506 module.fail_json(
507 msg=(
508 "FlashBlade REST version not supported. "
509 "Minimum version required: {0} (Purity//FB 4.6.8+)"
510 ).format(MIN_REQUIRED_API_VERSION)
511 )
513 if not module.params["context"]:
514 # If no context is provided set the context to the local array name
515 fleet_res = blade.get_fleets()
516 if fleet_res.status_code == 200 and list(fleet_res.items):
517 module.params["context"] = list(blade.get_arrays().items)[0].name
519 local_ds_name = module.params["local_directory_service"]
520 group = _get_group(module, blade, local_ds_name)
521 state = module.params["state"]
523 if state == "absent" and group:
524 delete_group(module, blade, local_ds_name, group)
525 elif state == "absent":
526 module.exit_json(changed=False)
527 elif state == "present" and not group:
528 create_group(module, blade, local_ds_name)
529 else:
530 update_group(module, blade, local_ds_name, group)
533if __name__ == "__main__":
534 main()