Coverage for plugins/modules/purefb_s3_export_policy.py: 91%

131 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 

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_s3_export_policy 

20version_added: '1.26.0' 

21short_description: Manage FlashBlade S3 Export Policies 

22description: 

23- Create, update, rename or delete FlashBlade S3 Export Policies and their rules. 

24- An S3 export policy is a reusable object-store policy that controls which 

25 buckets in an object-store account are visible for S3 through an 

26 object-store account export. 

27- The policy shell (name, enabled flag, inline rules) is managed via the 

28 C(/s3-export-policies) endpoint. The set of rules is reconciled against 

29 the C(/s3-export-policies/rules) sub-resource on update. 

30author: 

31- Pure Storage Ansible Team (@sdodsley) <pure-ansible-team@everpuredata.com> 

32options: 

33 name: 

34 description: 

35 - Name of the S3 export policy. 

36 type: str 

37 required: true 

38 state: 

39 description: 

40 - Define whether the S3 export policy should exist or not. 

41 default: present 

42 choices: [ absent, present ] 

43 type: str 

44 enabled: 

45 description: 

46 - If C(true) the policy is enabled. 

47 - Defaults to C(true) on creation if not specified. 

48 type: bool 

49 rename: 

50 description: 

51 - New name for the S3 export policy. 

52 - Only takes effect when the policy already exists. 

53 type: str 

54 rules: 

55 description: 

56 - Ordered list of rules to apply to the policy. 

57 - When supplied on create, rules are materialised in a single round trip. 

58 - When supplied on update, rules are reconciled against the current 

59 state - missing rules are added, divergent rules are patched, and 

60 rules not in this list are removed. 

61 - Omit this option entirely to leave existing rules untouched. 

62 type: list 

63 elements: dict 

64 suboptions: 

65 name: 

66 description: 

67 - Name of the rule. Used as the idempotency key. 

68 type: str 

69 required: true 

70 actions: 

71 description: 

72 - List of actions granted by this rule. 

73 - Currently only C(pure:S3Access) is supported. 

74 type: list 

75 elements: str 

76 effect: 

77 description: 

78 - Effect of this rule. 

79 type: str 

80 choices: [ allow, deny ] 

81 resources: 

82 description: 

83 - List of bucket resources from the account that this rule applies to. 

84 - Glob patterns are supported (for example C(my-bucket*) or C(*)). 

85 type: list 

86 elements: str 

87 context: 

88 description: 

89 - Name of fleet member on which to perform the operation. 

90 - This requires the array receiving the request is a member of a fleet 

91 and the context name to be a member of the same fleet. 

92 type: str 

93 default: "" 

94extends_documentation_fragment: 

95- everpure.flashblade.everpure.fb 

96""" 

97 

98EXAMPLES = r""" 

99- name: Create an S3 export policy with two rules 

100 everpure.flashblade.purefb_s3_export_policy: 

101 name: my_export_policy 

102 enabled: true 

103 rules: 

104 - name: allow_all_buckets 

105 actions: 

106 - "pure:S3Access" 

107 effect: allow 

108 resources: 

109 - "*" 

110 - name: deny_finance 

111 actions: 

112 - "pure:S3Access" 

113 effect: deny 

114 resources: 

115 - "finance-*" 

116 fb_url: 10.10.10.2 

117 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3 

118 

119- name: Disable an existing S3 export policy 

120 everpure.flashblade.purefb_s3_export_policy: 

121 name: my_export_policy 

122 enabled: false 

123 fb_url: 10.10.10.2 

124 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3 

125 

126- name: Rename an S3 export policy 

127 everpure.flashblade.purefb_s3_export_policy: 

128 name: my_export_policy 

129 rename: tenant_a_export_policy 

130 fb_url: 10.10.10.2 

131 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3 

132 

133- name: Delete an S3 export policy 

134 everpure.flashblade.purefb_s3_export_policy: 

135 name: tenant_a_export_policy 

136 state: absent 

137 fb_url: 10.10.10.2 

138 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3 

139""" 

140 

141RETURN = r""" 

