Repository: ansible-collections/amazon.aws

Issue:
ec2_instance should still return instance details when instances already exist
### Summary

I am duplicating issue #859 because it was incorrectly closed.

Idempotency -- the core design pattern and expectation on which Ansible is built -- requires that the module's return data be complete and consistent whether a change occurs or not. Any instances that matched `filters` passed to the module because they already exist must be included in the module's `instances` return key.

It is an anti-pattern to suggest inserting a second task to cover a fault in idempotency of the first, especially after breaking the expectation set by the original "ec2" module. In Linux design philosophy, any such breaking change is automatically a bug.

### Issue Type

Bug Report

### Component Name

ec2_instance

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.13.2]
  config file = /home/mvaughn/Documents/git/ansible/ansible.cfg
  configured module search path = ['/home/mvaughn/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3.10/site-packages/ansible
  ansible collection location = /home/mvaughn/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/lib/python-exec/python3.10/ansible
  python version = 3.10.5 (main, Jun 30 2022, 16:31:10) [GCC 11.3.0]
  jinja version = 3.1.2
  libyaml = True
```


### Collection Versions

```console (paste below)
$ ansible-galaxy collection list
# /usr/lib/python3.10/site-packages/ansible_collections
Collection                    Version
----------------------------- -------
amazon.aws                    3.3.1  
ansible.netcommon             3.0.1  
ansible.posix                 1.4.0  
ansible.utils                 2.6.1  
ansible.windows               1.10.0 
arista.eos                    5.0.1  
awx.awx                       21.2.0 
azure.azcollection            1.13.0 
check_point.mgmt              2.3.0  
chocolatey.chocolatey         1.3.0  
cisco.aci                     2.2.0  
cisco.asa                     3.1.0                     
cisco.dnac                    6.5.0  
cisco.intersight              1.0.19 
cisco.ios                     3.2.0  
cisco.iosxr                   3.2.0  
cisco.ise                     2.5.0  
cisco.meraki                  2.10.0 
cisco.mso                     2.0.0  
cisco.nso                     1.0.3  
cisco.nxos                    3.1.0                                                                                                                                                                                                                                                                                                                                                           
cisco.ucs                     1.8.0  
cloud.common                  2.1.2  
cloudscale_ch.cloud           2.2.2  
community.aws                 3.4.0  
community.azure               1.1.0  
community.ciscosmb            1.0.5  
community.crypto              2.4.0  
community.digitalocean        1.21.0 
community.dns                 2.2.1  
community.docker              2.7.0  
community.fortios             1.0.0  
community.general             5.3.0  
community.google              1.0.0  
community.grafana             1.5.0  
community.hashi_vault         3.0.0  
community.hrobot              1.4.0  
community.libvirt             1.1.0  
community.mongodb             1.4.1  
community.mysql               3.3.0  
community.network             4.0.1  
community.okd                 2.2.0  
community.postgresql          2.1.5  
community.proxysql            1.4.0  
community.rabbitmq            1.2.1  
community.routeros            2.1.0  
community.sap                 1.0.0  
community.sap_libs            1.1.0  
community.skydive             1.0.0  
community.sops                1.2.3  
community.vmware              2.7.0  
community.windows             1.10.0 
community.zabbix              1.7.0  
containers.podman             1.9.4  
cyberark.conjur               1.1.0  
cyberark.pas                  1.0.14 
dellemc.enterprise_sonic      1.1.1  
dellemc.openmanage            5.5.0  
dellemc.os10                  1.1.1  
dellemc.os6                   1.0.7  
dellemc.os9                   1.0.4  
f5networks.f5_modules         1.18.0 
fortinet.fortimanager         2.1.5  
fortinet.fortios              2.1.6  
frr.frr                       2.0.0  
gluster.gluster               1.0.2  
google.cloud                  1.0.2  
hetzner.hcloud                1.8.1  
hpe.nimble                    1.1.4  
ibm.qradar                    2.0.0  
infinidat.infinibox           1.3.3  
infoblox.nios_modules         1.3.0  
inspur.sm                     2.0.0  
junipernetworks.junos         3.1.0  
kubernetes.core               2.3.2  
mellanox.onyx                 1.0.0  
netapp.aws                    21.7.0 
netapp.azure                  21.10.0
netapp.cloudmanager           21.18.0
netapp.elementsw              21.7.0 
netapp.ontap                  21.20.0
netapp.storagegrid            21.10.0
netapp.um_info                21.8.0 
netapp_eseries.santricity     1.3.0  
netbox.netbox                 3.7.1  
ngine_io.cloudstack           2.2.4  
ngine_io.exoscale             1.0.0  
ngine_io.vultr                1.1.2  
openstack.cloud               1.8.0
openvswitch.openvswitch       2.1.0  
ovirt.ovirt                   2.1.0  
purestorage.flasharray        1.13.0 
purestorage.flashblade        1.9.0  
purestorage.fusion            1.0.2  
sensu.sensu_go                1.13.1 
servicenow.servicenow         1.0.6  
splunk.es                     2.0.0  
t_systems_mms.icinga_director 1.30.0 
theforeman.foreman            3.4.0  
vmware.vmware_rest            2.2.0  
vyos.vyos                     3.0.1  
wti.remote                    1.0.4

# /home/mvaughn/.ansible/collections/ansible_collections
Collection           Version
-------------------- -------
amazon.aws           4.1.0  
ansible.netcommon    1.1.2  
ansible.posix        1.3.0  
community.aws        4.0.0  
community.kubernetes 2.0.0  
kubernetes.core      2.3.2
```


### 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/lib/python3.10/site-packages
Requires: 
Required-by: 
---
Name: boto3
Version: 1.21.36
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: /home/mvaughn/.local/lib/python3.10/site-packages
Requires: s3transfer, jmespath, botocore
Required-by: 
---
Name: botocore
Version: 1.24.36
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: /home/mvaughn/.local/lib/python3.10/site-packages
Requires: jmespath, urllib3, python-dateutil
Required-by: s3transfer, boto3, awscli
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed
CACHE_PLUGIN(/home/mvaughn/Documents/git/ansible/ansible.cfg) = redis
CACHE_PLUGIN_CONNECTION(/home/mvaughn/Documents/git/ansible/ansible.cfg) = localhost:6379:0
CALLBACKS_ENABLED(/home/mvaughn/Documents/git/ansible/ansible.cfg) = ['ansible.posix.profile_tasks']
DEFAULT_FORKS(/home/mvaughn/Documents/git/ansible/ansible.cfg) = 32
DEFAULT_GATHER_TIMEOUT(/home/mvaughn/Documents/git/ansible/ansible.cfg) = 30
DEFAULT_LOG_PATH(/home/mvaughn/Documents/git/ansible/ansible.cfg) = /var/log/ansible.log
DEFAULT_LOOKUP_PLUGIN_PATH(/home/mvaughn/Documents/git/ansible/ansible.cfg) = ['/home/mvaughn/.ansible/plugins/lookup', '/home/mvaughn/Documents/git/ansible/plugins/lookup', '/usr/share/ansible/plugins/lookup']
DEFAULT_MANAGED_STR(/home/mvaughn/Documents/git/ansible/ansible.cfg) = Ansible managed: {file} modified on %Y-%m-%d %H:%M:%S by {uid} on {host}
DEFAULT_REMOTE_USER(/home/mvaughn/Documents/git/ansible/ansible.cfg) = ansible
HOST_KEY_CHECKING(/home/mvaughn/Documents/git/ansible/ansible.cfg) = False
INVENTORY_ENABLED(/home/mvaughn/Documents/git/ansible/ansible.cfg) = ['amazon.aws.aws_ec2', 'host_list', 'script', 'auto', 'yaml', 'ini', 'toml']
```


### OS / Environment

Gentoo Linux

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
- Launch an instance that already exists.
```


### Expected Results

```yaml (paste below)
- Module return includes `[instances]`.
```

### Actual Results

```yaml (paste below)
- Module return does not include `[instances]`.
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/ec2_instance.py`](https://github.com/['ansible-collections/amazon.aws', 'ansible-collections/community.aws', 'ansible-collections/community.vmware']/blob/main/plugins/modules/ec2_instance.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 @ryansb @s-hertel @tremble
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@nethershaw,

Thanks for taking the time to open this issue.  I personally prefer to consistently return details of the resource, even when no changes are made.  I've tweaked the subject line a little, to reflect the desired change as this makes it easier for other people to find the issue.
So, is there a recommended workaround for the moment? I've just taught myself how to debug tasks because I expected this pattern to work, when it didn't.

The documentation even leads me to believe that the 'instances' key would be present if 'wait == true' -- it isn't:

<img width="874" alt="Screenshot 2022-12-21 at 8 22 06 AM" src="https://user-images.githubusercontent.com/2440975/208904054-1b6eabda-63f1-4f00-a08a-7bb28a85ce87.png">

> So, is there a recommended workaround for the moment? I've just taught myself how to debug tasks because I expected this pattern to work, when it didn't.

ec2_instance_info:
https://docs.ansible.com/ansible/devel/collections/amazon/aws/ec2_instance_info_module.html
FTR: this works, but shouldn't be necessary:

```yaml
    - name: Create EC2 instances
      amazon.aws.ec2_instance:
        instance_type: t2.nano
        state: running
        image:
          id: ami-f2b39792
        region: us-west-1
        name: myinstance
        exact_count: 2

    - name: Gather information about EC2 instances
      amazon.aws.ec2_instance_info:
        region: us-west-1
        filters:
          "tag:Name": myinstance
          instance-state-name: ["running"]
      register: ec2_instances
```

Resolving patch:
diff --git a/plugins/modules/ec2_instance.py b/plugins/modules/ec2_instance.py
--- a/plugins/modules/ec2_instance.py
+++ b/plugins/modules/ec2_instance.py
@@ -6,7 +6,7 @@
 __metaclass__ = type
 
 
-DOCUMENTATION = r'''
+DOCUMENTATION = r"""
 ---
 module: ec2_instance
 version_added: 1.0.0
@@ -378,9 +378,9 @@
   - amazon.aws.ec2
   - amazon.aws.tags
   - amazon.aws.boto3
-'''
+"""
 
-EXAMPLES = '''
+EXAMPLES = r"""
 # Note: These examples do not set authentication details, see the AWS Guide for details.
 
 - name: Terminate every running instance in a region. Use with EXTREME caution.
@@ -534,12 +534,30 @@
     state: present
     tags:
       foo: bar
-'''
+"""
 
-RETURN = '''
+RETURN = r"""
+instance_ids:
+    description: a list of ec2 instance IDs matching the provided specification and filters
+    returned: always
+    type: list
+    sample: ["i-0123456789abcdef0", "i-0123456789abcdef1"]
+    version_added: 5.3.0
+changed_ids:
+    description: a list of the set of ec2 instance IDs changed by the module action
+    returned: when instances that must be present are launched
+    type: list
+    sample: ["i-0123456789abcdef0"]
+    version_added: 5.3.0
+terminated_ids:
+    description: a list of the set of ec2 instance IDs terminated by the module action
+    returned: when instances that must be absent are terminated
+    type: list
+    sample: ["i-0123456789abcdef1"]
+    version_added: 5.3.0
 instances:
     description: a list of ec2 instances
-    returned: when wait == true
+    returned: when wait == true or when matching instances already exist
     type: complex
     contains:
         ami_launch_index:
@@ -942,7 +960,7 @@
             returned: always
             type: dict
             sample: vpc-0011223344
-'''
+"""
 
 from collections import namedtuple
 import time
@@ -1824,7 +1842,9 @@ def enforce_count(existing_matches, module, desired_module_state):
         if current_count == exact_count:
             module.exit_json(
                 changed=False,
-                msg='{0} instances already running, nothing to do.'.format(exact_count)
+                instances=[pretty_instance(i) for i in existing_matches],
+                instance_ids=[i["InstanceId"] for i in existing_matches],
+                msg=f"{exact_count} instances already running, nothing to do.",
             )
 
         elif current_count < exact_count:
@@ -1843,7 +1863,12 @@ def enforce_count(existing_matches, module, desired_module_state):
             all_instance_ids = [x['InstanceId'] for x in existing_matches]
             terminate_ids = all_instance_ids[0:to_terminate]
             if module.check_mode:
-                module.exit_json(changed=True, msg='Would have terminated following instances if not in check mode {0}'.format(terminate_ids))
+                module.exit_json(
+                    changed=True,
+                    terminated_ids=terminate_ids,
+                    instance_ids=all_instance_ids,
+                    msg=f"Would have terminated following instances if not in check mode {terminate_ids}",
+                )
             # terminate instances
             try:
                 client.terminate_instances(aws_retry=True, InstanceIds=terminate_ids)
@@ -1852,10 +1877,14 @@ def enforce_count(existing_matches, module, desired_module_state):
                 pass
             except botocore.exceptions.ClientError as e:  # pylint: disable=duplicate-except
                 module.fail_json(e, msg='Unable to terminate instances')
+            # include data for all matched instances in addition to the list of terminations
+            # allowing for recovery of metadata from the destructive operation
             module.exit_json(
                 changed=True,
                 msg='Successfully terminated instances.',
                 terminated_ids=terminate_ids,
+                instance_ids=all_instance_ids,
+                instances=existing_matches,
             )
 
     except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
@@ -1872,11 +1901,21 @@ def ensure_present(existing_matches, desired_module_state):
         instance_spec = build_run_instance_spec(module.params)
         # If check mode is enabled,suspend 'ensure function'.
         if module.check_mode:
-            module.exit_json(
-                changed=True,
-                spec=instance_spec,
-                msg='Would have launched instances if not in check_mode.',
-            )
+            if existing_matches:
+                instance_ids = [x["InstanceId"] for x in existing_matches]
+                module.exit_json(
+                    changed=True,
+                    instance_ids=instance_ids,
+                    instances=existing_matches,
+                    spec=instance_spec,
+                    msg="Would have launched instances if not in check_mode.",
+                )
+            else:
+                module.exit_json(
+                    changed=True,
+                    spec=instance_spec,
+                    msg="Would have launched instances if not in check_mode.",
+                )
         instance_response = run_instances(**instance_spec)
         instances = instance_response['Instances']
         instance_ids = [i['InstanceId'] for i in instances]
@@ -1904,22 +1943,43 @@ def ensure_present(existing_matches, desired_module_state):
                     client.modify_instance_attribute(aws_retry=True, **c)
                 except botocore.exceptions.ClientError as e:
                     module.fail_json_aws(e, msg="Could not apply change {0} to new instance.".format(str(c)))
+        if existing_matches:
+            # If we came from enforce_count, create a second list to distinguish
+            # between existing and new instances when returning the entire cohort
+            all_instance_ids = [x["InstanceId"] for x in existing_matches] + instance_ids
+        if not module.params.get("wait"):
+            if existing_matches:
+                module.exit_json(
+                    changed=True,
+                    changed_ids=instance_ids,
+                    instance_ids=all_instance_ids,
+                    spec=instance_spec,
+                )
+            else:
+                module.exit_json(
+                    changed=True,
+                    instance_ids=instance_ids,
+                    spec=instance_spec,
+                )
+        await_instances(instance_ids, desired_module_state=desired_module_state)
+        instances = find_instances(ids=instance_ids)
 
-        if not module.params.get('wait'):
+        if existing_matches:
+            all_instances = existing_matches + instances
+            module.exit_json(
+                changed=True,
+                changed_ids=instance_ids,
+                instance_ids=all_instance_ids,
+                instances=[pretty_instance(i) for i in all_instances],
+                spec=instance_spec,
+            )
+        else:
             module.exit_json(
                 changed=True,
                 instance_ids=instance_ids,
+                instances=[pretty_instance(i) for i in instances],
                 spec=instance_spec,
             )
-        await_instances(instance_ids, desired_module_state=desired_module_state)
-        instances = find_instances(ids=instance_ids)
-
-        module.exit_json(
-            changed=True,
-            instances=[pretty_instance(i) for i in instances],
-            instance_ids=instance_ids,
-            spec=instance_spec,
-        )
     except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
         module.fail_json_aws(e, msg="Failed to create new EC2 instance")
 


Test patch:
diff --git a/tests/integration/targets/ec2_instance_instance_multiple/tasks/main.yml b/tests/integration/targets/ec2_instance_instance_multiple/tasks/main.yml
--- a/tests/integration/targets/ec2_instance_instance_multiple/tasks/main.yml
+++ b/tests/integration/targets/ec2_instance_instance_multiple/tasks/main.yml
@@ -164,7 +164,8 @@
       that:
       - create_multiple_instances is not failed
       - create_multiple_instances is not changed
-      - '"instance_ids" not in create_multiple_instances'
+      - '"instance_ids" in create_multiple_instances'
+      - create_multiple_instances.instance_ids | length == 5
       - '"ec2:RunInstances" not in create_multiple_instances.resource_actions'
 
   - name: "Enforce instance count - launch 5 instances (Idempotency)"
@@ -183,7 +184,8 @@
       that:
       - create_multiple_instances is not failed
       - create_multiple_instances is not changed
-      - '"instance_ids" not in create_multiple_instances'
+      - '"instance_ids" in create_multiple_instances'
+      - create_multiple_instances.instance_ids | length == 5
       - '"ec2:RunInstances" not in create_multiple_instances.resource_actions'
 
   - name: "Enforce instance count to 3 - Terminate 2 instances (check_mode)"
@@ -202,7 +204,10 @@
       that:
       - terminate_multiple_instances is not failed
       - terminate_multiple_instances is changed
-      - '"instance_ids" not in terminate_multiple_instances'
+      - '"instance_ids" in terminate_multiple_instances'
+      - terminate_multiple_instances.instance_ids | length == 5
+      - '"terminated_ids" in terminate_multiple_instances'
+      - terminate_multiple_instances.terminated_ids | length == 2
       - '"ec2:RunInstances" not in terminate_multiple_instances.resource_actions'
 
   - name: "Enforce instance count to 3 - Terminate 2 instances"
@@ -221,6 +226,8 @@
       that:
       - terminate_multiple_instances is not failed
       - terminate_multiple_instances is changed
+      - '"instance_ids" in terminate_multiple_instances'
+      - terminate_multiple_instances.instance_ids | length == 5
       - '"terminated_ids" in terminate_multiple_instances'
       - terminate_multiple_instances.terminated_ids | length == 2
 
@@ -240,7 +247,9 @@
       that:
       - terminate_multiple_instances is not failed
       - terminate_multiple_instances is not changed
-      - '"instance_ids" not in terminate_multiple_instances'
+      - '"instance_ids" in terminate_multiple_instances'
+      - terminate_multiple_instances.instance_ids | length == 3
+      - '"terminated_ids" not in terminate_multiple_instances'
       - '"ec2:TerminateInstances" not in terminate_multiple_instances.resource_actions'
 
   - name: "Enforce instance count to 3 - Terminate 2 instances (Idempotency)"
@@ -258,7 +267,9 @@
       that:
       - terminate_multiple_instances is not failed
       - terminate_multiple_instances is not changed
-      - '"instance_ids" not in terminate_multiple_instances'
+      - '"instance_ids" in terminate_multiple_instances'
+      - terminate_multiple_instances.instance_ids | length == 3
+      - '"terminated_ids" not in terminate_multiple_instances'
       - '"ec2:TerminateInstances" not in terminate_multiple_instances.resource_actions'
 
   - name: "Enforce instance count to 6 - Launch 3 more instances (check_mode)"
@@ -278,7 +289,9 @@
       that:
       - create_multiple_instances is not failed
       - create_multiple_instances is changed
-      - '"instance_ids" not in create_multiple_instances'
+      - '"instance_ids" in create_multiple_instances'
+      - create_multiple_instances.instance_ids | length == 3
+      - '"changed_ids" not in create_multiple_instances'
       - '"ec2:RunInstances" not in create_multiple_instances.resource_actions'
 
   - name: "Enforce instance count to 6 - Launch 3 more instances"
@@ -301,7 +314,9 @@
       - create_multiple_instances is not failed
       - create_multiple_instances is changed
       - '"instance_ids" in create_multiple_instances'
-      - create_multiple_instances.instance_ids | length == 3
+      - create_multiple_instances.instance_ids | length == 6
+      - '"changed_ids" in create_multiple_instances'
+      - create_multiple_instances.changed_ids | length == 3
 
   - name: "Enforce instance count to 6 - Launch 3 more instances (check_mode - Idempotency)"
     ec2_instance:
@@ -320,7 +335,9 @@
       that:
       - create_multiple_instances is not failed
       - create_multiple_instances is not changed
-      - '"instance_ids" not in create_multiple_instances'
+      - '"instance_ids" in create_multiple_instances'
+      - create_multiple_instances.instance_ids | length == 6
+      - '"changed_ids" not in create_multiple_instances'
       - '"ec2:RunInstances" not in create_multiple_instances.resource_actions'
 
   - name: "Enforce instance count to 6 - Launch 3 more instances (Idempotency)"
@@ -339,7 +356,9 @@
       that:
       - create_multiple_instances is not failed
       - create_multiple_instances is not changed
-      - '"instance_ids" not in create_multiple_instances'
+      - '"instance_ids" in create_multiple_instances'
+      - create_multiple_instances.instance_ids | length == 6
+      - '"changed_ids" not in create_multiple_instances'
       - '"ec2:RunInstances" not in create_multiple_instances.resource_actions'