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
« 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
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_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"""
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
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
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
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"""
141RETURN = r"""
142"""
144HAS_PYPURECLIENT = True
145try:
146 from pypureclient.flashblade import (
147 S3ExportPolicyPost,
148 S3ExportPolicyPatch,
149 S3ExportPolicyRulePost,
150 )
151except ImportError:
152 HAS_PYPURECLIENT = False
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)
167MIN_REQUIRED_API_VERSION = "2.20"
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 {}
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 }
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 }
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
206def _get_policy(module, blade):
207 """Return the existing policy object or None."""
208 return _get_policy_by_name(module, blade, module.params["name"])
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)
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}
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 ]
237 if not (to_add or to_remove or to_modify):
238 return False
240 if module.check_mode:
241 return True
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 )
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 )
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 )
294 return True
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)
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)
344def update_policy(module, blade, policy):
345 """Update an existing S3 export policy."""
346 changed = False
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
355 patch_kwargs = {}
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"]
362 if module.params["rename"] and module.params["rename"] != getattr(
363 policy, "name", None
364 ):
365 patch_kwargs["name"] = module.params["rename"]
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 )
382 module.exit_json(changed=changed)
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 )
407 module = AnsibleModule(argument_spec, supports_check_mode=True)
409 if not HAS_PYPURECLIENT:
410 module.fail_json(msg="py-pure-client sdk is required for this module")
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 )
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
429 state = module.params["state"]
430 policy = _get_policy(module, blade)
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
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)
454 module.exit_json(changed=False)
457if __name__ == "__main__":
458 main()