Repository: ansible-collections/community.aws

Issue:
s3_sync file_root parameter does not accept file path
### Summary

Hello, the s3_sync module documentation for file_root parameter comment states:
> File/directory path for synchronization. 

To me, this suggests that an individual file can be specified, ex: `/home/me/my_archive.tar.gz`

When an individual file path is provided, no file is detected by the file globbing. Thus, nothing is uploaded:
```
TASK [ia-home-archive : upload to S3 Glacier] *****************************************************
ok: [127.0.0.1] => {
    "changed": false,
    "filelist_actionable": [],
    "filelist_initial": [],
    "filelist_local_etag": [],
    "filelist_s3": [],
    "filelist_typed": [],
    "invocation": {
        "module_args": {
            "aws_access_key": null,
            "aws_ca_bundle": null,
            "aws_config": null,
            "aws_secret_key": null,
            "bucket": "ia-home-archive",
            "cache_control": "",
            "debug_botocore_endpoint_logs": false,
            "delete": false,
            "ec2_url": null,
            "exclude": ".*",
            "file_change_strategy": "date_size",
            "file_root": "/home/me/my_archive.tar.gz",
            "include": "*",
            "key_prefix": "",
            "mime_map": null,
            "mode": "push",
            "permission": "private",
            "profile": "2fa",
            "region": "us-west-2",
            "retries": null,
            "security_token": null,
            "storage_class": "DEEP_ARCHIVE",
            "validate_certs": true
        }
    },
    "uploads": []
}
```

Once I changed the `file_root` parameter to `/home/me` and `include` parameter to `my_archive.tar.gz`, I got the desired result.

It seems the documentation could be revised to clarify that `file_root` can only be a directory, or the module could be further improved to support a file path in the `file_root` parameter.

### Issue Type

Bug Report

### Component Name

