Repository: ansible-collections/amazon.aws

Issue:
Add is_boto3_errror_message() partner to is_boto3_errror_code()
##### SUMMARY

Amazon has some examples where the exceptions they return are very broad.  Where we want to special case behaviour for specific failure modes is_boto3_error_message can help avoid the need to re-raise the exception, and reduce the chances of proper failures being dropped on the floor accidentally.

##### ISSUE TYPE
- Feature Pull Request

##### COMPONENT NAME

plugins/module_utils/core.py
plugins/modules/cloudformation_info.py
plugins/modules/ec2_vpc_net.py

##### ADDITIONAL INFORMATION

~~Still to do: Add unit tests~~


Discussion:

Resolving patch:
diff --git a/plugins/modules/ec2_eip.py b/plugins/modules/ec2_eip.py
new file mode 100644
--- /dev/null
+++ b/plugins/modules/ec2_eip.py
@@ -0,0 +1,664 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Copyright: Ansible Project
+# 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_eip
+version_added: 1.0.0
+short_description: manages EC2 elastic IP (EIP) addresses.
+description:
+  - This module can allocate or release an EIP.
+  - This module can associate/disassociate an EIP with instances or network interfaces.
+options:
+  device_id:
+    description:
+      - The id of the device for the EIP. Can be an EC2 Instance id or Elastic Network Interface (ENI) id.
+      - The I(instance_id) alias has been deprecated and will be removed after 2022-12-01.
+    required: false
+    aliases: [ instance_id ]
+    type: str
+  public_ip:
+    description:
+      - The IP address of a previously allocated EIP.
+      - When I(state=present) and device is specified, the EIP is associated with the device.
+      - When I(state=absent) and device is specified, the EIP is disassociated from the device.
+    aliases: [ ip ]
+    type: str
+  state:
+    description:
+      - When C(state=present), allocate an EIP or associate an existing EIP with a device.
+      - When C(state=absent), disassociate the EIP from the device and optionally release it.
+    choices: ['present', 'absent']
+    default: present
+    type: str
+  in_vpc:
+    description:
+      - Allocate an EIP inside a VPC or not.
+      - Required if specifying an ENI with I(device_id).
+    default: false
+    type: bool
+  reuse_existing_ip_allowed:
+    description:
+      - Reuse an EIP that is not associated to a device (when available), instead of allocating a new one.
+    default: false
+    type: bool
+  release_on_disassociation:
+    description:
+      - Whether or not to automatically release the EIP when it is disassociated.
+    default: false
+    type: bool
+  private_ip_address:
+    description:
+      - The primary or secondary private IP address to associate with the Elastic IP address.
+    type: str
+  allow_reassociation:
+    description:
+      -  Specify this option to allow an Elastic IP address that is already associated with another
+         network interface or instance to be re-associated with the specified instance or interface.
+    default: false
+    type: bool
+  tag_name:
+    description:
+      - When I(reuse_existing_ip_allowed=true), supplement with this option to only reuse
+        an Elastic IP if it is tagged with I(tag_name).
+    type: str
+  tag_value:
+    description:
+      - Supplements I(tag_name) but also checks that the value of the tag provided in I(tag_name) matches I(tag_value).
+    type: str
+  public_ipv4_pool:
+    description:
+      - Allocates the new Elastic IP from the provided public IPv4 pool (BYOIP)
+        only applies to newly allocated Elastic IPs, isn't validated when I(reuse_existing_ip_allowed=true).
+    type: str
+extends_documentation_fragment:
+  - amazon.aws.aws
+  - amazon.aws.ec2
+  - amazon.aws.tags
+
+author:
+  - "Rick Mendes (@rickmendes) <rmendes@illumina.com>"
+notes:
+  - There may be a delay between the time the EIP is assigned and when
+    the cloud instance is reachable via the new address. Use wait_for and
+    pause to delay further playbook execution until the instance is reachable,
+    if necessary.
+  - This module returns multiple changed statuses on disassociation or release.
+    It returns an overall status based on any changes occurring. It also returns
+    individual changed statuses for disassociation and release.
+  - Support for I(tags) and I(purge_tags) was added in release 2.1.0.
+'''
+
+EXAMPLES = '''
+# Note: These examples do not set authentication details, see the AWS Guide for details.
+
+- name: associate an elastic IP with an instance
+  amazon.aws.ec2_eip:
+    device_id: i-1212f003
+    ip: 93.184.216.119
+
+- name: associate an elastic IP with a device
+  amazon.aws.ec2_eip:
+    device_id: eni-c8ad70f3
+    ip: 93.184.216.119
+
+- name: associate an elastic IP with a device and allow reassociation
+  amazon.aws.ec2_eip:
+    device_id: eni-c8ad70f3
+    public_ip: 93.184.216.119
+    allow_reassociation: true
+
+- name: disassociate an elastic IP from an instance
+  amazon.aws.ec2_eip:
+    device_id: i-1212f003
+    ip: 93.184.216.119
+    state: absent
+
+- name: disassociate an elastic IP with a device
+  amazon.aws.ec2_eip:
+    device_id: eni-c8ad70f3
+    ip: 93.184.216.119
+    state: absent
+
+- name: allocate a new elastic IP and associate it with an instance
+  amazon.aws.ec2_eip:
+    device_id: i-1212f003
+
+- name: allocate a new elastic IP without associating it to anything
+  amazon.aws.ec2_eip:
+    state: present
+  register: eip
+
+- name: output the IP
+  ansible.builtin.debug:
+    msg: "Allocated IP is {{ eip.public_ip }}"
+
+- name: provision new instances with ec2
+  amazon.aws.ec2:
+    keypair: mykey
+    instance_type: c1.medium
+    image: ami-40603AD1
+    wait: true
+    group: webserver
+    count: 3
+  register: ec2
+
+- name: associate new elastic IPs with each of the instances
+  amazon.aws.ec2_eip:
+    device_id: "{{ item }}"
+  loop: "{{ ec2.instance_ids }}"
+
+- name: allocate a new elastic IP inside a VPC in us-west-2
+  amazon.aws.ec2_eip:
+    region: us-west-2
+    in_vpc: true
+  register: eip
+
+- name: output the IP
+  ansible.builtin.debug:
+    msg: "Allocated IP inside a VPC is {{ eip.public_ip }}"
+
+- name: allocate eip - reuse unallocated ips (if found) with FREE tag
+  amazon.aws.ec2_eip:
+    region: us-east-1
+    in_vpc: true
+    reuse_existing_ip_allowed: true
+    tag_name: FREE
+
+- name: allocate eip - reuse unallocated ips if tag reserved is nope
+  amazon.aws.ec2_eip:
+    region: us-east-1
+    in_vpc: true
+    reuse_existing_ip_allowed: true
+    tag_name: reserved
+    tag_value: nope
+
+- name: allocate new eip - from servers given ipv4 pool
+  amazon.aws.ec2_eip:
+    region: us-east-1
+    in_vpc: true
+    public_ipv4_pool: ipv4pool-ec2-0588c9b75a25d1a02
+
+- name: allocate eip - from a given pool (if no free addresses where dev-servers tag is dynamic)
+  amazon.aws.ec2_eip:
+    region: us-east-1
+    in_vpc: true
+    reuse_existing_ip_allowed: true
+    tag_name: dev-servers
+    public_ipv4_pool: ipv4pool-ec2-0588c9b75a25d1a02
+
+- name: allocate eip from pool - check if tag reserved_for exists and value is our hostname
+  amazon.aws.ec2_eip:
+    region: us-east-1
+    in_vpc: true
+    reuse_existing_ip_allowed: true
+    tag_name: reserved_for
+    tag_value: "{{ inventory_hostname }}"
+    public_ipv4_pool: ipv4pool-ec2-0588c9b75a25d1a02
+'''
+
+RETURN = '''
+allocation_id:
+  description: allocation_id of the elastic ip
+  returned: on success
+  type: str
+  sample: eipalloc-51aa3a6c
+public_ip:
+  description: an elastic ip address
+  returned: on success
+  type: str
+  sample: 52.88.159.209
+'''
+
+try:
+    import botocore.exceptions
+except ImportError:
+    pass  # caught by AnsibleAWSModule
+
+from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule
+from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ansible_dict_to_boto3_filter_list
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ensure_ec2_tags
+
+
+def associate_ip_and_device(ec2, module, address, private_ip_address, device_id, allow_reassociation, check_mode, is_instance=True):
+    if address_is_associated_with_device(ec2, module, address, device_id, is_instance):
+        return {'changed': False}
+
+    # If we're in check mode, nothing else to do
+    if not check_mode:
+        if is_instance:
+            try:
+                params = dict(
+                    InstanceId=device_id,
+                    AllowReassociation=allow_reassociation,
+                )
+                if private_ip_address:
+                    params['PrivateIpAddress'] = private_ip_address
+                if address['Domain'] == 'vpc':
+                    params['AllocationId'] = address['AllocationId']
+                else:
+                    params['PublicIp'] = address['PublicIp']
+                res = ec2.associate_address(aws_retry=True, **params)
+            except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+                msg = "Couldn't associate Elastic IP address with instance '{0}'".format(device_id)
+                module.fail_json_aws(e, msg=msg)
+        else:
+            params = dict(
+                NetworkInterfaceId=device_id,
+                AllocationId=address['AllocationId'],
+                AllowReassociation=allow_reassociation,
+            )
+
+            if private_ip_address:
+                params['PrivateIpAddress'] = private_ip_address
+
+            try:
+                res = ec2.associate_address(aws_retry=True, **params)
+            except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+                msg = "Couldn't associate Elastic IP address with network interface '{0}'".format(device_id)
+                module.fail_json_aws(e, msg=msg)
+        if not res:
+            module.fail_json_aws(e, msg='Association failed.')
+
+    return {'changed': True}
+
+
+def disassociate_ip_and_device(ec2, module, address, device_id, check_mode, is_instance=True):
+    if not address_is_associated_with_device(ec2, module, address, device_id, is_instance):
+        return {'changed': False}
+
+    # If we're in check mode, nothing else to do
+    if not check_mode:
+        try:
+            if address['Domain'] == 'vpc':
+                res = ec2.disassociate_address(
+                    AssociationId=address['AssociationId'], aws_retry=True
+                )
+            else:
+                res = ec2.disassociate_address(
+                    PublicIp=address['PublicIp'], aws_retry=True
+                )
+        except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+            module.fail_json_aws(e, msg="Dissassociation of Elastic IP failed")
+
+    return {'changed': True}
+
+
+@AWSRetry.jittered_backoff()
+def find_address(ec2, module, public_ip, device_id, is_instance=True):
+    """ Find an existing Elastic IP address """
+    filters = []
+    kwargs = {}
+
+    if public_ip:
+        kwargs["PublicIps"] = [public_ip]
+    elif device_id:
+        if is_instance:
+            filters.append({"Name": 'instance-id', "Values": [device_id]})
+        else:
+            filters.append({'Name': 'network-interface-id', "Values": [device_id]})
+
+    if len(filters) > 0:
+        kwargs["Filters"] = filters
+    elif len(filters) == 0 and public_ip is None:
+        return None
+
+    try:
+        addresses = ec2.describe_addresses(**kwargs)
+    except is_boto3_error_code('InvalidAddress.NotFound') as e:
+        # If we're releasing and we can't find it, it's already gone...
+        if module.params.get('state') == 'absent':
+            module.exit_json(changed=False, disassociated=False, released=False)
+        module.fail_json_aws(e, msg="Couldn't obtain list of existing Elastic IP addresses")
+
+    addresses = addresses["Addresses"]
+    if len(addresses) == 1:
+        return addresses[0]
+    elif len(addresses) > 1:
+        msg = "Found more than one address using args {0}".format(kwargs)
+        msg += "Addresses found: {0}".format(addresses)
+        module.fail_json_aws(botocore.exceptions.ClientError, msg=msg)
+
+
+def address_is_associated_with_device(ec2, module, address, device_id, is_instance=True):
+    """ Check if the elastic IP is currently associated with the device """
+    address = find_address(ec2, module, address["PublicIp"], device_id, is_instance)
+    if address:
+        if is_instance:
+            if "InstanceId" in address and address["InstanceId"] == device_id:
+                return address
+        else:
+            if "NetworkInterfaceId" in address and address["NetworkInterfaceId"] == device_id:
+                return address
+    return False
+
+
+def allocate_address(ec2, module, domain, reuse_existing_ip_allowed, check_mode, tag_dict=None, public_ipv4_pool=None):
+    """ Allocate a new elastic IP address (when needed) and return it """
+    if not domain:
+        domain = 'standard'
+
+    if reuse_existing_ip_allowed:
+        filters = []
+        filters.append({'Name': 'domain', "Values": [domain]})
+
+        if tag_dict is not None:
+            filters += ansible_dict_to_boto3_filter_list(tag_dict)
+
+        try:
+            all_addresses = ec2.describe_addresses(Filters=filters, aws_retry=True)
+        except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+            module.fail_json_aws(e, msg="Couldn't obtain list of existing Elastic IP addresses")
+
+        all_addresses = all_addresses["Addresses"]
+
+        if domain == 'vpc':
+            unassociated_addresses = [a for a in all_addresses
+                                      if not a.get('AssociationId', None)]
+        else:
+            unassociated_addresses = [a for a in all_addresses
+                                      if not a['InstanceId']]
+        if unassociated_addresses:
+            return unassociated_addresses[0], False
+
+    if public_ipv4_pool:
+        return allocate_address_from_pool(ec2, module, domain, check_mode, public_ipv4_pool), True
+
+    try:
+        if check_mode:
+            return None, True
+        result = ec2.allocate_address(Domain=domain, aws_retry=True), True
+    except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+        module.fail_json_aws(e, msg="Couldn't allocate Elastic IP address")
+    return result
+
+
+def release_address(ec2, module, address, check_mode):
+    """ Release a previously allocated elastic IP address """
+
+    # If we're in check mode, nothing else to do
+    if not check_mode:
+        try:
+            result = ec2.release_address(AllocationId=address['AllocationId'], aws_retry=True)
+        except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+            module.fail_json_aws(e, msg="Couldn't release Elastic IP address")
+
+    return {'changed': True}
+
+
+@AWSRetry.jittered_backoff()
+def describe_eni_with_backoff(ec2, module, device_id):
+    try:
+        return ec2.describe_network_interfaces(NetworkInterfaceIds=[device_id])
+    except is_boto3_error_code('InvalidNetworkInterfaceID.NotFound') as e:
+        module.fail_json_aws(e, msg="Couldn't get list of network interfaces.")
+
+
+def find_device(ec2, module, device_id, is_instance=True):
+    """ Attempt to find the EC2 instance and return it """
+
+    if is_instance:
+        try:
+            paginator = ec2.get_paginator('describe_instances')
+            reservations = list(paginator.paginate(InstanceIds=[device_id]).search('Reservations[]'))
+        except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+            module.fail_json_aws(e, msg="Couldn't get list of instances")
+
+        if len(reservations) == 1:
+            instances = reservations[0]['Instances']
+            if len(instances) == 1:
+                return instances[0]
+    else:
+        try:
+            interfaces = describe_eni_with_backoff(ec2, module, device_id)
+        except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+            module.fail_json_aws(e, msg="Couldn't get list of network interfaces.")
+        if len(interfaces) == 1:
+            return interfaces[0]
+
+
+def ensure_present(ec2, module, domain, address, private_ip_address, device_id,
+                   reuse_existing_ip_allowed, allow_reassociation, check_mode, is_instance=True):
+    changed = False
+
+    # Return the EIP object since we've been given a public IP
+    if not address:
+        if check_mode:
+            return {'changed': True}
+
+        address, changed = allocate_address(ec2, module, domain, reuse_existing_ip_allowed, check_mode)
+
+    if device_id:
+        # Allocate an IP for instance since no public_ip was provided
+        if is_instance:
+            instance = find_device(ec2, module, device_id)
+            if reuse_existing_ip_allowed:
+                if instance['VpcId'] and len(instance['VpcId']) > 0 and domain is None:
+                    msg = "You must set 'in_vpc' to true to associate an instance with an existing ip in a vpc"
+                    module.fail_json_aws(botocore.exceptions.ClientError, msg=msg)
+
+            # Associate address object (provided or allocated) with instance
+            assoc_result = associate_ip_and_device(
+                ec2, module, address, private_ip_address, device_id, allow_reassociation,
+                check_mode
+            )
+        else:
+            instance = find_device(ec2, module, device_id, is_instance=False)
+            # Associate address object (provided or allocated) with instance
+            assoc_result = associate_ip_and_device(
+                ec2, module, address, private_ip_address, device_id, allow_reassociation,
+                check_mode, is_instance=False
+            )
+
+        changed = changed or assoc_result['changed']
+
+    return {'changed': changed, 'public_ip': address['PublicIp'], 'allocation_id': address['AllocationId']}
+
+
+def ensure_absent(ec2, module, address, device_id, check_mode, is_instance=True):
+    if not address:
+        return {'changed': False}
+
+    # disassociating address from instance
+    if device_id:
+        if is_instance:
+            return disassociate_ip_and_device(
+                ec2, module, address, device_id, check_mode
+            )
+        else:
+            return disassociate_ip_and_device(
+                ec2, module, address, device_id, check_mode, is_instance=False
+            )
+    # releasing address
+    else:
+        return release_address(ec2, module, address, check_mode)
+
+
+def allocate_address_from_pool(ec2, module, domain, check_mode, public_ipv4_pool):
+    # type: (EC2Connection, AnsibleAWSModule, str, bool, str) -> Address
+    """ Overrides botocore's allocate_address function to support BYOIP """
+    if check_mode:
+        return None
+
+    params = {}
+
+    if domain is not None:
+        params['Domain'] = domain
+
+    if public_ipv4_pool is not None:
+        params['PublicIpv4Pool'] = public_ipv4_pool
+
+    try:
+        result = ec2.allocate_address(aws_retry=True, **params)
+    except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+        module.fail_json_aws(e, msg="Couldn't allocate Elastic IP address")
+    return result
+
+
+def generate_tag_dict(module, tag_name, tag_value):
+    # type: (AnsibleAWSModule, str, str) -> Optional[Dict]
+    """ Generates a dictionary to be passed as a filter to Amazon """
+    if tag_name and not tag_value:
+        if tag_name.startswith('tag:'):
+            tag_name = tag_name.strip('tag:')
+        return {'tag-key': tag_name}
+
+    elif tag_name and tag_value:
+        if not tag_name.startswith('tag:'):
+            tag_name = 'tag:' + tag_name
+        return {tag_name: tag_value}
+
+    elif tag_value and not tag_name:
+        module.fail_json(msg="parameters are required together: ('tag_name', 'tag_value')")
+
+
+def main():
+    argument_spec = dict(
+        device_id=dict(required=False, aliases=['instance_id'],
+                       deprecated_aliases=[dict(name='instance_id',
+                                           date='2022-12-01',
+                                           collection_name='amazon.aws')]),
+        public_ip=dict(required=False, aliases=['ip']),
+        state=dict(required=False, default='present',
+                   choices=['present', 'absent']),
+        in_vpc=dict(required=False, type='bool', default=False),
+        reuse_existing_ip_allowed=dict(required=False, type='bool',
+                                       default=False),
+        release_on_disassociation=dict(required=False, type='bool', default=False),
+        allow_reassociation=dict(type='bool', default=False),
+        private_ip_address=dict(),
+        tags=dict(required=False, type='dict', aliases=['resource_tags']),
+        purge_tags=dict(required=False, type='bool', default=True),
+        tag_name=dict(),
+        tag_value=dict(),
+        public_ipv4_pool=dict()
+    )
+
+    module = AnsibleAWSModule(
+        argument_spec=argument_spec,
+        supports_check_mode=True,
+        required_by={
+            'private_ip_address': ['device_id'],
+        },
+    )
+
+    ec2 = module.client('ec2', retry_decorator=AWSRetry.jittered_backoff())
+
+    device_id = module.params.get('device_id')
+    instance_id = module.params.get('instance_id')
+    public_ip = module.params.get('public_ip')
+    private_ip_address = module.params.get('private_ip_address')
+    state = module.params.get('state')
+    in_vpc = module.params.get('in_vpc')
+    domain = 'vpc' if in_vpc else None
+    reuse_existing_ip_allowed = module.params.get('reuse_existing_ip_allowed')
+    release_on_disassociation = module.params.get('release_on_disassociation')
+    allow_reassociation = module.params.get('allow_reassociation')
+    tag_name = module.params.get('tag_name')
+    tag_value = module.params.get('tag_value')
+    public_ipv4_pool = module.params.get('public_ipv4_pool')
+    tags = module.params.get('tags')
+    purge_tags = module.params.get('purge_tags')
+
+    if instance_id:
+        is_instance = True
+        device_id = instance_id
+    else:
+        if device_id and device_id.startswith('i-'):
+            is_instance = True
+        elif device_id:
+            if device_id.startswith('eni-') and not in_vpc:
+                module.fail_json(msg="If you are specifying an ENI, in_vpc must be true")
+            is_instance = False
+
+    # Tags for *searching* for an EIP.
+    tag_dict = generate_tag_dict(module, tag_name, tag_value)
+
+    try:
+        if device_id:
+            address = find_address(ec2, module, public_ip, device_id, is_instance=is_instance)
+        else:
+            address = find_address(ec2, module, public_ip, None)
+
+        if state == 'present':
+            if device_id:
+                result = ensure_present(
+                    ec2, module, domain, address, private_ip_address, device_id,
+                    reuse_existing_ip_allowed, allow_reassociation,
+                    module.check_mode, is_instance=is_instance
+                )
+                if 'allocation_id' not in result:
+                    # Don't check tags on check_mode here - no EIP to pass through
+                    module.exit_json(**result)
+            else:
+                if address:
+                    result = {
+                        'changed': False,
+                        'public_ip': address['PublicIp'],
+                        'allocation_id': address['AllocationId']
+                    }
+                else:
+                    address, changed = allocate_address(
+                        ec2, module, domain, reuse_existing_ip_allowed,
+                        module.check_mode, tag_dict, public_ipv4_pool
+                    )
+                    if address:
+                        result = {
+                            'changed': changed,
+                            'public_ip': address['PublicIp'],
+                            'allocation_id': address['AllocationId']
+                        }
+                    else:
+                        # Don't check tags on check_mode here - no EIP to pass through
+                        result = {
+                            'changed': changed
+                        }
+                        module.exit_json(**result)
+
+            result['changed'] |= ensure_ec2_tags(
+                ec2, module, result['allocation_id'],
+                resource_type='elastic-ip', tags=tags, purge_tags=purge_tags)
+        else:
+            if device_id:
+                disassociated = ensure_absent(
+                    ec2, module, address, device_id, module.check_mode, is_instance=is_instance
+                )
+
+                if release_on_disassociation and disassociated['changed']:
+                    released = release_address(ec2, module, address, module.check_mode)
+                    result = {
+                        'changed': True,
+                        'disassociated': disassociated['changed'],
+                        'released': released['changed']
+                    }
+                else:
+                    result = {
+                        'changed': disassociated['changed'],
+                        'disassociated': disassociated['changed'],
+                        'released': False
+                    }
+            else:
+                released = release_address(ec2, module, address, module.check_mode)
+                result = {
+                    'changed': released['changed'],
+                    'disassociated': False,
+                    'released': released['changed']
+                }
+
+    except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
+        module.fail_json_aws(str(e))
+
+    module.exit_json(**result)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/plugins/modules/ec2_eip_info.py b/plugins/modules/ec2_eip_info.py
new file mode 100644
--- /dev/null
+++ b/plugins/modules/ec2_eip_info.py
@@ -0,0 +1,144 @@
+#!/usr/bin/python
+# Copyright (c) 2017 Ansible Project
+# 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_eip_info
+version_added: 1.0.0
+short_description: List EC2 EIP details
+description:
+    - List details of EC2 Elastic IP addresses.
+author: "Brad Macpherson (@iiibrad)"
+options:
+  filters:
+    description:
+      - A dict of filters to apply. Each dict item consists of a filter key and filter
+        value.  See U(https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-addresses.html#options)
+        for possible filters. Filter names and values are case sensitive.
+    required: false
+    default: {}
+    type: dict
+extends_documentation_fragment:
+- amazon.aws.aws
+- amazon.aws.ec2
+
+'''
+
+EXAMPLES = r'''
+# Note: These examples do not set authentication details or the AWS region,
+# see the AWS Guide for details.
+
+- name: List all EIP addresses in the current region.
+  amazon.aws.ec2_eip_info:
+  register: regional_eip_addresses
+
+- name: List all EIP addresses for a VM.
+  amazon.aws.ec2_eip_info:
+    filters:
+       instance-id: i-123456789
+  register: my_vm_eips
+
+- ansible.builtin.debug:
+    msg: "{{ my_vm_eips.addresses | selectattr('private_ip_address', 'equalto', '10.0.0.5') }}"
+
+- name: List all EIP addresses for several VMs.
+  amazon.aws.ec2_eip_info:
+    filters:
+       instance-id:
+         - i-123456789
+         - i-987654321
+  register: my_vms_eips
+
+- name: List all EIP addresses using the 'Name' tag as a filter.
+  amazon.aws.ec2_eip_info:
+    filters:
+      tag:Name: www.example.com
+  register: my_vms_eips
+
+- name: List all EIP addresses using the Allocation-id as a filter
+  amazon.aws.ec2_eip_info:
+    filters:
+      allocation-id: eipalloc-64de1b01
+  register: my_vms_eips
+
+# Set the variable eip_alloc to the value of the first allocation_id
+# and set the variable my_pub_ip to the value of the first public_ip
+- ansible.builtin.set_fact:
+    eip_alloc: my_vms_eips.addresses[0].allocation_id
+    my_pub_ip: my_vms_eips.addresses[0].public_ip
+
+'''
+
+
+RETURN = '''
+addresses:
+  description: Properties of all Elastic IP addresses matching the provided filters. Each element is a dict with all the information related to an EIP.
+  returned: on success
+  type: list
+  sample: [{
+        "allocation_id": "eipalloc-64de1b01",
+        "association_id": "eipassoc-0fe9ce90d6e983e97",
+        "domain": "vpc",
+        "instance_id": "i-01020cfeb25b0c84f",
+        "network_interface_id": "eni-02fdeadfd4beef9323b",
+        "network_interface_owner_id": "0123456789",
+        "private_ip_address": "10.0.0.1",
+        "public_ip": "54.81.104.1",
+        "tags": {
+            "Name": "test-vm-54.81.104.1"
+        }
+    }]
+
+'''
+
+try:
+    from botocore.exceptions import (BotoCoreError, ClientError)
+except ImportError:
+    pass  # caught by imported AnsibleAWSModule
+
+from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict
+
+from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ansible_dict_to_boto3_filter_list
+from ansible_collections.amazon.aws.plugins.module_utils.ec2 import boto3_tag_list_to_ansible_dict
+
+
+def get_eips_details(module):
+    connection = module.client('ec2', retry_decorator=AWSRetry.jittered_backoff())
+    filters = module.params.get("filters")
+    try:
+        response = connection.describe_addresses(
+            aws_retry=True,
+            Filters=ansible_dict_to_boto3_filter_list(filters)
+        )
+    except (BotoCoreError, ClientError) as e:
+        module.fail_json_aws(
+            e,
+            msg="Error retrieving EIPs")
+
+    addresses = camel_dict_to_snake_dict(response)['addresses']
+    for address in addresses:
+        if 'tags' in address:
+            address['tags'] = boto3_tag_list_to_ansible_dict(address['tags'])
+    return addresses
+
+
+def main():
+    module = AnsibleAWSModule(
+        argument_spec=dict(
+            filters=dict(type='dict', default={})
+        ),
+        supports_check_mode=True
+    )
+
+    module.exit_json(changed=False, addresses=get_eips_details(module))
+
+
+if __name__ == '__main__':
+    main()