142""" 

143 

144HAS_PYPURECLIENT = True 

145try: 

146 from pypureclient.flashblade import ( 

147 S3ExportPolicyPost, 

148 S3ExportPolicyPatch, 

149 S3ExportPolicyRulePost, 

150 ) 

151except ImportError: 

152 HAS_PYPURECLIENT = False 

153 

154from ansible.module_utils.basic import AnsibleModule 

155from ansible_collections.everpure.flashblade.plugins.module_utils.purefb import ( 

156 get_system, 

157 purefb_argument_spec, 

158) 

159from ansible_collections.everpure.flashblade.plugins.module_utils.common import ( 

160 get_error_message, 

161 get_rest_api_version, 

162) 

163from ansible_collections.everpure.flashblade.plugins.module_utils.version import ( 

164 LooseVersion, 

165) 

166 

167MIN_REQUIRED_API_VERSION = "2.20" 

168 

169 

170def _context_kwargs(module): 

171 """Build context_names kwargs only when a context is supplied.""" 

172 if module.params["context"]: 

173 return {"context_names": [module.params["context"]]} 

174 return {} 

175 

176 

177def _normalize_rule(rule): 

178 """Normalize a desired-rule dict for order-insensitive comparison.""" 

179 return { 

180 "name": rule.get("name"), 

181 "actions": sorted(rule.get("actions") or []), 

182 "effect": rule.get("effect"), 

183 "resources": sorted(rule.get("resources") or []), 

184 } 

185 

186 

187def _normalize_existing_rule(rule_obj): 

188 """Normalize a rule object returned by the SDK for comparison.""" 

189 return { 

190 "name": getattr(rule_obj, "name", None), 

191 "actions": sorted(list(getattr(rule_obj, "actions", None) or [])), 

192 "effect": getattr(rule_obj, "effect", None), 

193 "resources": sorted(list(getattr(rule_obj, "resources", None) or [])), 

194 } 

195 

196 

197def _get_policy_by_name(module, blade, name): 

198 """Return the policy object called name, or None.""" 

199 res = blade.get_s3_export_policies(names=[name], **_context_kwargs(module)) 

200 if res.status_code != 200: 

201 return None 

202 items = list(res.items) 

203 return items[0] if items else None 

204 

205 

206def _get_policy(module, blade): 

207 """Return the existing policy object or None.""" 

208 return _get_policy_by_name(module, blade, module.params["name"]) 

209 

210 

211def _get_policy_rules(module, blade): 

212 """Return the list of existing rule objects for the policy.""" 

213 res = blade.get_s3_export_policies_rules( 

214 policy_names=[module.params["name"]], **_context_kwargs(module) 

215 ) 

216 if res.status_code != 200: 

217 return [] 

218 return list(res.items) 

219 

220 

221def _reconcile_rules(module, blade, desired_rules): 

222 """Add/modify/remove rules to match desired_rules. Returns True if changed.""" 

223 current_rules = _get_policy_rules(module, blade) 

224 current_by_name = { 

225 getattr(r, "name", None): _normalize_existing_rule(r) for r in current_rules 

226 } 

227 desired_by_name = {r["name"]: _normalize_rule(r) for r in desired_rules} 

228 

229 to_add = [n for n in desired_by_name if n not in current_by_name] 

230 to_remove = [n for n in current_by_name if n not in desired_by_name] 

231 to_modify = [ 

232 n 

233 for n in desired_by_name 

234 if n in current_by_name and desired_by_name[n] != current_by_name[n] 

235 ] 

236 

237 if not (to_add or to_remove or to_modify): 

238 return False 

239 

240 if module.check_mode: 

241 return True 

242 

243 for name in to_add: 

244 d = desired_by_name[name] 

245 res = blade.post_s3_export_policies_rules( 

246 names=[name], 

247 policy_names=[module.params["name"]], 

248 rule=S3ExportPolicyRulePost( 

249 actions=d["actions"] or None, 

250 effect=d["effect"], 

251 resources=d["resources"] or None, 

252 ), 

253 **_context_kwargs(module), 

254 ) 

255 if res.status_code != 200: 

256 module.fail_json( 

257 msg="Failed to add rule {0} to S3 export policy {1}. Error: {2}".format( 

258 name, module.params["name"], get_error_message(res) 

259 ) 

260 ) 

261 

262 for name in to_modify: 

263 d = desired_by_name[name] 

264 res = blade.patch_s3_export_policies_rules( 

265 names=[name], 

266 policy_names=[module.params["name"]], 

267 rule=S3ExportPolicyRulePost( 

268 actions=d["actions"] or None, 

269 effect=d["effect"], 

270 resources=d["resources"] or None, 

271 ), 

272 **_context_kwargs(module), 

273 ) 

274 if res.status_code != 200: 

275 module.fail_json( 

276 msg="Failed to update rule {0} on S3 export policy {1}. Error: {2}".format( 

277 name, module.params["name"], get_error_message(res) 

278 ) 

279 ) 

280 

281 if to_remove: 

282 res = blade.delete_s3_export_policies_rules( 

283 names=to_remove, 

284 policy_names=[module.params["name"]], 

285 **_context_kwargs(module), 

286 ) 

287 if res.status_code != 200: 

288 module.fail_json( 

289 msg="Failed to remove rules {0} from S3 export policy {1}. Error: {2}".format( 

290 to_remove, module.params["name"], get_error_message(res) 

291 ) 

292 ) 

293 

294 return True 

295 

296 

297def delete_policy(module, blade): 

298 """Delete an S3 export policy.""" 

299 changed = True 

300 if not module.check_mode: 

301 res = blade.delete_s3_export_policies( 

302 names=[module.params["name"]], **_context_kwargs(module) 

303 ) 

304 if res.status_code != 200: 

305 module.fail_json( 

306 msg="Failed to delete S3 export policy {0}. Error: {1}".format( 

307 module.params["name"], get_error_message(res) 

308 ) 

309 ) 

310 module.exit_json(changed=changed) 

311 

312 

313def create_policy(module, blade): 

314 """Create a new S3 export policy.""" 

315 changed = True 

316 policy_kwargs = {} 

317 if module.params["enabled"] is not None: 

318 policy_kwargs["enabled"] = module.params["enabled"] 

319 if module.params["rules"]: 

320 policy_kwargs["rules"] = [ 

321 { 

322 "name": r["name"], 

323 "actions": r.get("actions"), 

324 "effect": r.get("effect"), 

325 "resources": r.get("resources"), 

326 } 

327 for r in module.params["rules"] 

328 ] 

329 if not module.check_mode: 

330 res = blade.post_s3_export_policies( 

331 names=[module.params["name"]], 

332 policy=S3ExportPolicyPost(**policy_kwargs), 

333 **_context_kwargs(module), 

334 ) 

335 if res.status_code != 200: 

336 module.fail_json( 

337 msg="Failed to create S3 export policy {0}. Error: {1}".format( 

338 module.params["name"], get_error_message(res) 

339 ) 

340 ) 

341 module.exit_json(changed=changed) 

342 

343 

344def update_policy(module, blade, policy): 

345 """Update an existing S3 export policy.""" 

346 changed = False 

347 

348 if module.params["rules"] is not None: 

349 # Reconcile the rules first, under the policy's current name. A rename 

350 # (if any) is applied afterwards so the rule calls address a name that 

351 # still exists. 

352 if _reconcile_rules(module, blade, module.params["rules"]): 

353 changed = True 

354 

355 patch_kwargs = {} 

356 

357 if module.params["enabled"] is not None and module.params["enabled"] != getattr( 

358 policy, "enabled", None 

359 ): 

360 patch_kwargs["enabled"] = module.params["enabled"] 

361 

362 if module.params["rename"] and module.params["rename"] != getattr( 

363 policy, "name", None 

364 ): 

365 patch_kwargs["name"] = module.params["rename"] 

366 

367 if patch_kwargs: 

368 changed = True 

369 if not module.check_mode: 

370 res = blade.patch_s3_export_policies( 

371 names=[module.params["name"]], 

372 policy=S3ExportPolicyPatch(**patch_kwargs), 

373 **_context_kwargs(module), 

374 ) 

375 if res.status_code != 200: 

376 module.fail_json( 

377 msg="Failed to update S3 export policy {0}. Error: {1}".format( 

378 module.params["name"], get_error_message(res) 

379 ) 

380 ) 

381 

382 module.exit_json(changed=changed) 

383 

384 

385def main(): 

386 argument_spec = purefb_argument_spec() 

387 argument_spec.update( 

388 dict( 

389 state=dict(type="str", default="present", choices=["absent", "present"]), 

390 name=dict(type="str", required=True), 

391 enabled=dict(type="bool"), 

392 rename=dict(type="str"), 

393 rules=dict( 

394 type="list", 

395 elements="dict", 

396 options=dict( 

397 name=dict(type="str", required=True), 

398 actions=dict(type="list", elements="str"), 

399 effect=dict(type="str", choices=["allow", "deny"]), 

400 resources=dict(type="list", elements="str"), 

401 ), 

402 ), 

403 context=dict(type="str", default=""), 

404 ) 

405 ) 

406 

407 module = AnsibleModule(argument_spec, supports_check_mode=True) 

408 

409 if not HAS_PYPURECLIENT: 

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

411 

412 blade = get_system(module) 

413 api_version = get_rest_api_version(blade) 

414 if LooseVersion(MIN_REQUIRED_API_VERSION) > LooseVersion(api_version): 

415 module.fail_json( 

416 msg="FlashBlade REST version not supported. " 

417 "Minimum version required: {0}".format(MIN_REQUIRED_API_VERSION) 

418 ) 

419 

420 if not module.params["context"]: 

421 # Only default the context to the local array name when this array is a 

422 # member of a fleet. A standalone array rejects any fleet context 

423 # ("Cannot find array in fleet: not in a fleet"), so leave context 

424 # unset - no context_names is then sent. 

425 fleet_res = blade.get_fleets() 

426 if fleet_res.status_code == 200 and list(fleet_res.items): 

427 module.params["context"] = list(blade.get_arrays().items)[0].name 

428 

429 state = module.params["state"] 

430 policy = _get_policy(module, blade) 

431 

432 if module.params["rename"] and policy is None: 

433 # The rename may already have happened on an earlier run: if a policy 

434 # with the new name exists, manage it under that name and treat the 

435 # rename as done, so a repeated rename task is idempotent. 

436 renamed = _get_policy_by_name(module, blade, module.params["rename"]) 

437 if renamed is None: 

438 module.fail_json( 

439 msg="Cannot rename S3 export policy {0}: policy does not exist.".format( 

440 module.params["name"] 

441 ) 

442 ) 

443 module.params["name"] = module.params["rename"] 

444 module.params["rename"] = None 

445 policy = renamed 

446 

447 if state == "present" and policy is None: 

448 create_policy(module, blade) 

449 elif state == "present" and policy is not None: 

450 update_policy(module, blade, policy) 

451 elif state == "absent" and policy is not None: 

452 delete_policy(module, blade) 

453 

454 module.exit_json(changed=False) 

455 

456 

457if __name__ == "__main__": 

458 main()