s3_sync

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.11.3]
  config file = None
  configured module search path = ['/home/peter.hall/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/local/share/virtualenvs/aws/lib/python3.6/site-packages/ansible
  ansible collection location = /home/peter.hall/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/local/share/virtualenvs/aws/bin/ansible
  python version = 3.6.8 (default, Nov 16 2020, 16:55:22) [GCC 4.8.5 20150623 (Red Hat 4.8.5-44)]
  jinja version = 3.0.1
  libyaml = True
```


### Collection Versions

```console (paste below)
$ ansible-galaxy collection list
Collection                    Version
----------------------------- -------
amazon.aws                    1.5.0
ansible.netcommon             2.3.0
ansible.posix                 1.2.0
ansible.utils                 2.3.1
ansible.windows               1.7.2
arista.eos                    2.2.0
awx.awx                       19.2.2
azure.azcollection            1.8.0
check_point.mgmt              2.0.0
chocolatey.chocolatey         1.1.0
cisco.aci                     2.0.0
cisco.asa                     2.0.2
cisco.intersight              1.0.16
cisco.ios                     2.3.1
cisco.iosxr                   2.4.0
cisco.meraki                  2.4.2
cisco.mso                     1.2.0
cisco.nso                     1.0.3
cisco.nxos                    2.5.0
cisco.ucs                     1.6.0
cloudscale_ch.cloud           2.2.0
community.aws                 1.5.0
community.azure               1.0.0
community.crypto              1.8.0
community.digitalocean        1.8.0
community.docker              1.9.0
community.fortios             1.0.0
community.general             3.5.0
community.google              1.0.0
community.grafana             1.2.1
community.hashi_vault         1.3.2
community.hrobot              1.1.1
community.kubernetes          1.2.1
community.kubevirt            1.0.0
community.libvirt             1.0.2
community.mongodb             1.3.0
community.mysql               2.1.0
community.network             3.0.0
community.okd                 1.1.2
community.postgresql          1.4.0
community.proxysql            1.1.0
community.rabbitmq            1.1.0
community.routeros            1.2.0
community.skydive             1.0.0
community.sops                1.1.0
community.vmware              1.12.0
community.windows             1.6.0
community.zabbix              1.4.0
containers.podman             1.6.2
cyberark.conjur               1.1.0
cyberark.pas                  1.0.7
dellemc.enterprise_sonic      1.1.0
dellemc.openmanage            3.6.0
dellemc.os10                  1.1.1
dellemc.os6                   1.0.7
dellemc.os9                   1.0.4
f5networks.f5_modules         1.11.0
fortinet.fortimanager         2.1.3
fortinet.fortios              2.1.2
frr.frr                       1.0.3
gluster.gluster               1.0.1
google.cloud                  1.0.2
hetzner.hcloud                1.4.4
hpe.nimble                    1.1.3
ibm.qradar                    1.0.3
infinidat.infinibox           1.2.4
inspur.sm                     1.2.0
junipernetworks.junos         2.4.0
kubernetes.core               1.2.1
mellanox.onyx                 1.0.0
netapp.aws                    21.6.0
netapp.azure                  21.8.1
netapp.cloudmanager           21.9.0
netapp.elementsw              21.6.1
netapp.ontap                  21.9.0
netapp.um_info                21.7.0
netapp_eseries.santricity     1.2.13
netbox.netbox                 3.1.1
ngine_io.cloudstack           2.1.0
ngine_io.exoscale             1.0.0
ngine_io.vultr                1.1.0
openstack.cloud               1.5.0
openvswitch.openvswitch       2.0.0
ovirt.ovirt                   1.5.4
purestorage.flasharray        1.10.0
purestorage.flashblade        1.6.0
sensu.sensu_go                1.11.1
servicenow.servicenow         1.0.6
splunk.es                     1.0.2
t_systems_mms.icinga_director 1.20.0
theforeman.foreman            2.1.2
vyos.vyos                     2.5.0
wti.remote                    1.0.1
```


### AWS SDK versions

```console (paste below)
$ pip show boto boto3 botocore
Name: boto
Version: 2.49.0
Summary: Amazon Web Services Library
Home-page: https://github.com/boto/boto/
Author: Mitch Garnaat
Author-email: mitch@garnaat.com
License: MIT
Location: /usr/local/share/virtualenvs/aws/lib/python3.6/site-packages
Requires:
Required-by:
---
Name: boto3
Version: 1.18.18
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: /usr/local/share/virtualenvs/aws/lib/python3.6/site-packages
Requires: botocore, jmespath, s3transfer
Required-by:
---
Name: botocore
Version: 1.21.18
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: /usr/local/share/virtualenvs/aws/lib/python3.6/site-packages
Requires: urllib3, jmespath, python-dateutil
Required-by: s3transfer, boto3
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed
CALLBACKS_ENABLED(/home/peter.hall/ansible/ansible.cfg) = ['profile_tasks']
DEFAULT_HOST_LIST(/home/peter.hall/ansible/ansible.cfg) = ['/home/peter.hall/repos
DEFAULT_STDOUT_CALLBACK(/home/peter.hall/ansible/ansible.cfg) = debug
DEFAULT_TIMEOUT(/home/peter.hall/ansible/ansible.cfg) = 30
```


### OS / Environment

CentOS 7

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
- name: upload to S3 Glacier
  community.aws.s3_sync:
    bucket:  home-archive
    file_root: /home/me/my_archive.tar.gz
    storage_class: DEEP_ARCHIVE
    permission: private
    profile: 2fa
    region: us-west-2
```


### Expected Results

file specified by `file_root: /home/me/my_archive.tar.gz` is uploaded

### Actual Results

File globbing does not detect any file.
```console (paste below)
TASK [ia-home-archive : upload to S3 Glacier] *****************************************************
task path: /home/peter.hall/repositories/do-ansible/roles/ia-home-archive/tasks/main_loop.yml:13
Friday 13 August 2021  15:45:07 -0700 (0:00:01.783)       0:00:10.643 *********
<127.0.0.1> ESTABLISH LOCAL CONNECTION FOR USER: peter.hall
<127.0.0.1> EXEC /bin/sh -c 'echo ~peter.hall && sleep 0'
<127.0.0.1> EXEC /bin/sh -c '( umask 77 && mkdir -p "` echo /home/peter.hall/.ansible/tmp `"&& mkdir "` echo /home/peter.hall/.ansible/tmp/ansible-tmp-1628894707.9170668-3601-171520442577912 `" && echo ansible-tmp-1628894707.9170668-3601-171520442577912="` echo /home/peter.hall/.ansible/tmp/ansible-tmp-1628894707.9170668-3601-171520442577912 `" ) && sleep 0'
Loading collection amazon.aws from /usr/local/share/virtualenvs/aws/lib/python3.6/site-packages/ansible_collections/amazon/aws
Including module_utils file ansible/__init__.py
Including module_utils file ansible/module_utils/__init__.py
Including module_utils file ansible/module_utils/_text.py
Including module_utils file ansible/module_utils/basic.py
Including module_utils file ansible/module_utils/common/_collections_compat.py
Including module_utils file ansible/module_utils/common/__init__.py
Including module_utils file ansible/module_utils/common/_json_compat.py
Including module_utils file ansible/module_utils/common/_utils.py
Including module_utils file ansible/module_utils/common/arg_spec.py
Including module_utils file ansible/module_utils/common/file.py
Including module_utils file ansible/module_utils/common/parameters.py
Including module_utils file ansible/module_utils/common/collections.py
Including module_utils file ansible/module_utils/common/process.py
Including module_utils file ansible/module_utils/common/sys_info.py
Including module_utils file ansible/module_utils/common/text/converters.py
Including module_utils file ansible/module_utils/common/text/__init__.py
Including module_utils file ansible/module_utils/common/text/formatters.py
Including module_utils file ansible/module_utils/common/validation.py
Including module_utils file ansible/module_utils/common/warnings.py
Including module_utils file ansible/module_utils/compat/selectors.py
Including module_utils file ansible/module_utils/compat/__init__.py
Including module_utils file ansible/module_utils/compat/_selectors2.py
Including module_utils file ansible/module_utils/compat/selinux.py
Including module_utils file ansible/module_utils/distro/__init__.py
Including module_utils file ansible/module_utils/distro/_distro.py
Including module_utils file ansible/module_utils/errors.py
Including module_utils file ansible/module_utils/parsing/convert_bool.py
Including module_utils file ansible/module_utils/parsing/__init__.py
Including module_utils file ansible/module_utils/pycompat24.py
Including module_utils file ansible/module_utils/six/__init__.py
Including module_utils file ansible_collections/amazon/aws/plugins/module_utils/core.py
Including module_utils file ansible/module_utils/common/dict_transformations.py
Including module_utils file ansible_collections/__init__.py
Including module_utils file ansible_collections/amazon/__init__.py
Including module_utils file ansible_collections/amazon/aws/__init__.py
Including module_utils file ansible_collections/amazon/aws/plugins/__init__.py
Including module_utils file ansible_collections/amazon/aws/plugins/module_utils/__init__.py
Including module_utils file ansible_collections/amazon/aws/plugins/module_utils/ec2.py
Including module_utils file ansible/module_utils/ansible_release.py
Including module_utils file ansible_collections/amazon/aws/plugins/module_utils/cloud.py
Using module file /usr/local/share/virtualenvs/aws/lib/python3.6/site-packages/ansible_collections/community/aws/plugins/modules/s3_sync.py
<127.0.0.1> PUT /home/peter.hall/.ansible/tmp/ansible-local-3507r12mon4r/tmpvuqi_l11 TO /home/peter.hall/.ansible/tmp/ansible-tmp-1628894707.9170668-3601-171520442577912/AnsiballZ_s3_sync.py
<127.0.0.1> EXEC /bin/sh -c 'chmod u+x /home/peter.hall/.ansible/tmp/ansible-tmp-1628894707.9170668-3601-171520442577912/ /home/peter.hall/.ansible/tmp/ansible-tmp-1628894707.9170668-3601-171520442577912/AnsiballZ_s3_sync.py && sleep 0'
<127.0.0.1> EXEC /bin/sh -c '/usr/local/share/virtualenvs/aws/bin/python /home/peter.hall/.ansible/tmp/ansible-tmp-1628894707.9170668-3601-171520442577912/AnsiballZ_s3_sync.py && sleep 0'
<127.0.0.1> EXEC /bin/sh -c 'rm -f -r /home/peter.hall/.ansible/tmp/ansible-tmp-1628894707.9170668-3601-171520442577912/ > /dev/null 2>&1 && sleep 0'
ok: [127.0.0.1] => {
    "changed": false,
    "filelist_actionable": [],
    "filelist_initial": [],
    "filelist_local_etag": [],
    "filelist_s3": [],
    "filelist_typed": [],
    "invocation": {
        "module_args": {
            "aws_access_key": null,
            "aws_ca_bundle": null,
            "aws_config": null,
            "aws_secret_key": null,
            "bucket": "ia-home-archive",
            "cache_control": "",
            "debug_botocore_endpoint_logs": false,
            "delete": false,
            "ec2_url": null,
            "exclude": ".*",
            "file_change_strategy": "date_size",
            "file_root": "/home/me/my_archive.tar.gz",
            "include": "*",
            "key_prefix": "",
            "mime_map": null,
            "mode": "push",
            "permission": "private",
            "profile": "2fa",
            "region": "us-west-2",
            "retries": null,
            "security_token": null,
            "storage_class": "DEEP_ARCHIVE",
            "validate_certs": true
        }
    },
    "uploads": []
}
```


### Code of Conduct

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


Discussion:
Files identified in the description:
* [`plugins/modules/s3_sync.py`](https://github.com/['ansible-collections/amazon.aws', 'ansible-collections/community.aws', 'ansible-collections/community.vmware']/blob/main/plugins/modules/s3_sync.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 @jillr @markuman @s-hertel @tremble @wimnat
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@pthall  Thanks for your report. I can confirm this bug/behaviour.  

According to the documentation, `file_root` must also accept a single file.  
The issue is, that [it is using `os.walk(file_root)` only, which will return nothing for a single file](https://github.com/ansible-collections/community.aws/blob/main/plugins/modules/s3_sync.py#L325).  

A fix for that is possibly easy.  
Just check `os.path.isfile(fileroot)` before and [return the expected object for the single file](https://github.com/ansible-collections/community.aws/blob/main/plugins/modules/s3_sync.py#L351).

@pthall  do you have some time and energy to provide a PR for this issue?

Resolving patch:
diff --git a/plugins/modules/s3_sync.py b/plugins/modules/s3_sync.py
--- a/plugins/modules/s3_sync.py
+++ b/plugins/modules/s3_sync.py
@@ -148,6 +148,11 @@
     file_root: roles/s3/files/
     storage_class: GLACIER
 
+- name: basic individual file upload
+  community.aws.s3_sync:
+    bucket: tedder
+    file_root: roles/s3/files/file_name
+
 - name: all the options
   community.aws.s3_sync:
     bucket: tedder
@@ -322,41 +327,57 @@ def calculate_multipart_etag(source_path, chunk_size=DEFAULT_CHUNK_SIZE):
 
 def gather_files(fileroot, include=None, exclude=None):
     ret = []
-    for (dirpath, dirnames, filenames) in os.walk(fileroot):
-        for fn in filenames:
-            fullpath = os.path.join(dirpath, fn)
-            # include/exclude
-            if include:
-                found = False
-                for x in include.split(','):
-                    if fnmatch.fnmatch(fn, x):
-                        found = True
-                if not found:
-                    # not on the include list, so we don't want it.
-                    continue
-
-            if exclude:
-                found = False
-                for x in exclude.split(','):
-                    if fnmatch.fnmatch(fn, x):
-                        found = True
-                if found:
-                    # skip it, even if previously included.
-                    continue
-
-            chopped_path = os.path.relpath(fullpath, start=fileroot)
-            fstat = os.stat(fullpath)
-            f_size = fstat[osstat.ST_SIZE]
-            f_modified_epoch = fstat[osstat.ST_MTIME]
-            ret.append({
-                'fullpath': fullpath,
-                'chopped_path': chopped_path,
-                'modified_epoch': f_modified_epoch,
-                'bytes': f_size,
-            })
-        # dirpath = path *to* the directory
-        # dirnames = subdirs *in* our directory
-        # filenames
+
+    if os.path.isfile(fileroot):
+        fullpath = fileroot
+        fstat = os.stat(fullpath)
+        path_array = fileroot.split('/')
+        chopped_path = path_array[-1]
+        f_size = fstat[osstat.ST_SIZE]
+        f_modified_epoch = fstat[osstat.ST_MTIME]
+        ret.append({
+            'fullpath': fullpath,
+            'chopped_path': chopped_path,
+            'modified_epoch': f_modified_epoch,
+            'bytes': f_size,
+        })
+
+    else:
+        for (dirpath, dirnames, filenames) in os.walk(fileroot):
+            for fn in filenames:
+                fullpath = os.path.join(dirpath, fn)
+                # include/exclude
+                if include:
+                    found = False
+                    for x in include.split(','):
+                        if fnmatch.fnmatch(fn, x):
+                            found = True
+                    if not found:
+                        # not on the include list, so we don't want it.
+                        continue
+
+                if exclude:
+                    found = False
+                    for x in exclude.split(','):
+                        if fnmatch.fnmatch(fn, x):
+                            found = True
+                    if found:
+                        # skip it, even if previously included.
+                        continue
+
+                chopped_path = os.path.relpath(fullpath, start=fileroot)
+                fstat = os.stat(fullpath)
+                f_size = fstat[osstat.ST_SIZE]
+                f_modified_epoch = fstat[osstat.ST_MTIME]
+                ret.append({
+                    'fullpath': fullpath,
+                    'chopped_path': chopped_path,
+                    'modified_epoch': f_modified_epoch,
+                    'bytes': f_size,
+                })
+            # dirpath = path *to* the directory
+            # dirnames = subdirs *in* our directory
+            # filenames
     return ret
 
 


Test patch:
diff --git a/tests/integration/targets/s3_sync/defaults/main.yml b/tests/integration/targets/s3_sync/defaults/main.yml
--- a/tests/integration/targets/s3_sync/defaults/main.yml
+++ b/tests/integration/targets/s3_sync/defaults/main.yml
@@ -1,2 +1,3 @@
 test_bucket: "{{ tiny_prefix }}-testbucket-ansible"
 test_bucket_2: "{{ tiny_prefix }}-testbucket-ansible-2"
+test_bucket_3: "{{ tiny_prefix }}-testbucket-ansible-3"
diff --git a/tests/integration/targets/s3_sync/tasks/main.yml b/tests/integration/targets/s3_sync/tasks/main.yml
--- a/tests/integration/targets/s3_sync/tasks/main.yml
+++ b/tests/integration/targets/s3_sync/tasks/main.yml
@@ -77,6 +77,7 @@
     - assert:
         that:
           - output is changed
+    
     # ============================================================
 
     - name: Sync files already present
@@ -99,6 +100,27 @@
         that:
           - output is not changed
  
+    # ============================================================
+    - name: Create a third s3_bucket
+      s3_bucket:
+        name: "{{ test_bucket_3 }}"
+        state: present
+      register: output
+
+    - assert:
+        that:
+          - output.changed
+          - output.name == "{{ test_bucket_3 }}"
+          - not output.requester_pays
+   
+    - name: Sync individual file with remote bucket
+      s3_sync:
+        bucket: "{{ test_bucket_3 }}"
+        file_root: "{{ output_dir }}/s3_sync/test1.txt"
+      register: output
+    - assert:
+        that:
+          - output is changed
 
     # ============================================================
     # DOCUMENTATION EXAMPLES
@@ -132,3 +154,4 @@
       with_items:
         - "{{ test_bucket }}"
         - "{{ test_bucket_2 }}"
+        - "{{ test_bucket_3 }}"