Test patch:
diff --git a/tests/integration/targets/ec2_eip/aliases b/tests/integration/targets/ec2_eip/aliases
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_eip/aliases
@@ -0,0 +1,5 @@
+# https://github.com/ansible-collections/community.aws/issues/159
+# unstable
+
+cloud/aws
+ec2_eip_info
\ No newline at end of file
diff --git a/tests/integration/targets/ec2_eip/defaults/main.yml b/tests/integration/targets/ec2_eip/defaults/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_eip/defaults/main.yml
@@ -0,0 +1,5 @@
+# VPCs are identified by the CIDR.  Don't hard code the CIDR.  CI may
+# run multiple copies of the test concurrently.
+vpc_cidr: 10.{{ 256 | random(seed=resource_prefix) }}.0.0/16
+subnet_cidr: 10.{{ 256 | random(seed=resource_prefix) }}.42.0/24
+subnet_az: '{{ ec2_availability_zone_names[0] }}'
diff --git a/tests/integration/targets/ec2_eip/meta/main.yml b/tests/integration/targets/ec2_eip/meta/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_eip/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+- setup_ec2_facts
diff --git a/tests/integration/targets/ec2_eip/tasks/main.yml b/tests/integration/targets/ec2_eip/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_eip/tasks/main.yml
@@ -0,0 +1,1442 @@
+- name: Integration testing for ec2_eip
+  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 }}'
+    amazon.aws.ec2_eip:
+      in_vpc: true
+
+  block:
+  - name: Get the current caller identity facts
+    aws_caller_info:
+    register: caller_info
+
+  - name: List available AZs
+    aws_az_info:
+    register: region_azs
+
+  - name: Create a VPC
+    ec2_vpc_net:
+      name: '{{ resource_prefix }}-vpc'
+      state: present
+      cidr_block: '{{ vpc_cidr }}'
+      tags:
+        AnsibleEIPTest: Pending
+        AnsibleEIPTestPrefix: '{{ resource_prefix }}'
+    register: vpc_result
+
+  - name: Look for signs of concurrent EIP tests.  Pause if they are running or their
+      prefix comes before ours.
+    vars:
+      running_query: vpcs[?tags.AnsibleEIPTest=='Running']
+      pending_query: vpcs[?tags.AnsibleEIPTest=='Pending'].tags.AnsibleEIPTestPrefix
+    ec2_vpc_net_info:
+      filters:
+        tag:AnsibleEIPTest:
+        - Pending
+        - Running
+    register: vpc_info
+    retries: 10
+    delay: 5
+    until:
+    - ( vpc_info.vpcs | map(attribute='tags') | selectattr('AnsibleEIPTest', 'equalto',
+      'Running') | length == 0 )
+    - ( vpc_info.vpcs | map(attribute='tags') | selectattr('AnsibleEIPTest', 'equalto',
+      'Pending') | map(attribute='AnsibleEIPTestPrefix') | sort | first == resource_prefix
+      )
+
+  - name: Create subnet
+    ec2_vpc_subnet:
+      cidr: '{{ subnet_cidr }}'
+      az: '{{ subnet_az }}'
+      vpc_id: '{{ vpc_result.vpc.id }}'
+      state: present
+    register: vpc_subnet_create
+
+  - name: Create internet gateway
+    amazon.aws.ec2_vpc_igw:
+      state: present
+      vpc_id: '{{ vpc_result.vpc.id }}'
+    register: vpc_igw
+
+  - name: Create security group
+    ec2_group:
+      state: present
+      name: '{{ resource_prefix }}-sg'
+      description: a security group for ansible tests
+      vpc_id: '{{ vpc_result.vpc.id }}'
+      rules:
+      - proto: tcp
+        from_port: 22
+        to_port: 22
+        cidr_ip: 0.0.0.0/0
+    register: security_group
+
+  - name: Create instance for attaching
+    ec2_instance:
+      name: '{{ resource_prefix }}-instance'
+      image_id: '{{ ec2_ami_id }}'
+      security_group: '{{ security_group.group_id }}'
+      vpc_subnet_id: '{{ vpc_subnet_create.subnet.id }}'
+      wait: yes
+      state: running
+    register: create_ec2_instance_result
+
+  - name: Create ENI A
+    ec2_eni:
+      subnet_id: '{{ vpc_subnet_create.subnet.id }}'
+    register: eni_create_a
+
+  - name: Create ENI B
+    ec2_eni:
+      subnet_id: '{{ vpc_subnet_create.subnet.id }}'
+    register: eni_create_b
+
+  - name: Make a crude lock
+    ec2_vpc_net:
+      name: '{{ resource_prefix }}-vpc'
+      state: present
+      cidr_block: '{{ vpc_cidr }}'
+      tags:
+        AnsibleEIPTest: Running
+        AnsibleEIPTestPrefix: '{{ resource_prefix }}'
+
+  - name: Get current state of EIPs
+    ec2_eip_info:
+    register: eip_info_start
+
+  - name: Require that there are no free IPs when we start, otherwise we can't test
+      things properly
+    assert:
+      that:
+      - '"addresses" in eip_info_start'
+      - ( eip_info_start.addresses | length ) == ( eip_info_start.addresses | select('match',
+        'association_id') | length )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Allocate a new EIP with no conditions - check_mode
+    ec2_eip:
+      state: present
+      tags:
+        AnsibleEIPTestPrefix: '{{ resource_prefix }}'
+    register: eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - eip is changed
+
+  - name: Allocate a new EIP with no conditions
+    ec2_eip:
+      state: present
+      tags:
+        AnsibleEIPTestPrefix: '{{ resource_prefix }}'
+    register: eip
+
+  - ec2_eip_info:
+    register: eip_info
+    check_mode: yes
+
+  - assert:
+      that:
+      - eip is changed
+      - eip.public_ip is defined and ( eip.public_ip | ansible.utils.ipaddr )
+      - eip.allocation_id is defined and eip.allocation_id.startswith("eipalloc-")
+      - ( eip_info_start.addresses | length ) + 1 == ( eip_info.addresses | length
+        )
+
+  - name: Get EIP info via public ip
+    ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - '"addresses" in eip_info'
+      - eip_info.addresses | length == 1
+      - eip_info.addresses[0].allocation_id == eip.allocation_id
+      - eip_info.addresses[0].domain == "vpc"
+      - eip_info.addresses[0].public_ip == eip.public_ip
+      - '"AnsibleEIPTestPrefix" in eip_info.addresses[0].tags'
+      - eip_info.addresses[0].tags['AnsibleEIPTestPrefix'] == resource_prefix
+
+  - name: Get EIP info via allocation id
+    ec2_eip_info:
+      filters:
+        allocation-id: '{{ eip.allocation_id }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - '"addresses" in eip_info'
+      - eip_info.addresses | length == 1
+      - eip_info.addresses[0].allocation_id == eip.allocation_id
+      - eip_info.addresses[0].domain == "vpc"
+      - eip_info.addresses[0].public_ip == eip.public_ip
+      - '"AnsibleEIPTestPrefix" in eip_info.addresses[0].tags'
+      - eip_info.addresses[0].tags['AnsibleEIPTestPrefix'] == resource_prefix
+
+  - name: Allocate a new ip (idempotence) - check_mode
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+    register: eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - eip is not changed
+
+  - name: Allocate a new ip (idempotence)
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+    register: eip
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - eip is not changed
+      - eip.public_ip is defined and ( eip.public_ip | ansible.utils.ipaddr )
+      - eip.allocation_id is defined and eip.allocation_id.startswith("eipalloc-")
+      - ( eip_info_start.addresses | length ) + 1  == ( eip_info.addresses | length
+        )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Release EIP - check_mode
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+    register: eip_release
+    check_mode: yes
+
+  - assert:
+      that:
+      - eip_release.changed
+
+  - name: Release eip
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+    register: eip_release
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - eip_release.changed
+      - not eip_release.disassociated
+      - eip_release.released
+      - ( eip_info_start.addresses | length ) == ( eip_info.addresses | length )
+
+  - name: Release EIP (idempotence) - check_mode
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+    register: eip_release
+    check_mode: yes
+
+  - assert:
+      that:
+      - eip_release is not changed
+
+  - name: Release EIP (idempotence)
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+    register: eip_release
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - not eip_release.changed
+      - not eip_release.disassociated
+      - not eip_release.released
+      - ( eip_info_start.addresses | length ) == ( eip_info.addresses | length )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Allocate a new EIP - attempt reusing unallocated ones (none available) -
+      check_mode
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+    register: eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - eip is changed
+
+  - name: Allocate a new EIP - attempt reusing unallocated ones (none available)
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+    register: eip
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - eip is changed
+      - eip.public_ip is defined and ( eip.public_ip | ansible.utils.ipaddr )
+      - eip.allocation_id is defined and eip.allocation_id.startswith("eipalloc-")
+      - ( eip_info_start.addresses | length ) + 1  == ( eip_info.addresses | length
+        )
+
+  - name: Re-Allocate a new EIP - attempt reusing unallocated ones (one available)
+      - check_mode
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+    register: reallocate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - reallocate_eip is not changed
+
+  - name: Re-Allocate a new EIP - attempt reusing unallocated ones (one available)
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+    register: reallocate_eip
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - reallocate_eip is not changed
+      - reallocate_eip.public_ip is defined and ( reallocate_eip.public_ip | ansible.utils.ipaddr
+        )
+      - reallocate_eip.allocation_id is defined and reallocate_eip.allocation_id.startswith("eipalloc-")
+      - ( eip_info_start.addresses | length ) + 1  == ( eip_info.addresses | length
+        )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: attempt reusing an existing EIP with a tag (No match available) - check_mode
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+      tag_name: Team
+    register: no_tagged_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - no_tagged_eip is changed
+
+  - name: attempt reusing an existing EIP with a tag (No match available)
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+      tag_name: Team
+    register: no_tagged_eip
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - no_tagged_eip is changed
+      - no_tagged_eip.public_ip is defined and ( no_tagged_eip.public_ip | ansible.utils.ipaddr
+        )
+      - no_tagged_eip.allocation_id is defined and no_tagged_eip.allocation_id.startswith("eipalloc-")
+      - ( eip_info_start.addresses | length ) + 2  == ( eip_info.addresses | length
+        )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Tag EIP so we can try matching it
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        Team: Frontend
+
+  - name: Attempt reusing an existing EIP with a tag (Match available) - check_mode
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+      tag_name: Team
+    register: reallocate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - reallocate_eip is not changed
+
+  - name: Attempt reusing an existing EIP with a tag (Match available)
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+      tag_name: Team
+    register: reallocate_eip
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - reallocate_eip is not changed
+      - reallocate_eip.public_ip is defined and ( reallocate_eip.public_ip | ansible.utils.ipaddr
+        )
+      - reallocate_eip.allocation_id is defined and reallocate_eip.allocation_id.startswith("eipalloc-")
+      - ( eip_info_start.addresses | length ) + 2  == ( eip_info.addresses | length
+        )
+
+  - name: Attempt reusing an existing EIP with a tag and it's value (no match available)
+      - check_mode
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+      tag_name: Team
+      tag_value: Backend
+    register: backend_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - backend_eip is changed
+
+  - name: Attempt reusing an existing EIP with a tag and it's value (no match available)
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+      tag_name: Team
+      tag_value: Backend
+    register: backend_eip
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - backend_eip is changed
+      - backend_eip.public_ip is defined and ( backend_eip.public_ip | ansible.utils.ipaddr
+        )
+      - backend_eip.allocation_id is defined and backend_eip.allocation_id.startswith("eipalloc-")
+      - ( eip_info_start.addresses | length ) + 3  == ( eip_info.addresses | length
+        )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Tag EIP so we can try matching it
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        Team: Backend
+
+  - name: Attempt reusing an existing EIP with a tag and it's value (match available)
+      - check_mode
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+      tag_name: Team
+      tag_value: Backend
+    register: reallocate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - reallocate_eip is not changed
+
+  - name: Attempt reusing an existing EIP with a tag and it's value (match available)
+    ec2_eip:
+      state: present
+      reuse_existing_ip_allowed: true
+      tag_name: Team
+      tag_value: Backend
+    register: reallocate_eip
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - reallocate_eip is not changed
+      - reallocate_eip.public_ip is defined and reallocate_eip.public_ip != ""
+      - reallocate_eip.allocation_id is defined and reallocate_eip.allocation_id !=
+        ""
+      - ( eip_info_start.addresses | length ) + 3  == ( eip_info.addresses | length
+        )
+
+  - name: Release backend_eip
+    ec2_eip:
+      state: absent
+      public_ip: '{{ backend_eip.public_ip }}'
+
+  - name: Release no_tagged_eip
+    ec2_eip:
+      state: absent
+      public_ip: '{{ no_tagged_eip.public_ip }}'
+
+  - name: Release eip
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - ( eip_info_start.addresses | length ) == ( eip_info.addresses | length )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Allocate a new EIP from a pool - check_mode
+    ec2_eip:
+      state: present
+      public_ipv4_pool: amazon
+    register: eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - eip is changed
+
+  - name: Allocate a new EIP from a pool
+    ec2_eip:
+      state: present
+      public_ipv4_pool: amazon
+    register: eip
+
+  - ec2_eip_info:
+    register: eip_info
+
+  - assert:
+      that:
+      - eip is changed
+      - eip.public_ip is defined and ( eip.public_ip | ansible.utils.ipaddr )
+      - eip.allocation_id is defined and eip.allocation_id.startswith("eipalloc-")
+      - ( eip_info_start.addresses | length ) + 1  == ( eip_info.addresses | length
+        )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Attach EIP to ENI A - check_mode
+    ec2_eip:
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_a.interface.id }}'
+    register: associate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - associate_eip is changed
+
+  - name: Attach EIP to ENI A
+    ec2_eip:
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_a.interface.id }}'
+    register: associate_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - associate_eip is changed
+      - eip_info.addresses | length == 1
+      - associate_eip.public_ip is defined and eip.public_ip == associate_eip.public_ip
+      - associate_eip.allocation_id is defined and eip.allocation_id == associate_eip.allocation_id
+      - eip_info.addresses[0].allocation_id == eip.allocation_id
+      - eip_info.addresses[0].domain == "vpc"
+      - eip_info.addresses[0].public_ip == eip.public_ip
+      - eip_info.addresses[0].association_id is defined and eip_info.addresses[0].association_id.startswith("eipassoc-")
+      - eip_info.addresses[0].network_interface_id == eni_create_a.interface.id
+      - eip_info.addresses[0].private_ip_address is defined and ( eip_info.addresses[0].private_ip_address
+        | ansible.utils.ipaddr )
+      - eip_info.addresses[0].network_interface_owner_id == caller_info.account
+
+  - name: Attach EIP to ENI A (idempotence) - check_mode
+    ec2_eip:
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_a.interface.id }}'
+    register: associate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - associate_eip is not changed
+
+  - name: Attach EIP to ENI A (idempotence)
+    ec2_eip:
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_a.interface.id }}'
+    register: associate_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - associate_eip is not changed
+      - associate_eip.public_ip is defined and eip.public_ip == associate_eip.public_ip
+      - associate_eip.allocation_id is defined and eip.allocation_id == associate_eip.allocation_id
+      - eip_info.addresses | length == 1
+      - eip_info.addresses[0].allocation_id == eip.allocation_id
+      - eip_info.addresses[0].domain == "vpc"
+      - eip_info.addresses[0].public_ip == eip.public_ip
+      - eip_info.addresses[0].association_id is defined and eip_info.addresses[0].association_id.startswith("eipassoc-")
+      - eip_info.addresses[0].network_interface_id == eni_create_a.interface.id
+      - eip_info.addresses[0].private_ip_address is defined and ( eip_info.addresses[0].private_ip_address
+        | ansible.utils.ipaddr )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Attach EIP to ENI B (should fail, already associated)
+    ec2_eip:
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_b.interface.id }}'
+    register: associate_eip
+    ignore_errors: true
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - associate_eip is failed
+      - eip_info.addresses | length == 1
+      - eip_info.addresses[0].allocation_id == eip.allocation_id
+      - eip_info.addresses[0].domain == "vpc"
+      - eip_info.addresses[0].public_ip == eip.public_ip
+      - eip_info.addresses[0].association_id is defined and eip_info.addresses[0].association_id.startswith("eipassoc-")
+      - eip_info.addresses[0].network_interface_id == eni_create_a.interface.id
+      - eip_info.addresses[0].private_ip_address is defined and ( eip_info.addresses[0].private_ip_address
+        | ansible.utils.ipaddr )
+
+  - name: Attach EIP to ENI B - check_mode
+    ec2_eip:
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_b.interface.id }}'
+      allow_reassociation: true
+    register: associate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - associate_eip is changed
+
+  - name: Attach EIP to ENI B
+    ec2_eip:
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_b.interface.id }}'
+      allow_reassociation: true
+    register: associate_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - associate_eip is changed
+      - associate_eip.public_ip is defined and eip.public_ip == associate_eip.public_ip
+      - associate_eip.allocation_id is defined and eip.allocation_id == associate_eip.allocation_id
+      - eip_info.addresses | length == 1
+      - eip_info.addresses[0].allocation_id == eip.allocation_id
+      - eip_info.addresses[0].domain == "vpc"
+      - eip_info.addresses[0].public_ip == eip.public_ip
+      - eip_info.addresses[0].association_id is defined and eip_info.addresses[0].association_id.startswith("eipassoc-")
+      - eip_info.addresses[0].network_interface_id == eni_create_b.interface.id
+      - eip_info.addresses[0].private_ip_address is defined and ( eip_info.addresses[0].private_ip_address
+        | ansible.utils.ipaddr )
+
+  - name: Attach EIP to ENI B (idempotence) - check_mode
+    ec2_eip:
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_b.interface.id }}'
+      allow_reassociation: true
+    register: associate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - associate_eip is not changed
+
+  - name: Attach EIP to ENI B (idempotence)
+    ec2_eip:
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_b.interface.id }}'
+      allow_reassociation: true
+    register: associate_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - associate_eip is not changed
+      - associate_eip.public_ip is defined and eip.public_ip == associate_eip.public_ip
+      - associate_eip.allocation_id is defined and eip.allocation_id == associate_eip.allocation_id
+      - eip_info.addresses | length == 1
+      - eip_info.addresses[0].allocation_id == eip.allocation_id
+      - eip_info.addresses[0].domain == "vpc"
+      - eip_info.addresses[0].public_ip == eip.public_ip
+      - eip_info.addresses[0].association_id is defined and eip_info.addresses[0].association_id.startswith("eipassoc-")
+      - eip_info.addresses[0].network_interface_id == eni_create_b.interface.id
+      - eip_info.addresses[0].private_ip_address is defined and ( eip_info.addresses[0].private_ip_address
+        | ansible.utils.ipaddr )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Detach EIP from ENI B, without enabling release on disassociation - check_mode
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_b.interface.id }}'
+    register: disassociate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - disassociate_eip is changed
+
+  - name: Detach EIP from ENI B, without enabling release on disassociation
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_b.interface.id }}'
+    register: disassociate_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - disassociate_eip.changed
+      - disassociate_eip.disassociated
+      - not disassociate_eip.released
+      - eip_info.addresses | length == 1
+
+  - name: Detach EIP from ENI B, without enabling release on disassociation (idempotence)
+      - check_mode
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_b.interface.id }}'
+    register: disassociate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - disassociate_eip is not changed
+
+  - name: Detach EIP from ENI B, without enabling release on disassociation (idempotence)
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_b.interface.id }}'
+    register: disassociate_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - not disassociate_eip.changed
+      - not disassociate_eip.disassociated
+      - not disassociate_eip.released
+      - eip_info.addresses | length == 1
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Attach EIP to ENI A
+    ec2_eip:
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_a.interface.id }}'
+    register: associate_eip
+
+  - name: Detach EIP from ENI A, enabling release on disassociation - check_mode
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_a.interface.id }}'
+      release_on_disassociation: true
+    register: disassociate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - disassociate_eip is changed
+
+  - name: Detach EIP from ENI A, enabling release on disassociation
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_a.interface.id }}'
+      release_on_disassociation: true
+    register: disassociate_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - disassociate_eip.changed
+      - disassociate_eip.disassociated
+      - disassociate_eip.released
+      - eip_info.addresses | length == 0
+
+  - name: Detach EIP from ENI A, enabling release on disassociation (idempotence)
+      - check_mode
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_a.interface.id }}'
+      release_on_disassociation: true
+    register: disassociate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - disassociate_eip is not changed
+
+  - name: Detach EIP from ENI A, enabling release on disassociation (idempotence)
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+      device_id: '{{ eni_create_a.interface.id }}'
+      release_on_disassociation: true
+    register: disassociate_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - not disassociate_eip.changed
+      - not disassociate_eip.disassociated
+      - not disassociate_eip.released
+      - eip_info.addresses | length == 0
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Attach EIP to an EC2 instance - check_mode
+    ec2_eip:
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      state: present
+      release_on_disassociation: yes
+    register: instance_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - instance_eip is changed
+
+  - name: Attach EIP to an EC2 instance
+    ec2_eip:
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      state: present
+      release_on_disassociation: yes
+    register: instance_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ instance_eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - instance_eip is changed
+      - eip_info.addresses[0].allocation_id is defined
+      - eip_info.addresses[0].instance_id == '{{ create_ec2_instance_result.instance_ids[0]
+        }}'
+
+  - name: Attach EIP to an EC2 instance (idempotence) - check_mode
+    ec2_eip:
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      state: present
+      release_on_disassociation: yes
+    register: instance_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - instance_eip is not changed
+
+  - name: Attach EIP to an EC2 instance (idempotence)
+    ec2_eip:
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      state: present
+      release_on_disassociation: yes
+    register: instance_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ instance_eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - instance_eip is not changed
+      - eip_info.addresses[0].allocation_id is defined
+      - eip_info.addresses[0].instance_id == '{{ create_ec2_instance_result.instance_ids[0]
+        }}'
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Detach EIP from EC2 instance, without enabling release on disassociation
+      - check_mode
+    ec2_eip:
+      state: absent
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+    register: detach_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - detach_eip is changed
+
+  - name: Detach EIP from EC2 instance, without enabling release on disassociation
+    ec2_eip:
+      state: absent
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+    register: detach_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ instance_eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - detach_eip.changed
+      - detach_eip.disassociated
+      - not detach_eip.released
+      - eip_info.addresses | length == 1
+
+  - name: Detach EIP from EC2 instance, without enabling release on disassociation
+      (idempotence) - check_mode
+    ec2_eip:
+      state: absent
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+    register: detach_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - detach_eip is not changed
+
+  - name: Detach EIP from EC2 instance, without enabling release on disassociation
+      (idempotence)
+    ec2_eip:
+      state: absent
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+    register: detach_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ instance_eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - not detach_eip.changed
+      - not detach_eip.disassociated
+      - not detach_eip.released
+      - eip_info.addresses | length == 1
+
+  - name: Release EIP
+    ec2_eip:
+      state: absent
+      public_ip: '{{ instance_eip.public_ip }}'
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Attach EIP to an EC2 instance with private Ip specified - check_mode
+    ec2_eip:
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      private_ip_address: '{{ create_ec2_instance_result.instances[0].private_ip_address
+        }}'
+      state: present
+      release_on_disassociation: yes
+    register: instance_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - instance_eip is changed
+
+  - name: Attach EIP to an EC2 instance with private Ip specified
+    ec2_eip:
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      private_ip_address: '{{ create_ec2_instance_result.instances[0].private_ip_address
+        }}'
+      state: present
+      release_on_disassociation: yes
+    register: instance_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ instance_eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - instance_eip is changed
+      - eip_info.addresses[0].allocation_id is defined
+      - eip_info.addresses[0].instance_id == '{{ create_ec2_instance_result.instance_ids[0]
+        }}'
+
+  - name: Attach EIP to an EC2 instance with private Ip specified (idempotence) -
+      check_mode
+    ec2_eip:
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      private_ip_address: '{{ create_ec2_instance_result.instances[0].private_ip_address
+        }}'
+      state: present
+      release_on_disassociation: yes
+    register: instance_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - instance_eip is not changed
+
+  - name: Attach EIP to an EC2 instance with private Ip specified (idempotence)
+    ec2_eip:
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      private_ip_address: '{{ create_ec2_instance_result.instances[0].private_ip_address
+        }}'
+      state: present
+      release_on_disassociation: yes
+    register: instance_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ instance_eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - instance_eip is not changed
+      - eip_info.addresses[0].allocation_id is defined
+      - eip_info.addresses[0].instance_id == '{{ create_ec2_instance_result.instance_ids[0]
+        }}'
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Detach EIP from EC2 instance, enabling release on disassociation - check_mode
+    ec2_eip:
+      state: absent
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      release_on_disassociation: yes
+    register: disassociate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - disassociate_eip is changed
+
+  - name: Detach EIP from EC2 instance, enabling release on disassociation
+    ec2_eip:
+      state: absent
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      release_on_disassociation: yes
+    register: disassociate_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ instance_eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - disassociate_eip.changed
+      - disassociate_eip.disassociated
+      - disassociate_eip.released
+      - eip_info.addresses | length == 0
+
+  - name: Detach EIP from EC2 instance, enabling release on disassociation (idempotence)
+      - check_mode
+    ec2_eip:
+      state: absent
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      release_on_disassociation: yes
+    register: disassociate_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - disassociate_eip is not changed
+
+  - name: Detach EIP from EC2 instance, enabling release on disassociation (idempotence)
+    ec2_eip:
+      state: absent
+      device_id: '{{ create_ec2_instance_result.instance_ids[0] }}'
+      release_on_disassociation: yes
+    register: disassociate_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ instance_eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - not disassociate_eip.changed
+      - not disassociate_eip.disassociated
+      - not disassociate_eip.released
+      - eip_info.addresses | length == 0
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Allocate a new eip
+    ec2_eip:
+      state: present
+    register: eip
+
+  - name: Tag EIP - check_mode
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        AnsibleEIPTestPrefix: '{{ resource_prefix }}'
+        another_tag: another Value {{ resource_prefix }}
+    register: tag_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - tag_eip is changed
+
+  - name: Tag EIP
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        AnsibleEIPTestPrefix: '{{ resource_prefix }}'
+        another_tag: another Value {{ resource_prefix }}
+    register: tag_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - tag_eip is changed
+      - '"AnsibleEIPTestPrefix" in eip_info.addresses[0].tags'
+      - '"another_tag" in eip_info.addresses[0].tags'
+      - eip_info.addresses[0].tags['AnsibleEIPTestPrefix'] == resource_prefix
+      - eip_info.addresses[0].tags['another_tag'] == 'another Value ' + resource_prefix
+      - ( eip_info_start.addresses | length ) + 1 == ( eip_info.addresses | length
+        )
+
+  - name: Tag EIP (idempotence) - check_mode
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        AnsibleEIPTestPrefix: '{{ resource_prefix }}'
+        another_tag: another Value {{ resource_prefix }}
+    register: tag_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - tag_eip is not changed
+
+  - name: Tag EIP (idempotence)
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        AnsibleEIPTestPrefix: '{{ resource_prefix }}'
+        another_tag: another Value {{ resource_prefix }}
+    register: tag_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - tag_eip is not changed
+      - '"AnsibleEIPTestPrefix" in eip_info.addresses[0].tags'
+      - '"another_tag" in eip_info.addresses[0].tags'
+      - eip_info.addresses[0].tags['AnsibleEIPTestPrefix'] == resource_prefix
+      - eip_info.addresses[0].tags['another_tag'] == 'another Value ' + resource_prefix
+      - ( eip_info_start.addresses | length ) + 1 == ( eip_info.addresses | length
+        )
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Add another Tag - check_mode
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        third tag: Third tag - {{ resource_prefix }}
+      purge_tags: false
+    register: tag_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - tag_eip is changed
+
+  - name: Add another Tag
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        third tag: Third tag - {{ resource_prefix }}
+      purge_tags: false
+    register: tag_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - tag_eip is changed
+      - '"AnsibleEIPTestPrefix" in eip_info.addresses[0].tags'
+      - '"another_tag" in eip_info.addresses[0].tags'
+      - '"third tag" in eip_info.addresses[0].tags'
+      - eip_info.addresses[0].tags['AnsibleEIPTestPrefix'] == resource_prefix
+      - eip_info.addresses[0].tags['another_tag'] == 'another Value ' + resource_prefix
+      - eip_info.addresses[0].tags['third tag'] == 'Third tag - ' + resource_prefix
+      - ( eip_info_start.addresses | length ) + 1 == ( eip_info.addresses | length
+        )
+
+  - name: Add another Tag (idempotence) - check_mode
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        third tag: Third tag - {{ resource_prefix }}
+      purge_tags: false
+    register: tag_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - tag_eip is not changed
+
+  - name: Add another Tag (idempotence)
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        third tag: Third tag - {{ resource_prefix }}
+      purge_tags: false
+    register: tag_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - tag_eip is not changed
+      - '"AnsibleEIPTestPrefix" in eip_info.addresses[0].tags'
+      - '"another_tag" in eip_info.addresses[0].tags'
+      - '"third tag" in eip_info.addresses[0].tags'
+      - eip_info.addresses[0].tags['AnsibleEIPTestPrefix'] == resource_prefix
+      - eip_info.addresses[0].tags['another_tag'] == 'another Value ' + resource_prefix
+      - eip_info.addresses[0].tags['third tag'] == 'Third tag - ' + resource_prefix
+
+    # ------------------------------------------------------------------------------------------
+
+  - name: Purge tags - check_mode
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        third tag: Third tag - {{ resource_prefix }}
+      purge_tags: true
+    register: tag_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - tag_eip is changed
+
+  - name: Purge tags
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        third tag: Third tag - {{ resource_prefix }}
+      purge_tags: true
+    register: tag_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - tag_eip is changed
+      - '"AnsibleEIPTestPrefix" not in eip_info.addresses[0].tags'
+      - '"another_tag" not in eip_info.addresses[0].tags'
+      - '"third tag" in eip_info.addresses[0].tags'
+      - eip_info.addresses[0].tags['third tag'] == 'Third tag - ' + resource_prefix
+
+  - name: Purge tags (idempotence) - check_mode
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        third tag: Third tag - {{ resource_prefix }}
+      purge_tags: true
+    register: tag_eip
+    check_mode: yes
+
+  - assert:
+      that:
+      - tag_eip is not changed
+
+  - name: Purge tags (idempotence)
+    ec2_eip:
+      state: present
+      public_ip: '{{ eip.public_ip }}'
+      tags:
+        third tag: Third tag - {{ resource_prefix }}
+      purge_tags: true
+    register: tag_eip
+
+  - ec2_eip_info:
+      filters:
+        public-ip: '{{ eip.public_ip }}'
+    register: eip_info
+
+  - assert:
+      that:
+      - tag_eip is not changed
+      - '"AnsibleEIPTestPrefix" not in eip_info.addresses[0].tags'
+      - '"another_tag" not in eip_info.addresses[0].tags'
+      - '"third tag" in eip_info.addresses[0].tags'
+      - eip_info.addresses[0].tags['third tag'] == 'Third tag - ' + resource_prefix
+
+  # ----- Cleanup ------------------------------------------------------------------------------
+
+  always:
+
+  - name: Cleanup instance (by id)
+    ec2_instance:
+      instance_ids: '{{ create_ec2_instance_result.instance_ids }}'
+      state: absent
+      wait: true
+    ignore_errors: true
+
+  - name: Cleanup instance (by name)
+    ec2_instance:
+      name: '{{ resource_prefix }}-instance'
+      state: absent
+      wait: true
+    ignore_errors: true
+
+  - name: Cleanup ENI A
+    ec2_eni:
+      state: absent
+      eni_id: '{{ eni_create_a.interface.id }}'
+    ignore_errors: true
+
+  - name: Cleanup ENI B
+    ec2_eni:
+      state: absent
+      eni_id: '{{ eni_create_b.interface.id }}'
+    ignore_errors: true
+
+  - name: Cleanup instance eip
+    ec2_eip:
+      state: absent
+      public_ip: '{{ instance_eip.public_ip }}'
+    retries: 5
+    delay: 5
+    until: eip_cleanup is successful
+    ignore_errors: true
+
+  - name: Cleanup IGW
+    ec2_vpc_igw:
+      state: absent
+      vpc_id: '{{ vpc_result.vpc.id }}'
+    register: vpc_igw
+    ignore_errors: true
+
+  - name: Cleanup security group
+    ec2_group:
+      state: absent
+      name: '{{ resource_prefix }}-sg'
+    ignore_errors: true
+
+  - name: Cleanup Subnet
+    ec2_vpc_subnet:
+      state: absent
+      cidr: '{{ subnet_cidr }}'
+      vpc_id: '{{ vpc_result.vpc.id }}'
+    ignore_errors: true
+
+  - name: Cleanup eip
+    ec2_eip:
+      state: absent
+      public_ip: '{{ eip.public_ip }}'
+    ignore_errors: true
+
+  - name: Cleanup reallocate_eip
+    ec2_eip:
+      state: absent
+      public_ip: '{{ reallocate_eip.public_ip }}'
+    ignore_errors: true
+
+  - name: Cleanup backend_eip
+    ec2_eip:
+      state: absent
+      public_ip: '{{ backend_eip.public_ip }}'
+    ignore_errors: true
+
+  - name: Cleanup no_tagged_eip
+    ec2_eip:
+      state: absent
+      public_ip: '{{ no_tagged_eip.public_ip }}'
+    ignore_errors: true
+
+  - name: Cleanup VPC
+    ec2_vpc_net:
+      state: absent
+      name: '{{ resource_prefix }}-vpc'
+      cidr_block: '{{ vpc_cidr }}'
+    ignore_errors: true