Repository: ansible-collections/amazon.aws

Issue:
`amazon.aws.s3_object` copy mode repeatedly updates objects that were uploaded in multiple parts
### Summary

Using `amazon.aws.s3_object` copy mode with objects that were uploaded in multiple parts (e.g. as happens with uploads via the web UI) results in the objects being copied every time the module is used - including when the corresponding objects exists in the source bucket with the same content. 

I suspect that the issue is due to how the Etag is generated for the source vs how it gets generated for the copy. 

### Issue Type

Bug Report

### Component Name

s3_object

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.16.4]
  config file = <redacted>
  configured module search path = <redacted>
  ansible python module location = <redacted>
  ansible collection location = <redacted>
  executable location = <redacted>
  python version = 3.12.1 (main, Mar  5 2024, 15:57:44) [Clang 15.0.0 (clang-1500.1.0.2.5)] (<redacted>)
  jinja version = 3.1.3
  libyaml = True
```


### Collection Versions

```console (paste below)
$ ansible-galaxy collection list

# <redacted>/ansible_collections
Collection                               Version
---------------------------------------- -------
amazon.aws                               7.4.0  

# <redacted>/ansible_collections
Collection                               Version
---------------------------------------- -------
amazon.aws                               7.3.0  
ansible.netcommon                        5.3.0  
ansible.posix                            1.5.4  
ansible.utils                            2.12.0 
ansible.windows                          2.2.0  
arista.eos                               6.2.2  
awx.awx                                  23.8.1 
azure.azcollection                       1.19.0 
check_point.mgmt                         5.2.2  
chocolatey.chocolatey                    1.5.1  
cisco.aci                                2.8.0  
cisco.asa                                4.0.3  
cisco.dnac                               6.11.0 
cisco.intersight                         2.0.7  
cisco.ios                                5.3.0  
cisco.iosxr                              6.1.1  
cisco.ise                                2.7.0  
cisco.meraki                             2.17.2 
cisco.mso                                2.5.0  
cisco.nxos                               5.3.0  
cisco.ucs                                1.10.0 
cloud.common                             2.1.4  
cloudscale_ch.cloud                      2.3.1  
community.aws                            7.1.0  
community.azure                          2.0.0  
community.ciscosmb                       1.0.7  
community.crypto                         2.18.0 
community.digitalocean                   1.26.0 
community.dns                            2.8.1  
community.docker                         3.8.0  
community.general                        8.4.0  
community.grafana                        1.8.0  
community.hashi_vault                    6.1.0  
community.hrobot                         1.9.0  
community.library_inventory_filtering_v1 1.0.0  
community.libvirt                        1.3.0  
community.mongodb                        1.7.1  
community.mysql                          3.9.0  
community.network                        5.0.2  
community.okd                            2.3.0  
community.postgresql                     3.4.0  
community.proxysql                       1.5.1  
community.rabbitmq                       1.2.3  
community.routeros                       2.13.0 
community.sap                            2.0.0  
community.sap_libs                       1.4.2  
community.sops                           1.6.7  
community.vmware                         4.2.0  
community.windows                        2.1.0  
community.zabbix                         2.3.1  
containers.podman                        1.12.0 
cyberark.conjur                          1.2.2  
cyberark.pas                             1.0.25 
dellemc.enterprise_sonic                 2.4.0  
dellemc.openmanage                       8.7.0  
dellemc.powerflex                        2.1.0  
dellemc.unity                            1.7.1  
f5networks.f5_modules                    1.28.0 
fortinet.fortimanager                    2.4.0  
fortinet.fortios                         2.3.5  
frr.frr                                  2.0.2  
gluster.gluster                          1.0.2  
google.cloud                             1.3.0  
grafana.grafana                          2.2.5  
hetzner.hcloud                           2.5.0  
hpe.nimble                               1.1.4  
ibm.qradar                               2.1.0  
ibm.spectrum_virtualize                  2.0.0  
ibm.storage_virtualize                   2.2.0  
infinidat.infinibox                      1.4.3  
infoblox.nios_modules                    1.6.1  
inspur.ispim                             2.2.0  
inspur.sm                                2.3.0  
junipernetworks.junos                    5.3.1  
kubernetes.core                          2.4.1  
lowlydba.sqlserver                       2.3.1  
microsoft.ad                             1.4.1  
netapp.aws                               21.7.1 
netapp.azure                             21.10.1
netapp.cloudmanager                      21.22.1
netapp.elementsw                         21.7.0 
netapp.ontap                             22.10.0
netapp.storagegrid                       21.12.0
netapp.um_info                           21.8.1 
netapp_eseries.santricity                1.4.0  
netbox.netbox                            3.17.0 
ngine_io.cloudstack                      2.3.0  
ngine_io.exoscale                        1.1.0  
openstack.cloud                          2.2.0  
openvswitch.openvswitch                  2.1.1  
ovirt.ovirt                              3.2.0  
purestorage.flasharray                   1.26.0 
purestorage.flashblade                   1.15.0 
purestorage.fusion                       1.6.1  
sensu.sensu_go                           1.14.0 
splunk.es                                2.1.2  
t_systems_mms.icinga_director            2.0.1  
telekom_mms.icinga_director              1.35.0 
theforeman.foreman                       3.15.0 
vmware.vmware_rest                       2.3.1  
vultr.cloud                              1.12.1 
vyos.vyos                                4.1.0  
wti.remote                               1.0.5  
```


### AWS SDK versions

```console (paste below)
$ pip show boto boto3 botocore
WARNING: Package(s) not found: boto
Name: boto3
Version: 1.34.59
Summary: The AWS SDK for Python
Home-page: https://github.com/boto/boto3
Author: Amazon Web Services
Author-email: 
License: Apache License 2.0
Location: <redacted>/.venv/lib/python3.12/site-packages
Requires: botocore, jmespath, s3transfer
Required-by: 
---
Name: botocore
Version: 1.34.59
Summary: Low-level, data-driven core of boto 3.
Home-page: https://github.com/boto/botocore
Author: Amazon Web Services
Author-email: 
License: Apache License 2.0
Location: <redacted>/.venv/lib/python3.12/site-packages
Requires: jmespath, python-dateutil, urllib3
Required-by: boto3, s3transfer
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed
CONFIG_FILE() = <redacted>/ansible.cfg
DEFAULT_INVENTORY_PLUGIN_PATH(<redacted>/ansible.cfg) = ['<redacted>/ansible/plugins/inventory']
DUPLICATE_YAML_DICT_KEY(<redacted>/ansible/ansible.cfg) = ignore
INVENTORY_IGNORE_EXTS(<redacted>/ansible/ansible.cfg) = ["{{(REJECT_EXTS + ('.orig'", '.cfg', "'.retry'))}}"]
```


### OS / Environment

MacOS 14.3 (23D56)

### Steps to Reproduce

1. Create a file, where `size(file) < 5GB` (the copy limit), e.g. `head -c 64MB < /dev/zero > 64MB-zero.bin`
2. Perform a multipart upload of the file to an S3 bucket. If you use the web UI, that appears to upload it in 16MB parts.
3. Observe the Etag, e.g. for the 64MB zero file, I have `05c46bd967d2892191397a04e43821b9-4`. [According to Amazon](https://aws.amazon.com/blogs/media/building-scalable-checksums/): 
> Amazon S3 calculates the MD5 digest of each individual part. MD5 digests are used to determine the ETag for the final object. Amazon S3 concatenates the bytes for the MD5 digests together and then calculates the MD5 digest of these concatenated values. The final step in creating the ETag is when Amazon S3 adds a dash with the total number of parts to the end.
4. Use `amazon.aws.s3_object` to copy the file to another bucket.
```yaml (paste below)
- name: copy file that was uploaded in parts
  amazon.aws.s3_object:
    bucket: target-bucket
    mode: copy
    copy_src:
      bucket: source-bucket
      prefix: 64MB-zero.bin
