Repository: ansible-collections/amazon.aws

Issue:
AWS: Cancelling EC2 Spot Request
### Summary

When submitting spot cancellation request through module amazon.aws.ec2_spot_instance module, only the spot request is cancelled. The EC2 instance provisioned by the spot instance continues to run. No option in the module to terminate the spot instance.

        - name: Create a new spot request
          amazon.aws.ec2_spot_instance:
            region: "{{ region }}"
            spot_type: persistent
            spot_price: 0.0031
            interruption: stop
            launch_specification:
              security_group_ids : "{{ security_group_id }}"
              instance_type: "{{ instance_type }}"
              image_id: "{{ image }}"
              subnet_id: "{{ subnet }}"
         register: spotReq

        - name: Spot Request Termination
          amazon.aws.ec2_spot_instance:
            region: "{{ region }}"
            spot_instance_request_ids: ["{{ spotReq.spot_request.spot_instance_request_id }}"]
            state: absent


### Issue Type

Bug Report

### Component Name

Ansible AWS module

### Ansible Version

```
$ ansible --version
/usr/local/lib/python3.6/site-packages/ansible/parsing/vault/__init__.py:44: CryptographyDeprecationWarning: Python 3.6 is no longer supported by the Python core team. Therefore, support for it is deprecated in cryptography and will be removed in a future release.
  from cryptography.exceptions import InvalidSignature
ansible [core 2.11.12]
  config file = /home/centos/ansible/ansible.cfg
  configured module search path = ['/home/centos/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/local/lib/python3.6/site-packages/ansible
  ansible collection location = /home/centos/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/local/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.3
  libyaml = True


```


### Collection Versions

```
$ ansible-galaxy collection list
/usr/local/lib/python3.6/site-packages/ansible/parsing/vault/__init__.py:44: CryptographyDeprecationWarning: Python 3.6 is no longer supported by the Python core team. Therefore, support for it is deprecated in cryptography and will be removed in a future release.
  from cryptography.exceptions import InvalidSignature

# /home/centos/.ansible/collections/ansible_collections
Collection    Version
------------- -------
amazon.aws    5.2.0
community.aws 5.2.0

# /usr/local/lib/python3.6/site-packages/ansible_collections
Collection                    Version
----------------------------- -------
amazon.aws                    1.5.1
ansible.netcommon             2.5.0
ansible.posix                 1.3.0
ansible.utils                 2.4.3
ansible.windows               1.8.0
arista.eos                    2.2.0
awx.awx                       19.4.0
azure.azcollection            1.10.0
check_point.mgmt              2.2.0
chocolatey.chocolatey         1.1.0
cisco.aci                     2.1.0
cisco.asa                     2.1.0
cisco.intersight              1.0.18
cisco.ios                     2.6.0
cisco.iosxr                   2.6.0
cisco.meraki                  2.5.0
cisco.mso                     1.2.0
cisco.nso                     1.0.3
cisco.nxos                    2.8.2
cisco.ucs                     1.6.0
cloudscale_ch.cloud           2.2.0
community.aws                 1.5.0
community.azure               1.1.0
community.crypto              1.9.8
community.digitalocean        1.13.0
community.docker              1.10.2
community.fortios             1.0.0
community.general             3.8.3
community.google              1.0.0
community.grafana             1.3.0
community.hashi_vault         1.5.0
community.hrobot              1.2.1
community.kubernetes          1.2.1
community.kubevirt            1.0.0
community.libvirt             1.0.2
community.mongodb             1.3.2
community.mysql               2.3.2
community.network             3.0.0
community.okd                 1.1.2
community.postgresql          1.6.0
community.proxysql            1.3.0
community.rabbitmq            1.1.0
community.routeros            1.2.0
community.skydive             1.0.0
community.sops                1.2.0
community.vmware              1.17.0
community.windows             1.8.0
community.zabbix              1.5.1
containers.podman             1.9.0
cyberark.conjur               1.1.0
cyberark.pas                  1.0.13
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.13.0
fortinet.fortimanager         2.1.4
fortinet.fortios              2.1.3
frr.frr                       1.0.3
gluster.gluster               1.0.2
google.cloud                  1.0.2
hetzner.hcloud                1.6.0
hpe.nimble                    1.1.4
ibm.qradar                    1.0.3
infinidat.infinibox           1.3.0
inspur.sm                     1.3.0
junipernetworks.junos         2.8.0
kubernetes.core               1.2.1
mellanox.onyx                 1.0.0
netapp.aws                    21.7.0
netapp.azure                  21.10.0
netapp.cloudmanager           21.12.1
netapp.elementsw              21.7.0
netapp.ontap                  21.14.1
netapp.um_info                21.8.0
netapp_eseries.santricity     1.2.13
netbox.netbox                 3.4.0
ngine_io.cloudstack           2.2.2
ngine_io.exoscale             1.0.0
ngine_io.vultr                1.1.0
openstack.cloud               1.5.3
openvswitch.openvswitch       2.1.0
ovirt.ovirt                   1.6.6
purestorage.flasharray        1.11.0
purestorage.flashblade        1.8.1
sensu.sensu_go                1.12.0
servicenow.servicenow         1.0.6
splunk.es                     1.0.2
t_systems_mms.icinga_director 1.26.0
theforeman.foreman            2.2.0
vyos.vyos                     2.6.0
wti.remote                    1.0.3

```


