Repository: ansible-collections/amazon.aws

Issue:
Add parameter ObjectLockEnabledForBucket to module amazon.aws.s3_bucket
### Summary

I would like to create s3 bucket with Object Lock Enabled. As of now, AWS doesn't support updating this parameter after the bucket was created unless one reaches AWS support.

### Issue Type

Feature Idea

### Component Name

s3_bucket

### Additional Information

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)

```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/s3_bucket.py`](https://github.com/['ansible-collections/amazon.aws', 'ansible-collections/community.aws', 'ansible-collections/community.vmware']/blob/main/plugins/modules/s3_bucket.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @abikouo @jillr @s-hertel @tremble
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@lmilbaum Thank you for this feature idea. Would you be willing to take the time and open a pull request for this new option?
> @lmilbaum Thank you for this feature idea. Would you be willing to take the time and open a pull request for this new option?

Will try :-)

Resolving patch:
diff --git a/plugins/modules/s3_bucket.py b/plugins/modules/s3_bucket.py
--- a/plugins/modules/s3_bucket.py
+++ b/plugins/modules/s3_bucket.py
@@ -134,6 +134,12 @@
     choices: [ 'BucketOwnerEnforced', 'BucketOwnerPreferred', 'ObjectWriter' ]
     type: str
     version_added: 2.0.0
+  object_lock_enabled:
+    description:
+      - Whether S3 Object Lock to be enabled.
+      - Defaults to C(False) when creating a new bucket.
+    type: bool
+    version_added: 5.3.0
   delete_object_ownership:
     description:
       - Delete bucket's ownership controls.
@@ -385,6 +391,7 @@ def create_or_update_bucket(s3_client, module):
     delete_public_access = module.params.get("delete_public_access")
     delete_object_ownership = module.params.get("delete_object_ownership")
     object_ownership = module.params.get("object_ownership")
+    object_lock_enabled = module.params.get("object_lock_enabled")
     acl = module.params.get("acl")
     # default to US Standard region,
     # note: module.region will also try to pull a default out of the boto3 configs.
@@ -402,7 +409,7 @@ def create_or_update_bucket(s3_client, module):
 
     if not bucket_is_present:
         try:
-            bucket_changed = create_bucket(s3_client, name, location)
+            bucket_changed = create_bucket(s3_client, name, location, object_lock_enabled)
             s3_client.get_waiter('bucket_exists').wait(Bucket=name)
             changed = changed or bucket_changed
         except botocore.exceptions.WaiterError as e:
@@ -650,6 +657,32 @@ def create_or_update_bucket(s3_client, module):
         except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:  # pylint: disable=duplicate-except
             module.fail_json_aws(e, msg="Failed to update bucket ACL")
 
+    # -- Object Lock
+    try:
+        object_lock_status = get_bucket_object_lock_enabled(s3_client, name)
+        result["object_lock_enabled"] = object_lock_status
+    except is_boto3_error_code(["NotImplemented", "XNotImplemented"]) as e:
+        if object_lock_enabled is not None:
+            module.fail_json(msg="Fetching bucket object lock state is not supported")
+    except is_boto3_error_code("ObjectLockConfigurationNotFoundError"):  # pylint: disable=duplicate-except
+        if object_lock_enabled:
+            module.fail_json(msg="Enabling object lock for existing buckets is not supported")
+        result["object_lock_enabled"] = False
+    except is_boto3_error_code("AccessDenied") as e:  # pylint: disable=duplicate-except
+        if object_lock_enabled is not None:
+            module.fail_json(msg="Permission denied fetching object lock state for bucket")
+    except (
+        botocore.exceptions.BotoCoreError,
+        botocore.exceptions.ClientError,
+    ) as e:  # pylint: disable=duplicate-except
+        module.fail_json_aws(e, msg="Failed to fetch bucket object lock state")
+    else:
+        if object_lock_status is not None:
+            if not object_lock_enabled and object_lock_status:
+                module.fail_json(msg="Disabling object lock for existing buckets is not supported")
+            if object_lock_enabled and not object_lock_status:
+                module.fail_json(msg="Enabling object lock for existing buckets is not supported")
+
     # Module exit
     module.exit_json(changed=changed, name=name, **result)
 
@@ -664,15 +697,22 @@ def bucket_exists(s3_client, bucket_name):
 
 
 @AWSRetry.exponential_backoff(max_delay=120)
-def create_bucket(s3_client, bucket_name, location):
+def create_bucket(s3_client, bucket_name, location, object_lock_enabled=False):
     try:
+        params = {"Bucket": bucket_name}
+
         configuration = {}
         if location not in ('us-east-1', None):
             configuration['LocationConstraint'] = location
-        if len(configuration) > 0:
-            s3_client.create_bucket(Bucket=bucket_name, CreateBucketConfiguration=configuration)
-        else:
-            s3_client.create_bucket(Bucket=bucket_name)
+
+        if configuration:
+            params["CreateBucketConfiguration"] = configuration
+
+        if object_lock_enabled is not None:
+            params["ObjectLockEnabledForBucket"] = object_lock_enabled
+
+        s3_client.create_bucket(**params)
+
         return True
     except is_boto3_error_code('BucketAlreadyOwnedByYou'):
         # We should never get here since we check the bucket presence before calling the create_or_update_bucket