```
5. Repeat the above and observe the task shows change each time (i.e. it's not idempotent). The timestamp on the target object is updated but the contents are not, suggesting a copy operation occurred needlessly.
6. Observe the etag on the target bucket does not match that of the source, namely it is not in multi-part form, e.g. the zeros file has etag: `e78585b8bfda6036cfd818710a210f23` (MD5 of 64MB of zeros).

### Expected Results

Module is idempotent, and does not repeatidly copy identical files.

### Actual Results

Copy operation performed on files uploaded in multipart, regardless of their state in the target bucket.

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:

Resolving patch:
diff --git a/plugins/module_utils/s3.py b/plugins/module_utils/s3.py
--- a/plugins/module_utils/s3.py
+++ b/plugins/module_utils/s3.py
@@ -58,7 +58,7 @@ def calculate_etag(module, filename, etag, s3, bucket, obj, version=None):
     if not HAS_MD5:
         return None
 
-    if "-" in etag:
+    if etag is not None and "-" in etag:
         # Multi-part ETag; a hash of the hashes of each part.
         parts = int(etag[1:-1].split("-")[1])
         try:
@@ -73,7 +73,7 @@ def calculate_etag_content(module, content, etag, s3, bucket, obj, version=None)
     if not HAS_MD5:
         return None
 
-    if "-" in etag:
+    if etag is not None and "-" in etag:
         # Multi-part ETag; a hash of the hashes of each part.
         parts = int(etag[1:-1].split("-")[1])
         try:
diff --git a/plugins/modules/s3_object.py b/plugins/modules/s3_object.py
--- a/plugins/modules/s3_object.py
+++ b/plugins/modules/s3_object.py
@@ -473,7 +473,7 @@ def key_check(module, s3, bucket, obj, version=None, validate=True):
 
 
 def etag_compare(module, s3, bucket, obj, version=None, local_file=None, content=None):
-    s3_etag = get_etag(s3, bucket, obj, version=version)
+    s3_etag = _head_object(s3, bucket, obj, version=version).get("ETag")
     if local_file is not None:
         local_etag = calculate_etag(module, local_file, s3_etag, s3, bucket, obj, version)
     else:
@@ -481,27 +481,49 @@ def etag_compare(module, s3, bucket, obj, version=None, local_file=None, content
     return s3_etag == local_etag
 
 
-def get_etag(s3, bucket, obj, version=None):
+def _head_object(s3, bucket, obj, version=None):
     try:
         if version:
             key_check = s3.head_object(aws_retry=True, Bucket=bucket, Key=obj, VersionId=version)
         else:
             key_check = s3.head_object(aws_retry=True, Bucket=bucket, Key=obj)
         if not key_check:
-            return None
-        return key_check["ETag"]
+            return {}
+        key_check.pop("ResponseMetadata")
+        return key_check
     except is_boto3_error_code("404"):
-        return None
+        return {}
+
+
+def _get_object_content(module, s3, bucket, obj, version=None):
+    try:
+        if version:
+            contents = s3.get_object(aws_retry=True, Bucket=bucket, Key=obj, VersionId=version)["Body"].read()
+        else:
+            contents = s3.get_object(aws_retry=True, Bucket=bucket, Key=obj)["Body"].read()
+        return contents
+    except is_boto3_error_code(["404", "403"]) as e:
+        # AccessDenied errors may be triggered if 1) file does not exist or 2) file exists but
+        # user does not have the s3:GetObject permission.
+        module.fail_json_aws(e, msg=f"Could not find the key {obj}.")
+    except is_boto3_error_message("require AWS Signature Version 4"):  # pylint: disable=duplicate-except
+        raise Sigv4Required()
+    except is_boto3_error_code("InvalidArgument") as e:  # pylint: disable=duplicate-except
+        module.fail_json_aws(e, msg=f"Could not find the key {obj}.")
+    except (
+        botocore.exceptions.BotoCoreError,
+        botocore.exceptions.ClientError,
+        boto3.exceptions.Boto3Error,
+    ) as e:  # pylint: disable=duplicate-except
+        raise S3ObjectFailure(f"Could not find the key {obj}.", e)
 
 
 def get_s3_last_modified_timestamp(s3, bucket, obj, version=None):
-    if version:
-        key_check = s3.head_object(aws_retry=True, Bucket=bucket, Key=obj, VersionId=version)
-    else:
-        key_check = s3.head_object(aws_retry=True, Bucket=bucket, Key=obj)
-    if not key_check:
-        return None
-    return key_check["LastModified"].timestamp()
+    last_modified = None
+    obj_info = _head_object(s3, bucket, obj, version)
+    if obj_info:
+        last_modified = obj_info["LastModified"].timestamp()
+    return last_modified
 
 
 def is_local_object_latest(s3, bucket, obj, version=None, local_file=None):
@@ -763,29 +785,7 @@ def download_s3file(module, s3, bucket, obj, dest, retries, version=None):
         module.exit_json(msg="GET operation skipped - running in check mode", changed=True)
     # retries is the number of loops; range/xrange needs to be one
     # more to get that count of loops.
-    try:
-        # Note: Something of a permissions related hack
-        # get_object returns the HEAD information, plus a *stream* which can be read.
-        # because the stream's dropped on the floor, we never pull the data and this is the
-        # functional equivalent of calling get_head which still relying on the 'GET' permission
-        if version:
-            s3.get_object(aws_retry=True, Bucket=bucket, Key=obj, VersionId=version)
-        else:
-            s3.get_object(aws_retry=True, Bucket=bucket, Key=obj)
-    except is_boto3_error_code(["404", "403"]) as e:
-        # AccessDenied errors may be triggered if 1) file does not exist or 2) file exists but
-        # user does not have the s3:GetObject permission. 404 errors are handled by download_file().
-        module.fail_json_aws(e, msg=f"Could not find the key {obj}.")
-    except is_boto3_error_message("require AWS Signature Version 4"):  # pylint: disable=duplicate-except
-        raise Sigv4Required()
-    except is_boto3_error_code("InvalidArgument") as e:  # pylint: disable=duplicate-except
-        module.fail_json_aws(e, msg=f"Could not find the key {obj}.")
-    except (
-        botocore.exceptions.BotoCoreError,
-        botocore.exceptions.ClientError,
-        boto3.exceptions.Boto3Error,
-    ) as e:  # pylint: disable=duplicate-except
-        raise S3ObjectFailure(f"Could not find the key {obj}.", e)
+    _get_object_content(module, s3, bucket, obj, version)
 
     optional_kwargs = {"ExtraArgs": {"VersionId": version}} if version else {}
     for x in range(0, retries + 1):
@@ -811,27 +811,8 @@ def download_s3file(module, s3, bucket, obj, dest, retries, version=None):
 def download_s3str(module, s3, bucket, obj, version=None):
     if module.check_mode:
         module.exit_json(msg="GET operation skipped - running in check mode", changed=True)
-    try:
-        if version:
-            contents = to_native(
-                s3.get_object(aws_retry=True, Bucket=bucket, Key=obj, VersionId=version)["Body"].read()
-            )
-        else:
-            contents = to_native(s3.get_object(aws_retry=True, Bucket=bucket, Key=obj)["Body"].read())
-        module.exit_json(msg="GET operation complete", contents=contents, changed=True)
-    except is_boto3_error_message("require AWS Signature Version 4"):
-        raise Sigv4Required()
-    except is_boto3_error_code("InvalidArgument") as e:  # pylint: disable=duplicate-except
-        module.fail_json_aws(
-            e,
-            msg=f"Failed while getting contents of object {obj} as a string.",
-        )
-    except (
-        botocore.exceptions.BotoCoreError,
-        botocore.exceptions.ClientError,
-        boto3.exceptions.Boto3Error,
-    ) as e:  # pylint: disable=duplicate-except
-        raise S3ObjectFailure(f"Failed while getting contents of object {obj} as a string.", e)
+    contents = to_native(_get_object_content(module, s3, bucket, obj, version))
+    module.exit_json(msg="GET operation complete", contents=contents, changed=True)
 
 
 def get_download_url(module, s3, bucket, obj, expiry, tags=None, changed=True):
@@ -1255,6 +1236,17 @@ def check_object_tags(module, connection, bucket, obj):
     return diff
 
 
+def calculate_object_etag(module, s3, bucket, obj, head_etag, version=None):
+    etag = head_etag
+    if "-" in etag:
+        # object has been created using multipart upload, compute ETag using
+        # object content to ensure idempotency.
+        contents = _get_object_content(module, s3, bucket, obj, version)
+        # Set ETag to None, to force function to compute ETag from content
+        etag = calculate_etag_content(module, contents, None, s3, bucket, obj)
+    return etag
+
+
 def copy_object_to_bucket(module, s3, bucket, obj, encrypt, metadata, validate, src_bucket, src_obj, versionId=None):
     try:
         params = {"Bucket": bucket, "Key": obj}
@@ -1265,14 +1257,33 @@ def copy_object_to_bucket(module, s3, bucket, obj, encrypt, metadata, validate,
                 changed=False,
             )
 
-        s_etag = get_etag(s3, src_bucket, src_obj, version=versionId)
-        d_etag = get_etag(s3, bucket, obj)
-        if s_etag == d_etag:
+        s_obj_info = _head_object(s3, src_bucket, src_obj, version=versionId)
+        d_obj_info = _head_object(s3, bucket, obj)
+        do_match = True
+        diff_msg = None
+        if d_obj_info:
+            src_etag = calculate_object_etag(module, s3, src_bucket, src_obj, s_obj_info.get("ETag"), versionId)
+            dst_etag = calculate_object_etag(module, s3, bucket, obj, d_obj_info.get("ETag"))
+            if src_etag != dst_etag:
+                # Source and destination objects ETag differ
+                do_match = False
+                diff_msg = "ETag from source and destination differ"
+            if do_match and metadata and metadata != d_obj_info.get("Metadata"):
+                # Metadata from module inputs differs from what has been retrieved from object header
+                diff_msg = "Would have update object Metadata if not running in check mode."
+                do_match = False
+        else:
+            # The destination object does not exists
+            do_match = False
+            diff_msg = "Would have copy object if not running in check mode."
+
+        if do_match:
+            # S3 objects are equals, ensure tags will not be updated
             if module.check_mode:
                 changed = check_object_tags(module, s3, bucket, obj)
                 result = {}
                 if changed:
-                    result.update({"msg": "Would have update object tags is not running in check mode."})
+                    result.update({"msg": "Would have update object tags if not running in check mode."})
                 return changed, result
 
             # Ensure tags
@@ -1281,8 +1292,9 @@ def copy_object_to_bucket(module, s3, bucket, obj, encrypt, metadata, validate,
             if changed:
                 result = {"msg": "tags successfully updated.", "tags": tags}
             return changed, result
-        elif module.check_mode:
-            return True, {"msg": "ETag from source and destination differ"}
+        # S3 objects differ
+        if module.check_mode:
+            return True, {"msg": diff_msg}
         else:
             changed = True
             bucketsrc = {


Test patch:
diff --git a/tests/integration/targets/s3_object/library/test_s3_upload_multipart.py b/tests/integration/targets/s3_object/library/test_s3_upload_multipart.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/s3_object/library/test_s3_upload_multipart.py
@@ -0,0 +1,137 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Copyright: Contributors to the Ansible project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+DOCUMENTATION = r"""
+---
+module: test_s3_upload_multipart
+short_description: Create object using multipart upload
+description:
+  - This module is used to create object into S3 bucket using multipart upload.
+  - Multipart upload allows to upload a single object as a set of parts.
+  - This module is exclusively used to test collection `amazon.aws`.
+options:
+  bucket:
+    description:
+      - Bucket name.
+    required: true
+    type: str
+  object:
+    description:
+      - Key name of the object.
+    type: str
+    required: true
+  part_size:
+    description:
+      - Part size in MB.
+    type: int
+    default: 10
+  parts:
+    description:
+      - Number of parts.
+    type: int
+    default: 6
+author:
+  - "Aubin Bikouo (@abikouo)"
+extends_documentation_fragment:
+  - amazon.aws.common.modules
+  - amazon.aws.region.modules
+  - amazon.aws.tags
+  - amazon.aws.boto3
+"""
+
+
+try:
+    import boto3
+    import botocore
+except ImportError:
+    pass  # Handled by AnsibleAWSModule
+
+import random
+import string
+
+from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict
+
+from ansible_collections.amazon.aws.plugins.module_utils.modules import AnsibleAWSModule
+from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry
+from ansible_collections.amazon.aws.plugins.module_utils.s3 import s3_extra_params
+
+
+def generate_content(length):
+    return "".join([random.choice(string.ascii_letters + string.digits) for i in range(length)])
+
+
+def updload_parts(s3, parts, part_size, **kwargs):
+    multiparts = []
+    for part_id in range(1, parts + 1):
+        response = s3.upload_part(
+            Body=str.encode(generate_content(part_size * 1024 * 1024)), PartNumber=part_id, **kwargs
+        )
+
+        multiparts.append(
+            {
+                "PartNumber": part_id,
+                "ETag": response.get("ETag"),
+            }
+        )
+    return multiparts
+
+
+def main():
+    argument_spec = dict(
+        bucket=dict(required=True),
+        object=dict(required=True),
+        parts=dict(type="int", default=6),
+        part_size=dict(type="int", default=10),
+    )
+
+    module = AnsibleAWSModule(
+        argument_spec=argument_spec,
+        supports_check_mode=True,
+    )
+
+    bucket = module.params.get("bucket")
+    object = module.params.get("object")
+    part_size = module.params.get("part_size")
+    parts = module.params.get("parts")
+
+    extra_params = s3_extra_params(module.params)
+    retry_decorator = AWSRetry.jittered_backoff()
+    try:
+        s3 = module.client("s3", retry_decorator=retry_decorator, **extra_params)
+    except (
+        botocore.exceptions.ClientError,
+        botocore.exceptions.BotoCoreError,
+        boto3.exceptions.Boto3Error,
+    ) as e:
+        module.fail_json_aws(e, msg="Failed to connect to AWS")
+
+    # create multipart upload
+    response = s3.create_multipart_upload(Bucket=bucket, Key=object)
+    upload_id = response.get("UploadId")
+
+    # upload parts
+    upload_params = {
+        "Bucket": bucket,
+        "Key": object,
+        "UploadId": upload_id,
+    }
+
+    multiparts = updload_parts(s3, parts, part_size, **upload_params)
+
+    # complete the upload
+    response = s3.complete_multipart_upload(
+        Bucket=bucket,
+        Key=object,
+        MultipartUpload={"Parts": multiparts},
+        UploadId=upload_id,
+    )
+
+    response.pop("ResponseMetadata", None)
+    module.exit_json(changed=True, s3_object=snake_dict_to_camel_dict(response))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/integration/targets/s3_object/tasks/copy_multipart_upload.yml b/tests/integration/targets/s3_object/tasks/copy_multipart_upload.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/s3_object/tasks/copy_multipart_upload.yml
@@ -0,0 +1,185 @@
+- name: Test copying object create using multipart upload
+  vars:
+    test_bucket: "{{ resource_prefix }}-multipart"
+    test_versioned_bucket: "{{ resource_prefix }}-multipart-versioned"
+    obj_metadata:
+      some: meta_info
+      test: ci
+  block:
+    # Create Sample bucket
+    - name: Create bucket with name '{{ test_bucket }}'
+      amazon.aws.s3_bucket:
+        name: "{{ test_bucket }}"
+        state: present
+
+    - name: Create object into bucket using multipart upload
+      test_s3_upload_multipart:
+        access_key: "{{ aws_access_key }}"
+        secret_key: "{{ aws_secret_key }}"
+        session_token: "{{ security_token | default(omit) }}"
+        region: "{{ aws_region }}"
+        bucket: "{{ test_bucket }}"
+        object: multipart_1
+
+    # Test copying multipart-uploaded object
+    - name: Copy object
+      amazon.aws.s3_object:
+        bucket: "{{ test_bucket }}"
+        mode: copy
+        object: multipart_1_copy
+        copy_src:
+          bucket: "{{ test_bucket }}"
+          object: multipart_1
+      register: _copy
+
+    - name: Ensure module reported change
+      ansible.builtin.assert:
+        that:
+          - _copy is changed
+
+    - name: Retrieve object info
+      amazon.aws.s3_object_info:
+        bucket_name: "{{ test_bucket }}"
+        object_name: multipart_1_copy
+      register: obj_info
+
+    - name: Ensure object has been created
+      ansible.builtin.assert:
+        that:
+          - obj_info.object_info | length == 1
+
+    - name: Copy object once again (idempotency)
+      amazon.aws.s3_object:
+        bucket: "{{ test_bucket }}"
+        mode: copy
+        object: multipart_1_copy
+        copy_src:
+          bucket: "{{ test_bucket }}"
+          object: multipart_1
+      register: copy_idempotency
+
+    - name: Ensure module did not reported change
+      ansible.builtin.assert:
+        that:
+          - copy_idempotency is not changed
+
+    # Update object with metadata
+    - name: Copy object with metadata
+      amazon.aws.s3_object:
+        bucket: "{{ test_bucket }}"
+        mode: copy
+        object: multipart_1_copy
+        copy_src:
+          bucket: "{{ test_bucket }}"
+          object: multipart_1
+        metadata: "{{ obj_metadata }}"
+      register: _copymetadata
+
+    - name: Ensure module reported change
+      ansible.builtin.assert:
+        that:
+          - _copymetadata is changed
+
+    - name: Retrieve object info
+      amazon.aws.s3_object_info:
+        bucket_name: "{{ test_bucket }}"
+        object_name: multipart_1_copy
+      register: obj_info
+
+    - name: Ensure object has been created
+      ansible.builtin.assert:
+        that:
+          - obj_info.object_info | length == 1
+          - obj_info.object_info.0.object_data.metadata == obj_metadata
+
+    # Test copy with metadata (idempotency)
+    - name: Copy object with metadata once again (idempotency)
+      amazon.aws.s3_object:
+        bucket: "{{ test_bucket }}"
+        mode: copy
+        object: multipart_1_copy
+        copy_src:
+          bucket: "{{ test_bucket }}"
+          object: multipart_1
+        metadata: "{{ obj_metadata }}"
+      register: copy_idempotency
+
+    - name: Ensure module did not reported change
+      ansible.builtin.assert:
+        that:
+          - copy_idempotency is not changed
+
+    - name: Retrieve object info
+      amazon.aws.s3_object_info:
+        bucket_name: "{{ test_bucket }}"
+        object_name: multipart_1_copy
+      register: obj_info
+
+    - name: Ensure object has been created
+      ansible.builtin.assert:
+        that:
+          - obj_info.object_info | length == 1
+          - obj_info.object_info.0.object_data.metadata == obj_metadata
+
+    # Test copying multipart-uploaded object into bucket with versioning activated
+    # Create bucket with versioning activated
+    - name: Create bucket with name '{{ test_versioned_bucket }}'
+      amazon.aws.s3_bucket:
+        name: "{{ test_versioned_bucket }}"
+        versioning: true
+        state: present
+
+    - name: Copy object into bucket with versioning activated
+      amazon.aws.s3_object:
+        bucket: "{{ test_versioned_bucket }}"
+        mode: copy
+        object: multipart_2
+        copy_src:
+          bucket: "{{ test_bucket }}"
+          object: multipart_1
+      register: _copy
+
+    - name: Get objects info from bucket with versioning activated
+      amazon.aws.s3_object_info:
+        bucket_name: "{{ test_versioned_bucket }}"
+        object_name: multipart_2
+      register: obj_info_1
+
+    - name: Ensure object was copied and object info contain versioning information
+      ansible.builtin.assert:
+        that:
+          - _copy is changed
+          - obj_info_1.object_info.0.object_data.version_id
+
+    # test copy idempotency with versioned bucket
+    - name: Copy object into bucket with versioning activated (once again)
+      amazon.aws.s3_object:
+        bucket: "{{ test_versioned_bucket }}"
+        mode: copy
+        object: multipart_2
+        copy_src:
+          bucket: "{{ test_bucket }}"
+          object: multipart_1
+      register: _copy_idempotency
+
+    - name: Get objects info from bucket with versioning activated
+      amazon.aws.s3_object_info:
+        bucket_name: "{{ test_versioned_bucket }}"
+        object_name: multipart_2
+      register: obj_info_2
+
+    - name: Validate that module did not reported change and object versioned remains unchanged
+      ansible.builtin.assert:
+        that:
+          - _copy_idempotency is not changed
+          - obj_info_1.object_info.0.object_data.version_id == obj_info_2.object_info.0.object_data.version_id
+
+  always:
+    - name: Delete buckets
+      amazon.aws.s3_bucket:
+        name: "{{ item }}"
+        state: absent
+        force: true
+      with_items:
+        - "{{ test_versioned_bucket }}"
+        - "{{ test_bucket }}"
diff --git a/tests/integration/targets/s3_object/tasks/copy_object.yml b/tests/integration/targets/s3_object/tasks/copy_object.yml
--- a/tests/integration/targets/s3_object/tasks/copy_object.yml
+++ b/tests/integration/targets/s3_object/tasks/copy_object.yml
@@ -6,6 +6,9 @@
     metacopy_data:
       name: metacopy
       version: "1.0.3"
+    another_metadata:
+      another: meta
+      mode: copy
   block:
     - name: define bucket name used for tests
       ansible.builtin.set_fact:
@@ -211,6 +214,54 @@
           - obj_info.results | selectattr('item', 'equalto', 'metacopy') | map(attribute='object_info.0.object_data.metadata') | first == metacopy_data
           - obj_info.results | selectattr('item', 'equalto', 'copywithmeta') | map(attribute='object_info.0.object_data.metadata') | first == withmeta_data
 
+    # Validate copy idempotency with metadata
+    - name: Copy same object including metadata (check_mode=true)
+      amazon.aws.s3_object:
+        bucket: "{{ bucket_name }}"
+        mode: copy
+        object: copywithmeta
+        copy_src:
+          bucket: "{{ bucket_name }}"
+          object: withmeta
+        metadata: "{{ another_metadata }}"
+      register: copy_with_metadata_checkmode
+      check_mode: true
+
+    - name: Get objects info
+      amazon.aws.s3_object_info:
+        bucket_name: "{{ bucket_name }}"
+        object_name: copywithmeta
+      register: obj_info
+
+    - name: Validate that objects module reported change but metadata was not updated
+      ansible.builtin.assert:
+        that:
+          - copy_with_metadata_checkmode is changed
+          - obj_info.object_info.0.object_data.metadata == withmeta_data
+
+    - name: Copy same object including metadata
+      amazon.aws.s3_object:
+        bucket: "{{ bucket_name }}"
+        mode: copy
+        object: copywithmeta
+        copy_src:
+          bucket: "{{ bucket_name }}"
+          object: withmeta
+        metadata: "{{ another_metadata }}"
+      register: copy_with_metadata
+
+    - name: Get objects info
+      amazon.aws.s3_object_info:
+        bucket_name: "{{ bucket_name }}"
+        object_name: copywithmeta
+      register: obj_info
+
+    - name: Validate that objects module reported change and metadata was updated
+      ansible.builtin.assert:
+        that:
+          - copy_with_metadata is changed
+          - obj_info.object_info.0.object_data.metadata == another_metadata
+
   always:
     - ansible.builtin.include_tasks: delete_bucket.yml
       with_items:
diff --git a/tests/integration/targets/s3_object/tasks/main.yml b/tests/integration/targets/s3_object/tasks/main.yml
--- a/tests/integration/targets/s3_object/tasks/main.yml
+++ b/tests/integration/targets/s3_object/tasks/main.yml
@@ -1071,9 +1071,10 @@
               - "'tags' in result"
               - (result.tags | length) == 0
 
-        - ansible.builtin.include_tasks: copy_recursively.yml
-        - ansible.builtin.include_tasks: copy_object.yml
-        - ansible.builtin.include_tasks: copy_object_acl_disabled_bucket.yml
+    - ansible.builtin.include_tasks: copy_recursively.yml
+    - ansible.builtin.include_tasks: copy_object.yml
+    - ansible.builtin.include_tasks: copy_object_acl_disabled_bucket.yml
+    - ansible.builtin.include_tasks: copy_multipart_upload.yml
   always:
     - name: delete temporary files
       file: