Repository: ansible-collections/community.aws

Issue:
ACM module has no "check" mode 
<!--- Verify first that your issue is not already reported on GitHub -->
<!--- Also test if the latest release and devel branch are affected too -->
<!--- Complete *all* sections as described, this form is processed automatically -->

##### SUMMARY
When running a playbook in check mode, changes are affected.

##### ISSUE TYPE
- Bug Report

##### COMPONENT NAME
aws_acm

##### ANSIBLE VERSION
<!--- Paste verbatim output from "ansible --version" between quotes -->
```paste below
$ aws --version
aws-cli/1.19.17 Python/3.6.9 Linux/4.15.0-136-generic botocore/1.20.16
```

##### CONFIGURATION
```paste below
$ ansible-config dump --only-changed
ANSIBLE_PIPELINING(./myproject/ansible.cfg) = True
ANSIBLE_SSH_CONTROL_PATH(./myproject/ansible.cfg) = ~/.ssh/ansible-ssh-%%C
COLLECTIONS_PATHS(./myproject/ansible.cfg) = ['./myproject/collections']
DEFAULT_CALLBACK_PLUGIN_PATH(./myproject/ansible.cfg) = ['./myproject/callbacks']
DEFAULT_FORKS(./myproject/ansible.cfg) = 20
DEFAULT_ROLES_PATH(./myproject/ansible.cfg) = ['./myproject/roles']
INTERPRETER_PYTHON(./myproject/ansible.cfg) = /usr/bin/python3
INVENTORY_IGNORE_PATTERNS(./myproject/ansible.cfg) = ['ssh-config']
RETRY_FILES_ENABLED(./myproject/ansible.cfg) = False
```

##### OS / ENVIRONMENT
```
$ uname -a
Linux mybox 4.15.0-136-generic #140-Ubuntu SMP Thu Jan 28 05:20:47 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
```

##### STEPS TO REPRODUCE
Full playbook is at https://gist.github.com/dale-c-anderson/31c6212e8a9e86f4a829e2bdee40a6f8  

The relevant portion is reproduced here:

```yaml
- hosts: localhost
  run_once: true
  become: false
  connection: local
  vars_files:
    - "{{ playbook_dir }}/path_definitions.yml"
  tags:
    - install_certs
  tasks:
    - name: Import SSL cert to AWS Certificate Manager
      community.aws.aws_acm:
        state: present
        region: ca-central-1
        certificate: "{{ lookup('file', ssl_public_certs_dir + '/dummy.crt') }}"
        private_key: "{{ lookup('file', ssl_private_keys_dir + '/dummy.key') }}" # Key has already been encrypted.
        name_tag: dummy.example.com
```
Ran the above with 
```bash
ansible-playbook --diff --check --tags install_certs
```

##### EXPECTED RESULTS
Certificate import should only be simulated.


##### ACTUAL RESULTS
Certificate was actually imported.