@@ -728,6 +768,12 @@ def put_bucket_versioning(s3_client, bucket_name, required_versioning):
     s3_client.put_bucket_versioning(Bucket=bucket_name, VersioningConfiguration={'Status': required_versioning})
 
 
+@AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=["NoSuchBucket", "OperationAborted"])
+def get_bucket_object_lock_enabled(s3_client, bucket_name):
+    object_lock_configuration = s3_client.get_object_lock_configuration(Bucket=bucket_name)
+    return object_lock_configuration["ObjectLockConfiguration"]["ObjectLockEnabled"] == "Enabled"
+
+
 @AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=['NoSuchBucket', 'OperationAborted'])
 def get_bucket_encryption(s3_client, bucket_name):
     try:
@@ -1086,6 +1132,7 @@ def main():
         acl=dict(type='str', choices=['private', 'public-read', 'public-read-write', 'authenticated-read']),
         validate_bucket_name=dict(type='bool', default=True),
         dualstack=dict(default=False, type="bool"),
+        object_lock_enabled=dict(type="bool"),
     )
 
     required_by = dict(


Test patch:
diff --git a/tests/integration/targets/s3_bucket/inventory b/tests/integration/targets/s3_bucket/inventory
--- a/tests/integration/targets/s3_bucket/inventory
+++ b/tests/integration/targets/s3_bucket/inventory
@@ -10,6 +10,7 @@ encryption_bucket_key
 encryption_sse
 public_access
 acl
+object_lock
 
 [all:vars]
 ansible_connection=local
diff --git a/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/object_lock.yml b/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/object_lock.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/object_lock.yml
@@ -0,0 +1,131 @@
+---
+- module_defaults:
+    group/aws:
+      aws_access_key: "{{ aws_access_key }}"
+      aws_secret_key: "{{ aws_secret_key }}"
+      security_token: "{{ security_token | default(omit) }}"
+      region: "{{ aws_region }}"
+  block:
+    - set_fact:
+        local_bucket_name: "{{ bucket_name | hash('md5')}}-objectlock"
+
+    # ============================================================
+
+    - name: 'Create a simple bucket'
+      s3_bucket:
+        name: '{{ local_bucket_name }}'
+        state: present
+      register: output
+
+    - assert:
+        that:
+          - output.changed
+          - not output.object_lock_enabled
+
+    - name: 'Re-disable object lock (idempotency)'
+      s3_bucket:
+        name: '{{ local_bucket_name }}'
+        state: present
+        object_lock_enabled: false
+      register: output
+
+    - assert:
+        that:
+          - not output.changed
+          - not output.object_lock_enabled
+
+    - name: 'Enable object lock'
+      s3_bucket:
+        name: '{{ local_bucket_name }}'
+        state: present
+        object_lock_enabled: true
+      register: output
+      ignore_errors: true
+
+    - assert:
+        that:
+          - output is failed
+
+    - name: Delete test s3 bucket
+      s3_bucket:
+        name: '{{ local_bucket_name }}'
+        state: absent
+      register: output
+
+    - assert:
+        that:
+          - output.changed
+
+    # ============================================================
+
+    - name: 'Create a bucket with object lock enabled'
+      s3_bucket:
+        name: '{{ local_bucket_name }}-2'
+        state: present
+        object_lock_enabled: true
+      register: output
+
+    - assert:
+        that:
+          - output.changed
+          - output.object_lock_enabled
+
+    - name: 'Disable object lock'
+      s3_bucket:
+        name: '{{ local_bucket_name }}-2'
+        state: present
+        object_lock_enabled: false
+      register: output
+      ignore_errors: true
+
+    - assert:
+        that:
+          - output is failed
+
+    - name: 'Re-Enable object lock (idempotency)'
+      s3_bucket:
+        name: '{{ local_bucket_name }}-2'
+        state: present
+        object_lock_enabled: true
+      register: output
+
+    - assert:
+        that:
+          - not output.changed
+          - output.object_lock_enabled
+
+    - name: 'Touch bucket with object lock enabled (idempotency)'
+      s3_bucket:
+        name: '{{ local_bucket_name }}-2'
+        state: present
+        object_lock_enabled: true
+      register: output
+
+    - assert:
+        that:
+          - not output.changed
+          - output.object_lock_enabled
+
+    - name: Delete test s3 bucket
+      s3_bucket:
+        name: '{{ local_bucket_name }}-2'
+        state: absent
+      register: output
+
+    - assert:
+        that:
+          - output.changed
+
+  # ============================================================
+  always:
+    - name: Ensure all buckets are deleted
+      s3_bucket:
+        name: '{{ local_bucket_name }}'
+        state: absent
+      ignore_errors: yes
+
+    - name: Ensure all buckets are deleted
+      s3_bucket:
+        name: '{{ local_bucket_name }}-2'
+        state: absent
+      ignore_errors: yes