diff --git a/meta/runtime.yml b/meta/runtime.yml index df3b4370954..af2a85729fa 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -38,6 +38,7 @@ action_groups: - ec2_key - ec2_snapshot - ec2_snapshot_info + - ec2_spot_instance - ec2_tag - ec2_tag_info - ec2_vol diff --git a/plugins/modules/ec2_spot_instance.py b/plugins/modules/ec2_spot_instance.py new file mode 100644 index 00000000000..b523c957415 --- /dev/null +++ b/plugins/modules/ec2_spot_instance.py @@ -0,0 +1,624 @@ +#!/usr/bin/python +# This file is part of Ansible +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: ec2_spot_instance +version_added: 2.0.0 +short_description: request, stop, reboot or cancel spot instance +description: + - Creates or cancels spot instance requests. +author: + - Sri Rachana Achyuthuni (@srirachanaachyuthuni) +options: + zone_group: + description: + - Name for logical grouping of spot requests. + - All spot instances in the request are launched in the same availability zone. + type: str + client_token: + description: The idempotency token you provided when you launched the instance, if applicable. + type: str + count: + description: + - Number of instances to launch. + default: 1 + type: int + interruption: + description: + - The behavior when a Spot Instance is interrupted. + choices: [ "hibernate", "stop", "terminate" ] + type: str + default: terminate + launch_group: + description: + - Launch group for spot requests, see U(https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/how-spot-instances-work.html#spot-launch-group). + type: str + launch_specification: + description: + - The launch specification. + type: dict + suboptions: + security_group_ids: + description: + - Security group id (or list of ids) to use with the instance. + type: list + elements: str + security_groups: + description: + - Security group name (or list of group names) to use with the instance. + - Only supported with EC2 Classic. To launch in a VPC, use C(group_id) + type: list + elements: str + key_name: + description: + - Key to use on the instance. + - The SSH key must already exist in AWS in order to use this argument. + - Keys can be created / deleted using the M(amazon.aws.ec2_key) module. + type: str + subnet_id: + description: + - The ID of the subnet in which to launch the instance. + type: str + user_data: + description: + - The base64-encoded user data for the instance. User data is limited to 16 KB. + type: str + block_device_mappings: + description: + - A list of hash/dictionaries of volumes to add to the new instance. + type: list + elements: dict + suboptions: + device_name: + description: + - The device name (for example, /dev/sdh or xvdh ). + type: str + virtual_name: + description: + - The virtual device name + type: str + ebs: + description: + - Parameters used to automatically set up EBS volumes when the instance is launched, + see U(https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2.html#EC2.Client.request_spot_instances) + type: dict + no_device: + description: + - To omit the device from the block device mapping, specify an empty string. + type: str + ebs_optimized: + description: + - Whether instance is using optimized EBS volumes, see U(https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSOptimized.html). + default: false + type: bool + iam_instance_profile: + description: + - The IAM instance profile. + type: dict + suboptions: + arn: + description: + - The Amazon Resource Name (ARN) of the instance profile. + - Only one of I(arn) or I(name) may be specified. + type: str + name: + description: + - The name of the instance profile. + - Only one of I(arn) or I(name) may be specified. + type: str + image_id: + description: + - The ID of the AMI. + type: str + instance_type: + description: + - Instance type to use for the instance, see U(https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-types.html). + - Required when creating a new instance. + type: str + kernel_id: + description: + - The ID of the kernel. + type: str + network_interfaces: + description: + - One or more network interfaces. If you specify a network interface, you must specify subnet IDs and security group IDs using the network interface. + type: list + elements: dict + suboptions: + associate_public_ip_address: + description: + - Indicates whether to assign a public IPv4 address to an instance you launch in a VPC. + type: bool + delete_on_termination: + description: + - If set to true , the interface is deleted when the instance is terminated. + You can specify true only if creating a new network interface when launching an instance. + type: bool + description: + description: + - The description of the network interface. Applies only if creating a network interface when launching an instance. + type: str + device_index: + description: + - The position of the network interface in the attachment order. A primary network interface has a device index of 0. + - If you specify a network interface when launching an instance, you must specify the device index. + type: int + groups: + description: + - The IDs of the security groups for the network interface. Applies only if creating a network interface when launching an instance. + type: list + elements: str + ipv6_address_count: + description: + - A number of IPv6 addresses to assign to the network interface + type: int + ipv6_addresses: + description: + - One or more IPv6 addresses to assign to the network interface. + type: list + elements: dict + suboptions: + ipv6address: + description: The IPv6 address. + type: str + network_interface_id: + description: + - The ID of the network interface. + type: str + private_ip_address: + description: + - The private IPv4 address of the network interface + type: str + private_ip_addresses: + description: + - One or more private IPv4 addresses to assign to the network interface + type: list + elements: dict + secondary_private_ip_address_count: + description: + - The number of secondary private IPv4 addresses. + type: int + subnet_id: + description: + - The ID of the subnet associated with the network interface + type: str + associate_carrier_ip_address: + description: + - Indicates whether to assign a carrier IP address to the network interface. + type: bool + interface_type: + description: + - The type of network interface. + type: str + choices: ['interface', 'efa'] + network_card_index: + description: + - The index of the network card. + type: int + ipv4_prefixes: + description: + - One or more IPv4 delegated prefixes to be assigned to the network interface. + type: list + elements: dict + ipv4_prefix_count: + description: + - The number of IPv4 delegated prefixes to be automatically assigned to the network interface + type: int + ipv6_prefixes: + description: + - One or more IPv6 delegated prefixes to be assigned to the network interface + type: list + elements: dict + ipv6_prefix_count: + description: + - The number of IPv6 delegated prefixes to be automatically assigned to the network interface + type: int + placement: + description: + - The placement information for the instance. + type: dict + suboptions: + availability_zone: + description: + - The Availability Zone. + type: str + group_name: + description: + - The name of the placement group. + type: str + tenancy: + description: + - the tenancy of the host + type: str + choices: ['default', 'dedicated', 'host'] + default: default + ramdisk_id: + description: + - The ID of the RAM disk. + type: str + monitoring: + description: + - Indicates whether basic or detailed monitoring is enabled for the instance. + type: dict + suboptions: + enabled: + description: + - Indicates whether detailed monitoring is enabled. Otherwise, basic monitoring is enabled. + type: bool + default: false + state: + description: + - Whether the spot request should be created or removed. + - When I(state=present), I(launch_specification) is required. + - When I(state=absent), I(spot_instance_request_ids) is required. + default: 'present' + choices: [ 'absent', 'present' ] + type: str + spot_price: + description: + - Maximum spot price to bid. If not set, a regular on-demand instance is requested. + - A spot request is made with this maximum bid. When it is filled, the instance is started. + type: str + spot_type: + description: + - The type of spot request. + - After being interrupted a C(persistent) spot instance will be started once there is capacity to fill the request again. + default: 'one-time' + choices: [ "one-time", "persistent" ] + type: str + tags: + description: + - A dictionary of key-value pairs for tagging the Spot Instance request on creation. + type: dict + spot_instance_request_ids: + description: + - List of strings with IDs of spot requests to be cancelled + default: [] + type: list + elements: str +extends_documentation_fragment: +- amazon.aws.aws +- amazon.aws.ec2 +''' + +EXAMPLES = ''' +# Note: These examples do not set authentication details, see the AWS Guide for details. + +- name: Simple Spot Request Creation + amazon.aws.ec2_spot_instance: + launch_specification: + image_id: ami-123456789 + key_name: my-keypair + instance_type: t2.medium + +- name: Spot Request Creation with more options + amazon.aws.ec2_spot_instance: + launch_specification: + image_id: ami-123456789 + key_name: my-keypair + instance_type: t2.medium + subnet_id: subnet-12345678 + block_device_mappings: + - device_name: /dev/sdb + ebs: + delete_on_termination: True + volume_type: gp3 + volume_size: 5 + - device_name: /dev/sdc + ebs: + delete_on_termination: True + volume_type: io2 + volume_size: 30 + network_interfaces: + - associate_public_ip_address: False + delete_on_termination: True + device_index: 0 + placement: + availability_zone: us-west-2a + monitoring: + enabled: False + spot_price: 0.002 + tags: + Environment: Testing + +- name: Spot Request Termination + amazon.aws.ec2_spot_instance: + spot_instance_request_ids: ['sir-12345678', 'sir-abcdefgh'] + state: absent +''' + +RETURN = ''' +spot_request: + description: The spot instance request details after creation + returned: when success + type: dict + sample: { + "create_time": "2021-08-23T22:59:12+00:00", + "instance_interruption_behavior": "terminate", + "launch_specification": { + "block_device_mappings": [ + { + "device_name": "/dev/sdb", + "ebs": { + "delete_on_termination": true, + "volume_size": 5, + "volume_type": "gp3" + } + } + ], + "ebs_optimized": false, + "iam_instance_profile": { + "arn": "arn:aws:iam::EXAMPLE:instance-profile/myinstanceprofile" + }, + "image_id": "ami-083ac7c7ecf9bb9b0", + "instance_type": "t2.small", + "key_name": "mykey", + "monitoring": { + "enabled": false + }, + "network_interfaces": [ + { + "associate_public_ip_address": false, + "delete_on_termination": true, + "device_index": 0 + } + ], + "placement": { + "availability_zone": "us-west-2a", + "tenancy": "default" + }, + "security_groups": [ + { + "group_name": "default" + } + ] + }, + "product_description": "Linux/UNIX", + "spot_instance_request_id": "sir-1234abcd", + "spot_price": "0.00600", + "state": "open", + "status": { + "code": "pending-evaluation", + "message": "Your Spot request has been submitted for review, and is pending evaluation.", + "update_time": "2021-08-23T22:59:12+00:00" + }, + "type": "one-time" + + } + +cancelled_spot_request: + description: The spot instance request details that has been cancelled + returned: always + type: str + sample: 'Spot requests with IDs: sir-1234abcd have been cancelled' +''' +import time +import datetime + +try: + import botocore +except ImportError: + pass # Handled by AnsibleAWSModule +from ..module_utils.core import AnsibleAWSModule +from ..module_utils.ec2 import AWSRetry +from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict +from ..module_utils.ec2 import ansible_dict_to_boto3_tag_list +from ..module_utils.ec2 import boto3_tag_list_to_ansible_dict +from ..module_utils.core import is_boto3_error_code + + +def build_launch_specification(launch_spec): + """ + Remove keys that have a value of None from Launch Specification + Descend into these subkeys: + network_interfaces + block_device_mappings + monitoring + placement + iam_instance_profile + """ + assigned_keys = dict((k, v) for k, v in launch_spec.items() if v is not None) + + sub_key_to_build = ['placement', 'iam_instance_profile', 'monitoring'] + for subkey in sub_key_to_build: + if launch_spec[subkey] is not None: + assigned_keys[subkey] = dict((k, v) for k, v in launch_spec[subkey].items() if v is not None) + + if launch_spec['network_interfaces'] is not None: + interfaces = [] + for iface in launch_spec['network_interfaces']: + interfaces.append(dict((k, v) for k, v in iface.items() if v is not None)) + assigned_keys['network_interfaces'] = interfaces + + if launch_spec['block_device_mappings'] is not None: + block_devs = [] + for dev in launch_spec['block_device_mappings']: + block_devs.append( + dict((k, v) for k, v in dev.items() if v is not None)) + assigned_keys['block_device_mappings'] = block_devs + + return snake_dict_to_camel_dict(assigned_keys, capitalize_first=True) + + +def request_spot_instances(module, connection): + + # connection.request_spot_instances() always creates a new spot request + changed = True + + if module.check_mode: + module.exit_json(changed=changed) + + params = {} + + if module.params.get('launch_specification'): + params['LaunchSpecification'] = build_launch_specification(module.params.get('launch_specification')) + + if module.params.get('zone_group'): + params['AvailabilityZoneGroup'] = module.params.get('zone_group') + + if module.params.get('count'): + params['InstanceCount'] = module.params.get('count') + + if module.params.get('launch_group'): + params['LaunchGroup'] = module.params.get('launch_group') + + if module.params.get('spot_price'): + params['SpotPrice'] = module.params.get('spot_price') + + if module.params.get('spot_type'): + params['Type'] = module.params.get('spot_type') + + if module.params.get('client_token'): + params['ClientToken'] = module.params.get('client_token') + + if module.params.get('interruption'): + params['InstanceInterruptionBehavior'] = module.params.get('interruption') + + if module.params.get('tags'): + params['TagSpecifications'] = [{ + 'ResourceType': 'spot-instances-request', + 'Tags': ansible_dict_to_boto3_tag_list(module.params.get('tags')), + }] + + # TODO: add support for datetime-based parameters + # params['ValidFrom'] = module.params.get('valid_from') + # params['ValidUntil'] = module.params.get('valid_until') + + try: + request_spot_instance_response = (connection.request_spot_instances(aws_retry=True, **params))['SpotInstanceRequests'][0] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Error while creating the spot instance request') + + request_spot_instance_response['Tags'] = boto3_tag_list_to_ansible_dict(request_spot_instance_response.get('Tags', [])) + spot_request = camel_dict_to_snake_dict(request_spot_instance_response, ignore_list=['Tags']) + module.exit_json(spot_request=spot_request, changed=changed) + + +def cancel_spot_instance_requests(module, connection): + + changed = False + spot_instance_request_ids = module.params.get('spot_instance_request_ids') + requests_exist = dict() + try: + paginator = connection.get_paginator('describe_spot_instance_requests').paginate(SpotInstanceRequestIds=spot_instance_request_ids, + Filters=[{'Name': 'state', 'Values': ['open', 'active']}]) + jittered_retry = AWSRetry.jittered_backoff() + requests_exist = jittered_retry(paginator.build_full_result)() + except is_boto3_error_code('InvalidSpotInstanceRequestID.NotFound'): + requests_exist['SpotInstanceRequests'] = [] + except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, msg="Failure when describing spot requests") + + try: + if len(requests_exist['SpotInstanceRequests']) > 0: + changed = True + if module.check_mode: + module.exit_json(changed=changed, + 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')) + 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') + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Error while cancelling the spot instance request') + + +def main(): + network_interface_options = dict( + associate_public_ip_address=dict(type='bool'), + delete_on_termination=dict(type='bool'), + description=dict(type='str'), + device_index=dict(type='int'), + groups=dict(type='list', elements='str'), + ipv6_address_count=dict(type='int'), + ipv6_addresses=dict(type='list', elements='dict', options=dict(ipv6address=dict(type='str'))), + network_interface_id=dict(type='str'), + private_ip_address=dict(type='str'), + private_ip_addresses=dict(type='list', elements='dict'), + secondary_private_ip_address_count=dict(type='int'), + subnet_id=dict(type='str'), + associate_carrier_ip_address=dict(type='bool'), + interface_type=dict(type='str', choices=['interface', 'efa']), + network_card_index=dict(type='int'), + ipv4_prefixes=dict(type='list', elements='dict'), + ipv4_prefix_count=dict(type='int'), + ipv6_prefixes=dict(type='list', elements='dict'), + ipv6_prefix_count=dict(type='int') + ) + block_device_mappings_options = dict( + device_name=dict(type='str'), + virtual_name=dict(type='str'), + ebs=dict(type='dict'), + no_device=dict(type='str'), + ) + monitoring_options = dict( + enabled=dict(type='bool', default=False) + ) + placement_options = dict( + availability_zone=dict(type='str'), + group_name=dict(type='str'), + tenancy=dict(type='str', choices=['default', 'dedicated', 'host'], default='default') + ) + iam_instance_profile_options = dict( + arn=dict(type='str'), + name=dict(type='str') + ) + launch_specification_options = dict( + security_group_ids=dict(type='list', elements='str'), + security_groups=dict(type='list', elements='str'), + block_device_mappings=dict(type='list', elements='dict', options=block_device_mappings_options), + ebs_optimized=dict(type='bool', default=False), + iam_instance_profile=dict(type='dict', options=iam_instance_profile_options), + image_id=dict(type='str'), + instance_type=dict(type='str'), + kernel_id=dict(type='str'), + key_name=dict(type='str'), + monitoring=dict(type='dict', options=monitoring_options), + network_interfaces=dict(type='list', elements='dict', options=network_interface_options, default=[]), + placement=dict(type='dict', options=placement_options), + ramdisk_id=dict(type='str'), + user_data=dict(type='str'), + subnet_id=dict(type='str') + ) + + argument_spec = dict( + zone_group=dict(type='str'), + client_token=dict(type='str', no_log=False), + count=dict(type='int', default=1), + interruption=dict(type='str', default="terminate", choices=['hibernate', 'stop', 'terminate']), + launch_group=dict(type='str'), + launch_specification=dict(type='dict', options=launch_specification_options), + state=dict(default='present', choices=['present', 'absent']), + spot_price=dict(type='str'), + spot_type=dict(default='one-time', choices=["one-time", "persistent"]), + 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'), + ) + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True + ) + + connection = module.client('ec2', retry_decorator=AWSRetry.jittered_backoff()) + + state = module.params['state'] + + if state == 'present': + request_spot_instances(module, connection) + + if state == 'absent': + cancel_spot_instance_requests(module, connection) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/ec2_spot_instance/aliases b/tests/integration/targets/ec2_spot_instance/aliases new file mode 100644 index 00000000000..4ef4b2067d0 --- /dev/null +++ b/tests/integration/targets/ec2_spot_instance/aliases @@ -0,0 +1 @@ +cloud/aws diff --git a/tests/integration/targets/ec2_spot_instance/defaults/main.yml b/tests/integration/targets/ec2_spot_instance/defaults/main.yml new file mode 100644 index 00000000000..cb3895af089 --- /dev/null +++ b/tests/integration/targets/ec2_spot_instance/defaults/main.yml @@ -0,0 +1,14 @@ +--- +vpc_seed_a: '{{ resource_prefix }}' +vpc_seed_b: '{{ resource_prefix }}-ec2_eni' +vpc_prefix: '10.{{ 256 | random(seed=vpc_seed_a) }}.{{ 256 | random(seed=vpc_seed_b ) }}' +vpc_cidr: '{{ vpc_prefix}}.128/26' +ip_1: "{{ vpc_prefix }}.132" +ip_2: "{{ vpc_prefix }}.133" +ip_3: "{{ vpc_prefix }}.134" +ip_4: "{{ vpc_prefix }}.135" +ip_5: "{{ vpc_prefix }}.136" + +ec2_ips: +- "{{ vpc_prefix }}.137" +- "{{ vpc_prefix }}.138" diff --git a/tests/integration/targets/ec2_spot_instance/tasks/main.yaml b/tests/integration/targets/ec2_spot_instance/tasks/main.yaml new file mode 100644 index 00000000000..6f1515f8aa8 --- /dev/null +++ b/tests/integration/targets/ec2_spot_instance/tasks/main.yaml @@ -0,0 +1,265 @@ +--- +- module_defaults: + group/aws: + aws_access_key: "{{ aws_access_key }}" + aws_secret_key: "{{ aws_secret_key }}" + security_token: "{{ security_token | default(omit) }}" + region: "{{ aws_region }}" + + collections: + - community.aws + + block: + - name: Get available AZs + aws_az_info: + filters: + region-name: "{{ aws_region }}" + register: az_info + + - name: Pick an AZ + set_fact: + availability_zone: "{{ az_info['availability_zones'][0]['zone_name'] }}" + + # ============================================================ + - name: create a VPC + ec2_vpc_net: + name: "{{ resource_prefix }}-vpc" + state: present + cidr_block: "{{ vpc_cidr }}" + tags: + Name: "{{ resource_prefix }}-vpc" + Description: "Created by ansible-test" + register: vpc_result + + - name: create a subnet + ec2_vpc_subnet: + cidr: "{{ vpc_cidr }}" + az: "{{ availability_zone }}" + vpc_id: "{{ vpc_result.vpc.id }}" + tags: + Name: "{{ resource_prefix }}-vpc" + Description: "Created by ansible-test" + state: present + register: vpc_subnet_result + + - name: create a security group + ec2_group: + name: "{{ resource_prefix }}-sg" + description: "Created by {{ resource_prefix }}" + rules: [] + state: present + vpc_id: "{{ vpc_result.vpc.id }}" + register: vpc_sg_result + + - name: Get a list of images + ec2_ami_info: + filters: + owner-alias: amazon + name: "amzn2-ami-minimal-hvm-*" + description: "Amazon Linux 2 AMI *" + register: images_info + + - name: create a new ec2 key pair + ec2_key: + name: "{{ resource_prefix }}-keypair" + + - name: Set facts to simplify use of extra resources + set_fact: + vpc_id: "{{ vpc_result.vpc.id }}" + vpc_subnet_id: "{{ vpc_subnet_result.subnet.id }}" + vpc_sg_id: "{{ vpc_sg_result.group_id }}" + image_id: "{{ images_info.images | sort(attribute='creation_date') | reverse | first | json_query('image_id') }}" + + # ============================================================ + + # Assert that spot instance request is created + - name: Create simple spot instance request + ec2_spot_instance: + launch_specification: + image_id: "{{ image_id }}" + key_name: "{{ resource_prefix }}-keypair" + instance_type: "t2.medium" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + register: create_result + + - name: Assert that result has changed and request has been created + assert: + that: + - create_result is changed + - create_result.spot_request is defined + - create_result.spot_request.spot_instance_request_id is defined + - create_result.spot_request.launch_specification.subnet_id == vpc_subnet_result.subnet.id + + - name: Create spot request with more complex options + ec2_spot_instance: + launch_specification: + image_id: "{{ image_id }}" + key_name: "{{ resource_prefix }}-keypair" + instance_type: "t2.medium" + block_device_mappings: + - device_name: /dev/sdb + ebs: + delete_on_termination: True + volume_type: gp3 + volume_size: 5 + network_interfaces: + - associate_public_ip_address: False + delete_on_termination: True + device_index: 0 + placement: + availability_zone: '{{ availability_zone }}' + monitoring: + enabled: False + spot_price: 0.002 + tags: + camelCase: "helloWorld" + PascalCase: "HelloWorld" + snake_case: "hello_world" + "Title Case": "Hello World" + "lowercase spaced": "hello world" + register: complex_create_result + + - assert: + that: + - complex_create_result is changed + - complex_create_result.spot_request is defined + - complex_create_result.spot_request.spot_instance_request_id is defined + - complex_create_result.spot_request.type == 'one-time' + - '"0.002" in complex_create_result.spot_request.spot_price' ## AWS pads trailing zeros on the spot price + - launch_spec.placement.availability_zone == availability_zone + - launch_spec.block_device_mappings|length == 1 + - launch_spec.block_device_mappings.0.ebs.delete_on_termination == true + - launch_spec.block_device_mappings.0.ebs.volume_type == 'gp3' + - launch_spec.block_device_mappings.0.ebs.volume_size == 5 + - launch_spec.network_interfaces|length == 1 + - launch_spec.network_interfaces.0.device_index == 0 + - launch_spec.network_interfaces.0.associate_public_ip_address == false + - launch_spec.network_interfaces.0.delete_on_termination == true + - spot_request_tags|length == 5 + - spot_request_tags['camelCase'] == 'helloWorld' + - spot_request_tags['PascalCase'] == 'HelloWorld' + - spot_request_tags['snake_case'] == 'hello_world' + - spot_request_tags['Title Case'] == 'Hello World' + - spot_request_tags['lowercase spaced'] == 'hello world' + vars: + launch_spec: '{{ complex_create_result.spot_request.launch_specification }}' + spot_request_tags: '{{ complex_create_result.spot_request.tags }}' + + # Assert check mode + - name: Create spot instance request (check_mode) + ec2_spot_instance: + launch_specification: + image_id: "{{ image_id }}" + key_name: "{{ resource_prefix }}-keypair" + instance_type: "t2.medium" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + check_mode: True + register: check_create_result + + - assert: + that: + - check_create_result is changed + + - name: Remove spot instance request (check_mode) + ec2_spot_instance: + spot_instance_request_ids: '{{ create_result.spot_request.spot_instance_request_id }}' + state: absent + check_mode: True + register: check_cancel_result + + - assert: + that: + - check_cancel_result is changed + + - name: Remove spot instance requests + ec2_spot_instance: + spot_instance_request_ids: + - '{{ create_result.spot_request.spot_instance_request_id }}' + - '{{ complex_create_result.spot_request.spot_instance_request_id }}' + state: absent + register: cancel_result + + - assert: + that: + - cancel_result is changed + - '"Cancelled Spot request" in cancel_result.msg' + + - name: Sometimes we run the next test before the EC2 API is fully updated from the previous task + pause: + seconds: 3 + + - name: Check no change if request is already cancelled (idempotency) + ec2_spot_instance: + spot_instance_request_ids: '{{ create_result.spot_request.spot_instance_request_id }}' + state: absent + register: cancel_request_again + + - assert: + that: + - cancel_request_again is not changed + - '"Spot request not found or already cancelled" in cancel_request_again.msg' + + - name: Gracefully try to remove non-existent request (NotFound) + ec2_spot_instance: + spot_instance_request_ids: + - sir-12345678 + state: absent + register: fake_cancel_result + + - assert: + that: + - fake_cancel_result is not changed + - '"Spot request not found or already cancelled" in fake_cancel_result.msg' + + + always: + + # ============================================================ + - name: Delete spot instances + ec2_instance: + state: absent + filters: + vpc-id: "{{ vpc_result.vpc.id }}" + + - name: remove spot instance requests + ec2_spot_instance: + spot_instance_request_ids: + - '{{ create_result.spot_request.spot_instance_request_id }}' + - '{{ complex_create_result.spot_request.spot_instance_request_id }}' + state: 'absent' + ignore_errors: true + retries: 5 + + - name: remove the security group + ec2_group: + name: "{{ resource_prefix }}-sg" + description: "{{ resource_prefix }}" + rules: [] + state: absent + vpc_id: "{{ vpc_result.vpc.id }}" + ignore_errors: true + retries: 5 + + - name: remove the subnet + ec2_vpc_subnet: + cidr: "{{ vpc_cidr }}" + az: "{{ availability_zone }}" + vpc_id: "{{ vpc_result.vpc.id }}" + state: absent + ignore_errors: true + retries: 5 + when: vpc_subnet_result is defined + + - name: remove the VPC + ec2_vpc_net: + name: "{{ resource_prefix }}-vpc" + cidr_block: "{{ vpc_cidr }}" + state: absent + ignore_errors: true + retries: 5 + + - name: remove key pair by name + ec2_key: + name: "{{ resource_prefix }}-keypair" + state: absent + ignore_errors: true