Repository: ansible-collections/amazon.aws

Issue:
ec2_instance module doesn't have count
### Summary

ec2_instance module doesn't have count parameter like ec2 module does

### Issue Type

Bug Report

### Component Name

ec2_instance

### Ansible Version

```console (paste below)
$ ansible --version
2.11
```


### Collection Versions

```console (paste below)
$ ansible-galaxy collection list
amazon.aws
```


### AWS SDK versions

```console (paste below)
$ pip show boto boto3 botocore
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed

```


### OS / Environment

RHEL 8

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
- name: Create ec2 instance(s) for controller hybrid node(s)
  amazon.aws.ec2_instance:
    key_name: "{{ key_name }}"
    instance_type: t2.large
    wait: yes
    count: "{{ count | default('1') }}"
    region: "{{ region }}"
    network:
      assign_public_ip: yes
      group: lab-sg
    vpc_subnet_id: "{{ subnet }}"
    image_id: "{{ ami }}"
    tags:
        Name: "{{ vm_name }} controller hybrid"
        lab_managed: lab_managed
```


### Expected Results

It provisions correctly

### Actual Results

```console (paste below)
Unsupported parameters for (amazon.aws.ec2_instance) module: count
```


### 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 @wimnat
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

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
@@ -23,6 +23,7 @@
   instance_ids:
     description:
       - If you specify one or more instance IDs, only instances that have the specified IDs are returned.
+      - Mutually exclusive with I(exact_count).
     type: list
     elements: str
   state:
@@ -55,6 +56,19 @@
         Only required when instance is not already present.
     default: t2.micro
     type: str
+  count:
+    description:
+      - Number of instances to launch.
+      - Setting this value will result in always launching new instances.
+      - Mutually exclusive with I(exact_count).
+    type: int
+  exact_count:
+    description:
+      - An integer value which indicates how many instances that match the I(filters) parameter should be running.
+      - Instances are either created or terminated based on this value.
+      - If termination takes place, least recently created instances will be terminated based on Launch Time.
+      - Mutually exclusive with I(count), I(instance_ids).
+    type: int
   user_data:
     description:
       - Opaque blob of data which is made available to the ec2 instance
@@ -415,6 +429,7 @@
     tags:
       Env: "eni_on"
     instance_type: t2.micro
+
 - name: start an instance with metadata options
   amazon.aws.ec2_instance:
     name: "public-metadataoptions-instance"
@@ -426,6 +441,35 @@
     metadata_options:
       http_endpoint: enabled
       http_tokens: optional
+
+# ensure number of instances running with a tag matches exact_count
+- name: start multiple instances
+  amazon.aws.ec2_instance:
+    instance_type: t3.small
+    image_id: ami-123456
+    exact_count: 5
+    region: us-east-2
+    network:
+      assign_public_ip: yes
+      security_group: default
+      vpc_subnet_id: subnet-0123456
+    tags:
+      foo: bar
+
+# launches multiple instances - specific number of instances
+- name: start specific number of multiple instances
+  amazon.aws.ec2_instance:
+    instance_type: t3.small
+    image_id: ami-123456
+    count: 3
+    region: us-east-2
+    network:
+      assign_public_ip: yes
+      security_group: default
+      vpc_subnet_id: subnet-0123456
+    state: present
+    tags:
+      foo: bar
 '''
 
 RETURN = '''
@@ -848,6 +892,7 @@
 except ImportError:
     pass  # caught by AnsibleAWSModule
 
+
 from ansible.module_utils._text import to_native
 from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict
 from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict
@@ -1250,6 +1295,14 @@ def build_run_instance_spec(params):
     if params.get('instance_role'):
         spec['IamInstanceProfile'] = dict(Arn=determine_iam_role(params.get('instance_role')))
 
+    if module.params.get('exact_count'):
+        spec['MaxCount'] = module.params.get('to_launch')
+        spec['MinCount'] = module.params.get('to_launch')
+
+    if module.params.get('count'):
+        spec['MaxCount'] = module.params.get('count')
+        spec['MinCount'] = module.params.get('count')
+
     spec['InstanceType'] = params['instance_type']
     return spec
 
@@ -1699,6 +1752,50 @@ def handle_existing(existing_matches, state):
     return result
 
 
+def enforce_count(existing_matches, module, desired_module_state):
+    exact_count = module.params.get('exact_count')
+
+    try:
+        current_count = len(existing_matches)
+        if current_count == exact_count:
+            module.exit_json(
+                changed=False,
+                msg='{0} instances already running, nothing to do.'.format(exact_count)
+            )
+
+        elif current_count < exact_count:
+            to_launch = exact_count - current_count
+            module.params['to_launch'] = to_launch
+            # launch instances
+            try:
+                ensure_present(existing_matches=existing_matches, desired_module_state=desired_module_state)
+            except botocore.exceptions.ClientError as e:
+                module.fail_json(e, msg='Unable to launch instances')
+        elif current_count > exact_count:
+            to_terminate = current_count - exact_count
+            # sort the instances from least recent to most recent based on launch time
+            existing_matches = sorted(existing_matches, key=lambda inst: inst['LaunchTime'])
+            # get the instance ids of instances with the count tag on them
+            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))
+            # terminate instances
+            try:
+                result = client.terminate_instances(InstanceIds=terminate_ids)
+                await_instances(terminate_ids, desired_module_state='terminated', force_wait=True)
+            except botocore.exceptions.ClientError as e:
+                module.fail_json(e, msg='Unable to terminate instances')
+            module.exit_json(
+                changed=True,
+                msg='Successfully terminated instances.',
+                terminated_ids=terminate_ids,
+            )
+
+    except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+        module.fail_json_aws(e, msg="Failed to enforce instance count")
+
+
 def ensure_present(existing_matches, desired_module_state):
     tags = dict(module.params.get('tags') or {})
     name = module.params.get('name')
@@ -1712,6 +1809,7 @@ def ensure_present(existing_matches, desired_module_state):
             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']
@@ -1817,7 +1915,8 @@ def main():
         state=dict(default='present', choices=['present', 'started', 'running', 'stopped', 'restarted', 'rebooted', 'terminated', 'absent']),
         wait=dict(default=True, type='bool'),
         wait_timeout=dict(default=600, type='int'),
-        # count=dict(default=1, type='int'),
+        count=dict(type='int'),
+        exact_count=dict(type='int'),
         image=dict(type='dict'),
         image_id=dict(type='str'),
         instance_type=dict(default='t2.micro', type='str'),
@@ -1861,6 +1960,8 @@ def main():
             ['availability_zone', 'vpc_subnet_id'],
             ['tower_callback', 'user_data'],
             ['image_id', 'image'],
+            ['exact_count', 'count'],
+            ['exact_count', 'instance_ids'],
         ],
         supports_check_mode=True
     )
@@ -1895,7 +1996,9 @@ def main():
                 msg='No matching instances found',
                 changed=False,
             )
-    elif existing_matches:
+    elif module.params.get('exact_count'):
+        enforce_count(existing_matches, module, desired_module_state=state)
+    elif existing_matches and not module.params.get('count'):
         for match in existing_matches:
             warn_if_public_ip_assignment_changed(match)
             warn_if_cpu_options_changed(match)


Test patch:
diff --git a/tests/integration/targets/ec2_instance/inventory b/tests/integration/targets/ec2_instance/inventory
--- a/tests/integration/targets/ec2_instance/inventory
+++ b/tests/integration/targets/ec2_instance/inventory
@@ -1,5 +1,6 @@
 [tests]
 instance_minimal
+instance_multiple
 checkmode_tests
 termination_protection
 ebs_optimized
diff --git a/tests/integration/targets/ec2_instance/roles/ec2_instance/tasks/instance_multiple.yml b/tests/integration/targets/ec2_instance/roles/ec2_instance/tasks/instance_multiple.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_instance/roles/ec2_instance/tasks/instance_multiple.yml
@@ -0,0 +1,415 @@
+- block:
+
+################################################################
+
+  - name: "Create multiple instance (check_mode)"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      count: 5
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      state: present
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+    register: create_multiple_instances
+    check_mode: true
+
+  - assert:
+      that:
+      - create_multiple_instances is not failed
+      - create_multiple_instances is changed
+      - '"instance_ids" not in create_multiple_instances'
+      - '"ec2:RunInstances" not in create_multiple_instances.resource_actions'
+
+  - name: "Create multiple instances"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      count: 5
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      state: present
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      wait: true
+    register: create_multiple_instances
+
+  - assert:
+      that:
+      - create_multiple_instances is not failed
+      - create_multiple_instances is changed
+      - '"ec2:RunInstances" in create_multiple_instances.resource_actions'
+      - '"instance_ids" in create_multiple_instances'
+      - create_multiple_instances.instance_ids | length == 5
+
+  - name: "Save instance IDs"
+    set_fact:
+      created_instance_ids: "{{ create_multiple_instances.instance_ids }}"
+
+# Terminate instances created in count test
+
+  - name: "Terminate instance based on id (check_mode)"
+    ec2_instance:
+      state: absent
+      instance_ids:
+      - "{{ item }}"
+    register: terminate_id
+    check_mode: true
+    with_items: "{{ created_instance_ids }}"
+
+  - assert:
+      that:
+      - terminate_id is not failed
+      - terminate_id is changed
+
+  - name: "Terminate instance based on id"
+    ec2_instance:
+      state: absent
+      instance_ids:
+      - "{{ item }}"
+      wait: true
+    register: terminate_id
+    with_items: "{{ created_instance_ids }}"
+
+  - assert:
+      that:
+      - terminate_id is not failed
+      - terminate_id is changed
+
+  - name: "Terminate instance based on id - Idempotency (check_mode)"
+    ec2_instance:
+      state: absent
+      instance_ids:
+      - "{{ item }}"
+    register: terminate_id
+    check_mode: true
+    with_items: "{{ created_instance_ids }}"
+
+  - assert:
+      that:
+      - terminate_id is not failed
+      - terminate_id is not changed
+
+  - name: "Terminate instance based on id - Idempotency"
+    ec2_instance:
+      state: absent
+      instance_ids:
+      - "{{ item }}"
+    register: terminate_id
+    with_items: "{{ created_instance_ids }}"
+
+  - assert:
+      that:
+      - terminate_id is not failed
+      - terminate_id is not changed
+
+################################################################
+
+  - name: "Enforce instance count - launch 5 instances (check_mode)"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 5
+      region: "{{ aws_region }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      image_id: "{{ ec2_ami_id }}"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+    register: create_multiple_instances
+    check_mode: true
+
+  - assert:
+      that:
+      - create_multiple_instances is not failed
+      - create_multiple_instances is changed
+      - '"instance_ids" not in create_multiple_instances'
+      - '"ec2:RunInstances" not in create_multiple_instances.resource_actions'
+
+  - name: "Enforce instance count - launch 5 instances"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 5
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      wait: true
+    register: create_multiple_instances
+
+  - assert:
+      that:
+      - create_multiple_instances is not failed
+      - create_multiple_instances is changed
+      - '"ec2:RunInstances" in create_multiple_instances.resource_actions'
+      - '"instance_ids" in create_multiple_instances'
+      - create_multiple_instances.instance_ids | length == 5
+
+  - name: "Enforce instance count - launch 5 instances (check_mode - Idempotency)"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 5
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+    register: create_multiple_instances
+    check_mode: true
+
+  - assert:
+      that:
+      - create_multiple_instances is not failed
+      - create_multiple_instances is not changed
+      - '"instance_ids" not in create_multiple_instances'
+      - '"ec2:RunInstances" not in create_multiple_instances.resource_actions'
+
+  - name: "Enforce instance count - launch 5 instances (Idempotency)"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 5
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      wait: true
+    register: create_multiple_instances
+
+  - assert:
+      that:
+      - create_multiple_instances is not failed
+      - create_multiple_instances is not changed
+      - '"instance_ids" not in create_multiple_instances'
+      - '"ec2:RunInstances" not in create_multiple_instances.resource_actions'
+
+  - name: "Enforce instance count to 3 - Terminate 2 instances (check_mode)"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 3
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+    register: terminate_multiple_instances
+    check_mode: true
+
+  - assert:
+      that:
+      - terminate_multiple_instances is not failed
+      - terminate_multiple_instances is changed
+      - '"instance_ids" not in terminate_multiple_instances'
+      - '"ec2:RunInstances" not in terminate_multiple_instances.resource_actions'
+
+  - name: "Enforce instance count to 3 - Terminate 2 instances"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 3
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      wait: true
+    register: terminate_multiple_instances
+
+  - assert:
+      that:
+      - terminate_multiple_instances is not failed
+      - terminate_multiple_instances is changed
+      - '"terminated_ids" in terminate_multiple_instances'
+      - terminate_multiple_instances.terminated_ids | length == 2
+
+  - name: "Enforce instance count to 3 - Terminate 2 instances (check_mode - Idempotency)"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 3
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+    register: terminate_multiple_instances
+    check_mode: true
+
+  - assert:
+      that:
+      - terminate_multiple_instances is not failed
+      - terminate_multiple_instances is not changed
+      - '"instance_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)"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 3
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+    register: terminate_multiple_instances
+
+  - assert:
+      that:
+      - terminate_multiple_instances is not failed
+      - terminate_multiple_instances is not changed
+      - '"instance_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)"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 6
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      wait: true
+    check_mode: true
+    register: create_multiple_instances
+
+  - assert:
+      that:
+      - create_multiple_instances is not failed
+      - create_multiple_instances is changed
+      - '"instance_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"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 6
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      wait: true
+    register: create_multiple_instances
+
+  - name: debug is here
+    debug: msg="{{ create_multiple_instances.instance_ids }}"
+
+  - assert:
+      that:
+      - create_multiple_instances is not failed
+      - create_multiple_instances is changed
+      - '"instance_ids" in create_multiple_instances'
+      - create_multiple_instances.instance_ids | length == 3
+
+  - name: "Enforce instance count to 6 - Launch 3 more instances (check_mode - Idempotency)"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 6
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      wait: true
+    check_mode: true
+    register: create_multiple_instances
+
+  - assert:
+      that:
+      - create_multiple_instances is not failed
+      - create_multiple_instances is not changed
+      - '"instance_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)"
+    ec2_instance:
+      instance_type: "{{ ec2_instance_type }}"
+      exact_count: 6
+      region: "{{ aws_region }}"
+      image_id: "{{ ec2_ami_id }}"
+      name: "{{ resource_prefix }}-test-enf_cnt"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      wait: true
+    register: create_multiple_instances
+
+  - assert:
+      that:
+      - create_multiple_instances is not failed
+      - create_multiple_instances is not changed
+      - '"instance_ids" not in create_multiple_instances'
+      - '"ec2:RunInstances" not in create_multiple_instances.resource_actions'
+
+
+  - name: Gather information about any running instance with Name ending with "-test-enf_cnt"
+    ec2_instance_info:
+      region: "{{ ec2_region }}"
+      filters:
+        "tag:Name": "*-test-enf_cnt"
+        instance-state-name: [ "running"]
+    register: test_instances
+
+  - name: set fact
+    set_fact: test_instances_ids="{{ test_instances.instances[item].instance_id }}"
+    loop: "{{ range(0, test_instances.instances | length) | list }}"
+    register: test_instances_list
+
+  - name: Make a list of ids
+    set_fact: instances_to_terminate="{{ test_instances_list.results | map(attribute='ansible_facts.test_instances_ids') | list }}"
+
+# Terminate instances created in enforce count test
+
+  - name: "Terminate instance based on id (check_mode)"
+    ec2_instance:
+      state: absent
+      instance_ids:
+      - "{{ item }}"
+      wait: true
+    register: terminate_id
+    check_mode: true
+    with_items: "{{ instances_to_terminate }}"
+
+  - assert:
+      that:
+      - terminate_id is not failed
+      - terminate_id is changed
+
+  - name: "Terminate instance based on id"
+    ec2_instance:
+      state: absent
+      instance_ids:
+      - "{{ item }}"
+      wait: true
+    register: terminate_id
+    with_items: "{{ instances_to_terminate }}"
+
+  - assert:
+      that:
+      - terminate_id is not failed
+      - terminate_id is changed
+
+  - name: "Terminate instance based on id - Idempotency (check_mode)"
+    ec2_instance:
+      state: absent
+      instance_ids:
+      - "{{ item }}"
+      wait: true
+    register: terminate_id
+    check_mode: true
+    with_items: "{{ instances_to_terminate }}"
+
+  - assert:
+      that:
+      - terminate_id is not failed
+      - terminate_id is not changed
+
+  - name: "Terminate instance based on id - Idempotency"
+    ec2_instance:
+      state: absent
+      instance_ids:
+      - "{{ item }}"
+      wait: true
+    register: terminate_id
+    with_items: "{{ instances_to_terminate }}"
+
+  - assert:
+      that:
+      - terminate_id is not failed
+      - terminate_id is not changed