### AWS SDK versions

```
$ 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: /home/centos/.local/lib/python3.6/site-packages
Requires:
Required-by:
---
Name: boto3
Version: 1.23.10
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/centos/.local/lib/python3.6/site-packages
Requires: botocore, jmespath, s3transfer
Required-by:
---
Name: botocore
Version: 1.26.10
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/centos/.local/lib/python3.6/site-packages
Requires: jmespath, python-dateutil, urllib3
Required-by: boto3, s3transfer

```


### Configuration

```
$ ansible-config dump --only-changed
/usr/local/lib/python3.6/site-packages/ansible/parsing/vault/__init__.py:44: CryptographyDeprecationWarning: Python 3.6 is no longer supported by the Python core team. Therefore, support for it is deprecated in cryptography and will be removed in a future release.
  from cryptography.exceptions import InvalidSignature
ANSIBLE_FORCE_COLOR(env: ANSIBLE_FORCE_COLOR) = True
DEPRECATION_WARNINGS(env: ANSIBLE_DEPRECATION_WARNINGS) = False
```


### OS / Environment

[ centos@workstation ~/ansible ]$ cat /etc/*release*
CentOS Linux release 7.9.2009 (Core)
Derived from Red Hat Enterprise Linux 7.8 (Source)
NAME="CentOS Linux"
VERSION="7 (Core)"
ID="centos"
ID_LIKE="rhel fedora"
VERSION_ID="7"
PRETTY_NAME="CentOS Linux 7 (Core)"
ANSI_COLOR="0;31"
CPE_NAME="cpe:/o:centos:centos:7"
HOME_URL="https://www.centos.org/"
BUG_REPORT_URL="https://bugs.centos.org/"

CENTOS_MANTISBT_PROJECT="CentOS-7"
CENTOS_MANTISBT_PROJECT_VERSION="7"
REDHAT_SUPPORT_PRODUCT="centos"
REDHAT_SUPPORT_PRODUCT_VERSION="7"

CentOS Linux release 7.9.2009 (Core)
CentOS Linux release 7.9.2009 (Core)
cpe:/o:centos:centos:7

[ centos@workstation ~/ansible ]$ uname -a
Linux ip-172-31-66-217.ec2.internal 3.10.0-1160.el7.x86_64 #1 SMP Mon Oct 19 16:18:59 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

[ centos@workstation ~/ansible ]$


### Steps to Reproduce

```
        - name: Create a new spot request
          amazon.aws.ec2_spot_instance:
            region: "{{ region }}"
            spot_type: persistent
            spot_price: 0.0031
            interruption: stop
            launch_specification:
              security_group_ids : "{{ security_group_id }}"
              instance_type: "{{ instance_type }}"
              image_id: "{{ image }}"
              subnet_id: "{{ subnet }}"
         register: spotReq

        - name: Spot Request Termination
          amazon.aws.ec2_spot_instance:
            region: "{{ region }}"
            spot_instance_request_ids: ["{{ spotReq.spot_request.spot_instance_request_id }}"]
            state: absent

```


### Expected Results

Cancelling the spot request should terminate the instance created by the spot request.

### Actual Results

```
Spot request shows below status:

[ centos@workstation ~/ansible ]$ aws ec2 describe-spot-instance-requests --spot-instance-request-ids sir-84ryjv2k|jq '.SpotInstanceRequests[].Status'
{
  "Code": "request-canceled-and-instance-running",
  "Message": "Your Spot request is canceled, but your instance i-06954e3d6398b72f6 is still running.",
  "UpdateTime": "2023-02-11T05:56:55+00:00"
}

Instance stays in "Running" state till terminated manually. 
```

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


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 --->

Resolving patch:
diff --git a/plugins/modules/ec2_spot_instance.py b/plugins/modules/ec2_spot_instance.py
--- a/plugins/modules/ec2_spot_instance.py
+++ b/plugins/modules/ec2_spot_instance.py
@@ -278,6 +278,13 @@
       - List of strings with IDs of spot requests to be cancelled
     type: list
     elements: str
+  terminate_instances:
+    description:
+      - Boolean value to set whether or not to terminate instances associated to spot request.
+      - Can be used only when I(state=absent).
+    default: False
+    type: bool
+    version_added: 5.4.0
 extends_documentation_fragment:
   - amazon.aws.common.modules
   - amazon.aws.region.modules
@@ -523,6 +530,11 @@ def cancel_spot_instance_requests(module, connection):
                                  msg='Would have cancelled Spot request {0}'.format(spot_instance_request_ids))
 
             connection.cancel_spot_instance_requests(aws_retry=True, SpotInstanceRequestIds=module.params.get('spot_instance_request_ids'))
+
+            if module.params.get("terminate_instances") is True:
+                associated_instances = [request["InstanceId"] for request in requests_exist["SpotInstanceRequests"]]
+                terminate_associated_instances(connection, module, associated_instances)
+
             module.exit_json(changed=changed, msg='Cancelled Spot request {0}'.format(module.params.get('spot_instance_request_ids')))
         else:
             module.exit_json(changed=changed, msg='Spot request not found or already cancelled')
@@ -530,6 +542,13 @@ def cancel_spot_instance_requests(module, connection):
         module.fail_json_aws(e, msg='Error while cancelling the spot instance request')
 
 
+def terminate_associated_instances(connection, module, instance_ids):
+    try:
+        connection.terminate_instances(aws_retry=True, InstanceIds=instance_ids)
+    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
+        module.fail_json(e, msg="Unable to terminate instances")
+
+
 def main():
     network_interface_options = dict(
         associate_public_ip_address=dict(type='bool'),
@@ -601,16 +620,21 @@ def main():
         tags=dict(type='dict'),
         # valid_from=dict(type='datetime', default=datetime.datetime.now()),
         # valid_until=dict(type='datetime', default=(datetime.datetime.now() + datetime.timedelta(minutes=60))
-        spot_instance_request_ids=dict(type='list', elements='str'),
+        spot_instance_request_ids=dict(type="list", elements="str"),
+        terminate_instances=dict(type="bool", default="False"),
     )
+
     module = AnsibleAWSModule(
         argument_spec=argument_spec,
         supports_check_mode=True
     )
 
-    connection = module.client('ec2', retry_decorator=AWSRetry.jittered_backoff())
+    state = module.params["state"]
 
-    state = module.params['state']
+    if module.params.get("terminate_instances") and state != "absent":
+        module.fail_json("terminate_instances can only be used when state is absent.")
+
+    connection = module.client('ec2', retry_decorator=AWSRetry.jittered_backoff())
 
     if state == 'present':
         request_spot_instances(module, connection)


Test patch:
diff --git a/tests/integration/targets/ec2_spot_instance/tasks/main.yaml b/tests/integration/targets/ec2_spot_instance/tasks/main.yaml
--- a/tests/integration/targets/ec2_spot_instance/tasks/main.yaml
+++ b/tests/integration/targets/ec2_spot_instance/tasks/main.yaml
@@ -64,6 +64,9 @@
 
   # ============================================================
 
+  - name: Run tests for termianting associated instances
+    import_tasks: terminate_associated_instances.yml
+
   # Assert that spot instance request is created
   - name: Create simple spot instance request
     ec2_spot_instance:
@@ -253,7 +256,6 @@
         - fake_cancel_result is not changed
         - '"Spot request not found or already cancelled" in fake_cancel_result.msg'
 
-
   always:
 
     # ============================================================
diff --git a/tests/integration/targets/ec2_spot_instance/tasks/terminate_associated_instances.yml b/tests/integration/targets/ec2_spot_instance/tasks/terminate_associated_instances.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_spot_instance/tasks/terminate_associated_instances.yml
@@ -0,0 +1,109 @@
+---
+- block:
+
+  # Spot instance request creation
+  - name: Simple Spot Request Creation
+    amazon.aws.ec2_spot_instance:
+      launch_specification:
+        image_id: "{{ ec2_ami_id }}"
+        key_name: "{{ resource_prefix }}-keypair"
+        instance_type: "t2.micro"
+        subnet_id: "{{ vpc_subnet_result.subnet.id }}"
+      tags:
+        ansible-test: "{{ resource_prefix }}"
+    register: create_result
+
+  # Get instance ID of associated spot instance request
+  - name: Get info about the spot instance request created
+    amazon.aws.ec2_spot_instance_info:
+      spot_instance_request_ids:
+        - "{{ create_result.spot_request.spot_instance_request_id }}"
+    register: spot_instance_info_result
+    retries: 5
+    until: spot_instance_info_result.spot_request[0].instance_id is defined
+
+  - name: Pause to allow instance launch
+    pause:
+      seconds: 60
+
+  - name: Get instance ID of the instance associated with above spot instance request
+    set_fact:
+      instance_id_1: "{{ spot_instance_info_result.spot_request[0].instance_id }}"
+
+  - name: Check state of instance - BEFORE request cancellation
+    amazon.aws.ec2_instance_info:
+      instance_ids: ["{{ instance_id_1 }}"]
+    register: instance_info_result
+
+  # Cancel spot instance request
+  - name: Spot Request Termination
+    amazon.aws.ec2_spot_instance:
+      spot_instance_request_ids:
+        - '{{ create_result.spot_request.spot_instance_request_id }}'
+      state: absent
+
+  # Verify that instance is not terminated and still running
+  - name: Check state of instance - AFTER request cancellation
+    amazon.aws.ec2_instance_info:
+      instance_ids: ["{{ instance_id_1 }}"]
+    register: instance_info_result
+
+  - assert:
+      that: instance_info_result.instances[0].state.name == 'running'
+
+#==========================================================================
+
+  # Spot instance request creation
+  - name: Simple Spot Request Creation
+    amazon.aws.ec2_spot_instance:
+      launch_specification:
+        image_id: "{{ ec2_ami_id }}"
+        key_name: "{{ resource_prefix }}-keypair"
+        instance_type: "t2.micro"
+        subnet_id: "{{ vpc_subnet_result.subnet.id }}"
+      tags:
+        ansible-test: "{{ resource_prefix }}"
+    register: create_result
+
+  # Get instance ID of associated spot instance request
+  - name: Get info about the spot instance request created
+    amazon.aws.ec2_spot_instance_info:
+      spot_instance_request_ids:
+        - "{{ create_result.spot_request.spot_instance_request_id }}"
+    register: spot_instance_info_result
+    retries: 5
+    until: spot_instance_info_result.spot_request[0].instance_id is defined
+
+  - name: Pause to allow instance launch
+    pause:
+      seconds: 60
+
+  - name: Get instance ID of the instance associated with above spot instance request
+    set_fact:
+      instance_id_2: "{{ spot_instance_info_result.spot_request[0].instance_id }}"
+
+  - name: Check state of instance - BEFORE request cancellation
+    amazon.aws.ec2_instance_info:
+      instance_ids: ["{{ instance_id_2 }}"]
+    register: instance_info_result
+
+  # Cancel spot instance request
+  - name: Spot Request Termination
+    amazon.aws.ec2_spot_instance:
+      spot_instance_request_ids:
+        - '{{ create_result.spot_request.spot_instance_request_id }}'
+      state: absent
+      terminate_instances: true
+
+  - name: wait for instance to terminate
+    pause:
+      seconds: 60
+
+  # Verify that instance is terminated or shutting-down
+  - name: Check state of instance - AFTER request cancellation
+    amazon.aws.ec2_instance_info:
+      instance_ids: ["{{ instance_id_2 }}"]
+    register: instance_info_result
+
+  - assert:
+      that: instance_info_result.instances[0].state.name in ['terminated', 'shutting-down']