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

1#!/usr/bin/python 

2# -*- coding: utf-8 -*- 

3 

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) 

6 

7from __future__ import absolute_import, division, print_function 

8 

9__metaclass__ = type 

10 

11ANSIBLE_METADATA = { 

12 "metadata_version": "1.1", 

13 "status": ["preview"], 

14 "supported_by": "community", 

15} 

16 

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""" 

95 

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 

105 

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 

115 

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 

123 

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 

131 

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""" 

141 

142RETURN = r""" 

143""" 

144 

145 

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 

157 

158 

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) 

171 

172MIN_REQUIRED_API_VERSION = "2.24" 

173 

174 

175def _context_kwargs(module): 

176 """Return context_names kwargs when a context is set. 

177 

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 {} 

184 

185 

186def _lds_filter(local_ds_name, extra=None): 

187 """Build a filter expression that scopes to the given LDS. 

188 

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 

198 

199 

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 

212 

213 

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 

237 

238 

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) 

254 

255 

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 ) 

276 

277 

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 ) 

294 

295 

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 

311 

312 

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 ) 

320 

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 ) 

360 

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) 

376 

377 

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"] 

386 

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 ) 

400 

401 if module.params["local_users"]: 

402 _reconcile_user_members(module, blade, local_ds_name, module.params["name"]) 

403 module.exit_json(changed=changed) 

404 

405 

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 = [] 

411 

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") 

432 

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") 

438 

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") 

444 

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 ) 

451 

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 ) 

466 

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 ) 

478 

479 module.exit_json(changed=patch_changed or membership_changed) 

480 

481 

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 ) 

497 

498 module = AnsibleModule(argument_spec, supports_check_mode=True) 

499 

500 if not HAS_PYPURECLIENT: 

501 module.fail_json(msg="py-pure-client sdk is required for this module") 

502 

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 ) 

512 

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 

518 

519 local_ds_name = module.params["local_directory_service"] 

520 group = _get_group(module, blade, local_ds_name) 

521 state = module.params["state"] 

522 

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) 

531 

532 

533if __name__ == "__main__": 

534 main()