diff --git a/changelogs/fragments/s3_bucket-object-retention.yml b/changelogs/fragments/s3_bucket-object-retention.yml new file mode 100644 index 00000000000..4989db9857b --- /dev/null +++ b/changelogs/fragments/s3_bucket-object-retention.yml @@ -0,0 +1,2 @@ +minor_changes: + - s3_bucket - Add ``object_lock_default_retention`` to set Object Lock default retention configuration for S3 buckets (https://github.com/ansible-collections/amazon.aws/pull/2062). diff --git a/plugins/modules/s3_bucket.py b/plugins/modules/s3_bucket.py index edef6afdcfd..53a2c0d9b9d 100644 --- a/plugins/modules/s3_bucket.py +++ b/plugins/modules/s3_bucket.py @@ -172,6 +172,30 @@ type: bool default: false version_added: 8.1.0 + object_lock_default_retention: + description: + - Default Object Lock configuration that will be applied by default to + every new object placed in the specified bucket. + - O(object_lock_enabled) must be included and set to V(True). + - Object lock retention policy can't be removed. + suboptions: + mode: + description: Type of retention modes. + choices: [ "GOVERNANCE", "COMPLIANCE"] + required: true + type: str + days: + description: + - The number of days that you want to specify for the default retention period. + - Mutually exclusive with O(object_lock_default_retention.years). + type: int + years: + description: + - The number of years that you want to specify for the default retention period. + - Mutually exclusive with O(object_lock_default_retention.days). + type: int + type: dict + version_added: 8.1.0 extends_documentation_fragment: - amazon.aws.common.modules @@ -298,6 +322,15 @@ name: mys3bucket state: present accelerate_enabled: true + +# Default Object Lock retention +- amazon.aws.s3_bucket: + name: mys3bucket + state: present + object_lock_enabled: true + object_lock_default_retention: + mode: governance + days: 1 """ RETURN = r""" @@ -318,6 +351,15 @@ type: str returned: when O(state=present) sample: "BucketOwnerPreferred" +object_lock_default_retention: + description: S3 bucket's object lock retention policy. + type: dict + returned: when O(state=present) + sample: { + "Days": 1, + "Mode": "GOVERNANCE", + "Years": 0, + } policy: description: S3 bucket's policy. type: dict @@ -934,6 +976,56 @@ def handle_bucket_accelerate(s3_client, module: AnsibleAWSModule, name: str) -> return accelerate_enabled_changed, accelerate_enabled_result +def handle_bucket_object_lock_retention(s3_client, module: AnsibleAWSModule, name: str) -> tuple[bool, dict]: + """ + Manage object lock retention configuration for an S3 bucket. + Parameters: + s3_client (boto3.client): The Boto3 S3 client object. + module (AnsibleAWSModule): The Ansible module object. + name (str): The name of the bucket to handle object lock for. + Returns: + A tuple containing a boolean indicating whether the bucket object lock + retention configuration was changed and a dictionary containing the change. + """ + object_lock_enabled = module.params.get("object_lock_enabled") + object_lock_default_retention = module.params.get("object_lock_default_retention") + object_lock_default_retention_result = {} + object_lock_default_retention_changed = False + try: + if object_lock_enabled: + object_lock_configuration_status = get_object_lock_configuration(s3_client, name) + else: + object_lock_configuration_status = {} + except is_boto3_error_code(["NotImplemented", "XNotImplemented"]) as e: + if object_lock_default_retention is not None: + module.fail_json_aws(e, msg="Fetching bucket object lock default retention is not supported") + except is_boto3_error_code("AccessDenied") as e: # pylint: disable=duplicate-except + if object_lock_default_retention is not None: + module.fail_json_aws(e, msg="Permission denied fetching object lock default retention 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 default retention state") + else: + if not object_lock_default_retention and object_lock_configuration_status != {}: + module.fail_json(msg="Removing object lock default retention is not supported") + if object_lock_default_retention is not None: + conf = snake_dict_to_camel_dict(object_lock_default_retention, capitalize_first=True) + conf = {k: v for k, v in conf.items() if v} # remove keys with None value + try: + if object_lock_default_retention and object_lock_configuration_status != conf: + put_object_lock_configuration(s3_client, name, conf) + object_lock_default_retention_changed = True + object_lock_default_retention_result = object_lock_default_retention + else: + object_lock_default_retention_result = object_lock_default_retention + except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e: + module.fail_json_aws(e, msg="Failed to update bucket object lock default retention") + + return object_lock_default_retention_changed, object_lock_default_retention_result + + def create_or_update_bucket(s3_client, module: AnsibleAWSModule): """ Create or update an S3 bucket along with its associated configurations. @@ -1014,6 +1106,12 @@ def create_or_update_bucket(s3_client, module: AnsibleAWSModule): bucket_accelerate_changed, bucket_accelerate_result = handle_bucket_accelerate(s3_client, module, name) result["accelerate_enabled"] = bucket_accelerate_result + # -- Object Lock Default Retention + bucket_object_lock_retention_changed, bucket_object_lock_retention_result = handle_bucket_object_lock_retention( + s3_client, module, name + ) + result["object_lock_default_retention"] = bucket_object_lock_retention_result + # Module exit changed = ( changed @@ -1026,6 +1124,7 @@ def create_or_update_bucket(s3_client, module: AnsibleAWSModule): or bucket_ownership_changed or bucket_acl_changed or bucket_accelerate_changed + or bucket_object_lock_retention_changed ) module.exit_json(changed=changed, name=name, **result) @@ -1080,6 +1179,36 @@ def create_bucket(s3_client, bucket_name: str, location: str, object_lock_enable return False +@AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=["NoSuchBucket", "OperationAborted"]) +def get_object_lock_configuration(s3_client, bucket_name): + """ + Get the object lock default retention configuration for an S3 bucket. + Parameters: + s3_client (boto3.client): The Boto3 S3 client object. + bucket_name (str): The name of the S3 bucket. + Returns: + Object lock default retention configuration dictionary. + """ + result = s3_client.get_object_lock_configuration(Bucket=bucket_name) + return result.get("ObjectLockConfiguration", {}).get("Rule", {}).get("DefaultRetention", {}) + + +@AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=["NoSuchBucket", "OperationAborted"]) +def put_object_lock_configuration(s3_client, bucket_name, object_lock_default_retention): + """ + Set tags for an S3 bucket. + Parameters: + s3_client (boto3.client): The Boto3 S3 client object. + bucket_name (str): The name of the S3 bucket. + object_lock_default_retention (dict): A dictionary containing the object + lock default retention configuration to be set on the bucket. + Returns: + None + """ + conf = {"ObjectLockEnabled": "Enabled", "Rule": {"DefaultRetention": object_lock_default_retention}} + s3_client.put_object_lock_configuration(Bucket=bucket_name, ObjectLockConfiguration=conf) + + @AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=["NoSuchBucket", "OperationAborted"]) def put_bucket_accelerate_configuration(s3_client, bucket_name): """ @@ -1882,10 +2011,21 @@ def main(): dualstack=dict(default=False, type="bool"), accelerate_enabled=dict(default=False, type="bool"), object_lock_enabled=dict(type="bool"), + object_lock_default_retention=dict( + type="dict", + options=dict( + mode=dict(type="str", choices=["GOVERNANCE", "COMPLIANCE"], required=True), + years=dict(type="int"), + days=dict(type="int"), + ), + mutually_exclusive=[("days", "years")], + required_one_of=[("days", "years")], + ), ) required_by = dict( encryption_key_id=("encryption",), + object_lock_default_retention="object_lock_enabled", ) mutually_exclusive = [ diff --git a/tests/integration/targets/s3_bucket/inventory b/tests/integration/targets/s3_bucket/inventory index 945118fc2ad..30c203beac2 100644 --- a/tests/integration/targets/s3_bucket/inventory +++ b/tests/integration/targets/s3_bucket/inventory @@ -12,6 +12,7 @@ public_access acl object_lock accelerate +default_retention [all:vars] ansible_connection=local diff --git a/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/default_retention.yml b/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/default_retention.yml new file mode 100644 index 00000000000..638572cf6f0 --- /dev/null +++ b/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/default_retention.yml @@ -0,0 +1,136 @@ +--- +- module_defaults: + group/aws: + access_key: "{{ aws_access_key }}" + secret_key: "{{ aws_secret_key }}" + session_token: "{{ security_token | default(omit) }}" + region: "{{ aws_region }}" + block: + - ansible.builtin.set_fact: + local_bucket_name: "{{ bucket_name | hash('md5')}}-default-retention" + + # ============================================================ + + - name: Create a simple bucket with object lock + amazon.aws.s3_bucket: + name: "{{ local_bucket_name }}" + state: present + object_lock_enabled: true + register: output + + - ansible.builtin.assert: + that: + - output.changed + - output.object_lock_enabled + + - name: Add object lock default retention + amazon.aws.s3_bucket: + name: "{{ local_bucket_name }}" + state: present + object_lock_enabled: true + object_lock_default_retention: + mode: GOVERNANCE + days: 1 + register: output + + - ansible.builtin.assert: + that: + - output.changed + - output.object_lock_enabled + - output.object_lock_default_retention + + - name: Delete test s3 bucket + amazon.aws.s3_bucket: + name: "{{ local_bucket_name }}" + state: absent + register: output + + - ansible.builtin.assert: + that: + - output.changed + + # ============================================================ + + - name: Create a bucket with object lock and default retention enabled + amazon.aws.s3_bucket: + name: "{{ local_bucket_name }}-2" + state: present + object_lock_enabled: true + object_lock_default_retention: + mode: GOVERNANCE + days: 1 + register: output + + - ansible.builtin.assert: + that: + - output.changed + - output.object_lock_enabled + - output.object_lock_default_retention + + - name: Touch bucket with object lock enabled (idempotency) + amazon.aws.s3_bucket: + name: "{{ local_bucket_name }}-2" + state: present + object_lock_enabled: true + object_lock_default_retention: + mode: GOVERNANCE + days: 1 + register: output + + - ansible.builtin.assert: + that: + - not output.changed + - output.object_lock_enabled + - output.object_lock_default_retention + + - name: Change bucket with object lock default retention enabled + amazon.aws.s3_bucket: + name: "{{ local_bucket_name }}-2" + state: present + object_lock_enabled: true + object_lock_default_retention: + mode: GOVERNANCE + days: 2 + register: output + + - ansible.builtin.assert: + that: + - output.changed + - output.object_lock_enabled + - output.object_lock_default_retention + + - name: Disable object lock default retention + amazon.aws.s3_bucket: + name: "{{ local_bucket_name }}-2" + state: present + object_lock_enabled: true + register: output + ignore_errors: true + + - ansible.builtin.assert: + that: + - not output.changed + + - name: Delete test s3 bucket + amazon.aws.s3_bucket: + name: "{{ local_bucket_name }}-2" + state: absent + register: output + + - ansible.builtin.assert: + that: + - output.changed + + # ============================================================ + always: + - name: Ensure all buckets are deleted + amazon.aws.s3_bucket: + name: "{{ local_bucket_name }}" + state: absent + ignore_errors: true + + - name: Ensure all buckets are deleted + amazon.aws.s3_bucket: + name: "{{ local_bucket_name }}-2" + state: absent + ignore_errors: true