<!--- Paste verbatim command output between quotes -->
```paste below
$ ansible-playbook playbooks/ssl-999-bug-report.yml --diff --check --tags install_certs

[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
[WARNING]: Skipping plugin (/home/danderson/repos/git.acromedia.com/teams/telus/project-lion/telus-lion-drupal-infrastructure/callbacks/human-log.py) as it seems to be invalid: name 'reload' is not
defined

PLAY [localhost] ********************************************************************************************************************************************************************************************

PLAY [localhost] ********************************************************************************************************************************************************************************************

TASK [Gathering Facts] **************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Import SSL cert to AWS Certificate Manager] ***********************************************************************************************************************************************************
changed: [localhost]

PLAY RECAP **************************************************************************************************************************************************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   


```
![image](https://user-images.githubusercontent.com/634971/109711209-73c93400-7b53-11eb-967a-316ecdc570af.png)

![image](https://user-images.githubusercontent.com/634971/109711248-817eb980-7b53-11eb-801c-8d2e458fcf93.png)



Discussion:
Files identified in the description:
None

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 --->
Files identified in the description:
* [`plugins/modules/aws_acm.py`](https://github.com/['ansible-collections/amazon.aws', 'ansible-collections/community.aws', 'ansible-collections/community.vmware']/blob/main/plugins/modules/aws_acm.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 @matt-telstra @s-hertel @tremble @wimnat
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Ah, woops. I never use check mode myself, so I forgot to put this in.

What's check mode supposed to do? Just check the input parameters match the module spec? Or should it check that it can find IAM credentials?

Some boto calls allow you to do a "dry_run" to check that you've got sufficient IAM permissions, but I expect that this particular call does not.
@matt-telstra See https://docs.ansible.com/ansible/latest/user_guide/playbooks_checkmode.html

Yes, check mode and dry run are the same thing. If the boto call doesn't support dry run directly, the module would probably need simulate it in some other way. Having a module that affects changes when it's not supposed to will result in a never-ending litany of bug reports.

For testing in my own projects (outside of Ansible), I use moto. I suppose I could just turn on moto and do the checks that way?
Although that would start with a clean slate for every task. So deleting a cert would throw an error because the cert doesn't exist. So I'd have to handle that case.

I'm not sure whether other Ansible AWS modules use moto. That would add a dependency to the module. Is that worth it?
It looks like some modules [only check the input](https://github.com/ansible-collections/amazon.aws/blob/76aeec280994454fc2e99fcf3996e5a5ab1e8184/plugins/modules/aws_s3.py#L389). Others say `supports_check_mode=False` ([example](https://github.com/matt-telstra/community.aws/blob/52fb23054101acbd78e13a45048ddcf935899586/plugins/modules/aws_api_gateway.py#L209)), although I'm not sure what that does.

@jillr what do AWS modules normally do in check mode? Just validate the input? Or is there a helper function I can call to check IAM credentials? Would we expect an AWS task to fail in check mode if no IAM credentials can be found? 
Is this the kind of thing we need?

https://github.com/matt-telstra/community.aws/commit/0df3af8b3354ebcbae21101403e0061ab4315ff1

(I haven't created a PR yet, because I haven't written the tests for this, or tested it manually, yet)
The idea of "check_mode" is generally to tell you if changes would be made.

The most common way to do this is just to exit with changed=True or return
True from a function (depending on the structure of the module) just before
you'd apply a change.  Generally read-only (get, list or describe) calls
should still happen.

Please note, the Boto/AWS check mode only tests permissions, not if a
change would occur, and is especially useless on read-only operations.


Resolving patch:
diff --git a/plugins/modules/aws_acm.py b/plugins/modules/aws_acm.py
--- a/plugins/modules/aws_acm.py
+++ b/plugins/modules/aws_acm.py
@@ -207,7 +207,7 @@
     arn:
       description: The ARN of the certificate in ACM
       type: str
-      returned: when I(state=present)
+      returned: when I(state=present) and not in check mode
       sample: "arn:aws:acm:ap-southeast-2:123456789012:certificate/01234567-abcd-abcd-abcd-012345678901"
     domain_name:
       description: The domain name encoded within the public certificate
@@ -362,30 +362,39 @@ def main():
             else:
                 module.debug("Existing certificate in ACM is different, overwriting")
 
-                # update cert in ACM
-                arn = acm.import_certificate(client, module,
+                if module.check_mode:
+                    arn = old_cert['certificate_arn']
+                    # note: returned domain will be the domain of the previous cert
+                else:
+                    # update cert in ACM
+                    arn = acm.import_certificate(client, module,
+                                                 certificate=module.params['certificate'],
+                                                 private_key=module.params['private_key'],
+                                                 certificate_chain=module.params['certificate_chain'],
+                                                 arn=old_cert['certificate_arn'],
+                                                 tags=tags)
+                domain = acm.get_domain_of_cert(client=client, module=module, arn=arn)
+                module.exit_json(certificate=dict(domain_name=domain, arn=arn), changed=True)
+        else:  # len(certificates) == 0
+            module.debug("No certificate in ACM. Creating new one.")
+            if module.check_mode:
+                domain = 'example.com'
+                module.exit_json(certificate=dict(domain_name=domain), changed=True)
+            else:
+                arn = acm.import_certificate(client=client,
+                                             module=module,
                                              certificate=module.params['certificate'],
                                              private_key=module.params['private_key'],
                                              certificate_chain=module.params['certificate_chain'],
-                                             arn=old_cert['certificate_arn'],
                                              tags=tags)
                 domain = acm.get_domain_of_cert(client=client, module=module, arn=arn)
-                module.exit_json(certificate=dict(domain_name=domain, arn=arn), changed=True)
-        else:  # len(certificates) == 0
-            module.debug("No certificate in ACM. Creating new one.")
-            arn = acm.import_certificate(client=client,
-                                         module=module,
-                                         certificate=module.params['certificate'],
-                                         private_key=module.params['private_key'],
-                                         certificate_chain=module.params['certificate_chain'],
-                                         tags=tags)
-            domain = acm.get_domain_of_cert(client=client, module=module, arn=arn)
 
-            module.exit_json(certificate=dict(domain_name=domain, arn=arn), changed=True)
+                module.exit_json(certificate=dict(domain_name=domain, arn=arn), changed=True)
 
     else:  # state == absent
         for cert in certificates:
-            acm.delete_certificate(client, module, cert['certificate_arn'])
+            if not module.check_mode:
+                acm.delete_certificate(client, module, cert['certificate_arn'])
         module.exit_json(arns=[cert['certificate_arn'] for cert in certificates],
                          changed=(len(certificates) > 0))
 


Test patch:
diff --git a/tests/integration/targets/aws_acm/tasks/full_acm_test.yml b/tests/integration/targets/aws_acm/tasks/full_acm_test.yml
--- a/tests/integration/targets/aws_acm/tasks/full_acm_test.yml
+++ b/tests/integration/targets/aws_acm/tasks/full_acm_test.yml
@@ -9,7 +9,16 @@
   - name: list certs
     aws_acm_info: null
     register: list_all
-    failed_when: list_all.certificates is not defined
+  - name: list certs with check mode
+    aws_acm_info: null
+    register: list_all_check
+    check_mode: yes # read-only task, should work the same as with no
+  - name: check certificate listing worked
+    assert:
+      that:
+        - list_all.certificates is defined
+        - list_all_check.certificates is defined
+        - list_all.certificates == list_all_check.certificates
   - name: ensure absent cert which doesn't exist - first time
     aws_acm:
       name_tag: '{{ item.name }}'
@@ -17,18 +26,33 @@
     with_items: '{{ local_certs }}'
   - name: ensure absent cert which doesn't exist - second time
     aws_acm:
-      name_tag: '{{ item.name }}'
+      name_tag: '{{ item[0].name }}'
       state: absent
-    with_items: '{{ local_certs }}'
+    check_mode: '{{ item[1] }}'
+    with_nested:
+      - '{{ local_certs }}'
+      - [true, false]
     register: absent_start_two
-    failed_when: absent_start_two.changed
+  - name: check no change when ensuring absent cert is absent
+    assert:
+      that:
+        - not item.changed
+    with_items: "{{ absent_start_two.results }}"
   - name: list cert which shouldn't exist
     aws_acm_info:
       tags:
-        Name: '{{ item.name }}'
+        Name: '{{ item[0].name }}'
     register: list_tag
-    with_items: '{{ local_certs }}'
-    failed_when: list_tag.certificates | length > 0
+    check_mode: '{{ item[1] }}'
+    with_nested:
+      - '{{ local_certs }}'
+      - [ False, True ] # read-only task, should work the same with check mode or not
+  - name: check listing of missing cert returns no result
+    with_items: "{{ list_tag.results }}"
+    assert:
+      that:
+        - (item.certificates | length) == 0
+        - not list_tag.changed
   - name: check directory was made
     assert:
       that:
@@ -54,6 +78,27 @@
       privatekey_path: '{{ item.priv_key }}'
       signature_algorithms:
       - sha256WithRSAEncryption
+  - name: upload certificate with check mode
+    aws_acm:
+      name_tag: '{{ item.name }}'
+      certificate: '{{ lookup(''file'', item.cert ) }}'
+      private_key: '{{ lookup(''file'', item.priv_key ) }}'
+      state: present
+    check_mode: yes
+    register: upload_check
+    with_items: '{{ local_certs }}'
+  - name: check whether cert was uploaded in check mode
+    aws_acm_info:
+      tags:
+        Name: '{{ item.name }}'
+    register: list_after_check_mode_upload
+    with_items: '{{ local_certs }}'
+  - name: check cert was not really uploaded in check mode
+    with_items: "{{ list_after_check_mode_upload.results }}"
+    assert:
+      that:
+        - upload_check.changed
+        - (item.certificates | length) == 0
   - name: upload certificates first time
     aws_acm:
       name_tag: '{{ item.name }}'
@@ -61,6 +106,7 @@
       private_key: '{{ lookup(''file'', item.priv_key ) }}'
       state: present
     register: upload
+    check_mode: no
     with_items: '{{ local_certs }}'
     until: upload is succeeded
     retries: 5
@@ -148,7 +194,33 @@
     register: upload2
     with_items: '{{ local_certs }}'
     failed_when: upload2.changed
-  - name: update first cert with body of the second, first time
+  - name: update first cert with body of the second, first time, check mode
+    aws_acm:
+      state: present
+      name_tag: '{{ local_certs[0].name }}'
+      certificate: '{{ lookup(''file'', local_certs[1].cert ) }}'
+      private_key: '{{ lookup(''file'', local_certs[1].priv_key ) }}'
+    check_mode: yes
+    register: overwrite_check
+  - name: check update in check mode detected required update
+    assert:
+      that:
+        - overwrite_check.changed
+  - name: check previous tasks did not change real cert
+    aws_acm_info:
+      tags:
+        Name: '{{ local_certs[0].name }}'
+    register: fetch_after_overwrite_check
+  - name: check update with check mode did not change real cert
+    assert:
+      that:
+      - fetch_after_overwrite_check.certificates | length == 1
+      - fetch_after_overwrite_check.certificates[0].certificate_arn == fetch_after_up.results[0].certificates[0].certificate_arn
+      - fetch_after_overwrite_check.certificates[0].domain_name == local_certs[0].domain
+      - (fetch_after_overwrite_check.certificates[0].certificate | replace( ' ', '' ) | replace( '\n', '')) == (lookup('file', local_certs[0].cert )| replace( ' ', '' ) | replace( '\n', ''))
+      - '''Name'' in fetch_after_overwrite_check.certificates[0].tags'
+      - fetch_after_overwrite_check.certificates[0].tags['Name'] == local_certs[0].name
+  - name: update first cert with body of the second, first real time
     aws_acm:
       state: present
       name_tag: '{{ local_certs[0].name }}'
@@ -206,7 +278,30 @@
       - overwrite2.certificate.arn == upload.results[0].certificate.arn
       - overwrite2.certificate.domain_name == local_certs[1].domain
       - not overwrite2.changed
-  - name: delete certs 1 and 2
+  - name: delete certs 1 and 2 in check mode
+    aws_acm:
+      state: absent
+      domain_name: '{{ local_certs[1].domain }}'
+    check_mode: yes
+    register: delete_both_check
+  - name: test deletion with check mode detected change
+    assert:
+      that:
+        - delete_both_check.changed
+  - name: fetch info for certs 1 and 2
+    aws_acm_info:
+      tags:
+        Name: '{{ local_certs[item].name }}'
+    register: check_del_one_check
+    with_items:
+    - 0
+    - 1
+  - name: test deletion with check mode detected change
+    with_items: '{{ check_del_one_check.results }}'
+    assert:
+      that:
+        - (item.certificates | length) == 1
+  - name: delete certs 1 and 2 real
     aws_acm:
       state: absent
       domain_name: '{{ local_certs[1].domain }}'
@@ -234,13 +329,16 @@
   - name: check certs 1 and 2 were already deleted
     with_items: '{{ check_del_one.results }}'
     assert:
-      that: item.certificates | length == 0
-  - name: check cert 3 not deleted
+      that: (item.certificates | length) == 0
+  - name: check cert 3
     aws_acm_info:
       tags:
         Name: '{{ local_certs[2].name }}'
     register: check_del_one_remain
-    failed_when: check_del_one_remain.certificates | length != 1
+  - name: check cert 3 not deleted
+    assert:
+      that:
+        - (check_del_one_remain.certificates | length) == 1
   - name: delete cert 3
     aws_acm:
       state: absent
@@ -270,6 +368,16 @@
       - delete_third.arns is defined
       - delete_third.arns | length == 0
       - not delete_third.changed
+  - name: delete cert 3 again, check mode
+    aws_acm:
+      state: absent
+      domain_name: '{{ local_certs[2].domain }}'
+    check_mode: yes
+    register: delete_third_check
+  - name: test deletion in check mode detected required change
+    assert:
+      that:
+        - not delete_third_check.changed
   - name: check directory was made
     assert:
       that: