diff --git a/changelogs/fragments/141-ec2_eni-boto3.yml b/changelogs/fragments/141-ec2_eni-boto3.yml new file mode 100644 index 00000000000..52d6f45bb9f --- /dev/null +++ b/changelogs/fragments/141-ec2_eni-boto3.yml @@ -0,0 +1,6 @@ +--- +minor_changes: + - ec2_eni - Port ec2_eni module to boto3 and add an integration test suite. + - ec2_eni - Add support for tagging. + - ec2_eni_info - Add retries on transient AWS failures. + - ec2_eni_info - Add support for providing an ENI ID. diff --git a/plugins/module_utils/waiters.py b/plugins/module_utils/waiters.py index 25db598bcb3..9e474ae953f 100644 --- a/plugins/module_utils/waiters.py +++ b/plugins/module_utils/waiters.py @@ -31,6 +31,24 @@ }, ] }, + "NetworkInterfaceAttached": { + "operation": "DescribeNetworkInterfaces", + "delay": 5, + "maxAttempts": 40, + "acceptors": [ + { + "expected": "attached", + "matcher": "pathAll", + "state": "success", + "argument": "NetworkInterfaces[].Attachment.Status" + }, + { + "expected": "InvalidNetworkInterfaceID.NotFound", + "matcher": "error", + "state": "failure" + }, + ] + }, "RouteTableExists": { "delay": 5, "maxAttempts": 40, @@ -304,6 +322,12 @@ def rds_model(name): core_waiter.NormalizedOperationMethod( ec2.describe_internet_gateways )), + ('EC2', 'network_interface_attached'): lambda ec2: core_waiter.Waiter( + 'network_interface_attached', + ec2_model('NetworkInterfaceAttached'), + core_waiter.NormalizedOperationMethod( + ec2.describe_network_interfaces + )), ('EC2', 'route_table_exists'): lambda ec2: core_waiter.Waiter( 'route_table_exists', ec2_model('RouteTableExists'), diff --git a/plugins/modules/ec2_eni.py b/plugins/modules/ec2_eni.py index 2843a5589a3..1ae81364b58 100644 --- a/plugins/modules/ec2_eni.py +++ b/plugins/modules/ec2_eni.py @@ -15,7 +15,9 @@ - Create and optionally attach an Elastic Network Interface (ENI) to an instance. If an ENI ID or private_ip is provided, the existing ENI (if any) will be modified. The 'attached' parameter controls the attachment status of the network interface. -author: "Rob White (@wimnat)" +author: + - "Rob White (@wimnat)" + - "Mike Healey (@healem)" options: eni_id: description: @@ -104,6 +106,32 @@ required: false default: false type: bool + name: + description: + - Name for the ENI. This will create a tag called "Name" with the value assigned here. + - This can be used in conjunction with I(subnet_id) as another means of identifiying a network interface. + - AWS does not enforce unique Name tags, so duplicate names are possible if you configure it that way. + If that is the case, you will need to provide other identifying information such as I(private_ip_address) or I(eni_id). + required: false + type: str + tags: + description: + - A hash/dictionary of tags to add to the new ENI or to add/remove from an existing one. Please note that + the name field sets the "Name" tag. + - To clear all tags, set this option to an empty dictionary to use in conjunction with I(purge_tags). + If you provide I(name), that tag will not be removed. + - To prevent removing any tags set I(purge_tags) to false. + type: dict + required: false + version_added: 1.3.0 + purge_tags: + description: + - Indicates whether to remove tags not specified in I(tags) or I(name). This means you have to specify all + the desired tags on each task affecting a network interface. + - If I(tags) is omitted or None this option is disregarded. + default: true + type: bool + version_added: 1.3.0 extends_documentation_fragment: - amazon.aws.aws - amazon.aws.ec2 @@ -164,6 +192,13 @@ description: "My new description" state: present +# Update an ENI using name and subnet_id +- amazon.aws.ec2_eni: + name: eni-20 + subnet_id: subnet-xxxxxxx + description: "My new description" + state: present + # Update an ENI identifying it by private_ip_address and subnet_id - amazon.aws.ec2_eni: subnet_id: subnet-xxxxxxx @@ -217,6 +252,10 @@ description: interface's physical address type: str sample: "00:00:5E:00:53:23" + name: + description: The name of the ENI + type: str + sample: "my-eni-20" owner_id: description: aws account id type: str @@ -242,6 +281,10 @@ description: which vpc subnet the interface is bound type: str sample: subnet-b0a0393c + tags: + description: The dictionary of tags associated with the ENI + type: dict + sample: { "Name": "my-eni", "group": "Finance" } vpc_id: description: which vpc this network interface is bound type: str @@ -250,67 +293,115 @@ ''' import time -import re try: - import boto.ec2 - import boto.vpc - from boto.exception import BotoServerError + import boto3 + import botocore.exceptions except ImportError: - pass # Taken care of by ec2.HAS_BOTO + pass # Handled by AnsibleAWSModule from ..module_utils.core import AnsibleAWSModule -from ..module_utils.ec2 import AnsibleAWSError -from ..module_utils.ec2 import HAS_BOTO -from ..module_utils.ec2 import connect_to_aws -from ..module_utils.ec2 import get_aws_connection_info +from ..module_utils.core import is_boto3_error_code +from ..module_utils.ec2 import AWSRetry +from ..module_utils.ec2 import ansible_dict_to_boto3_tag_list from ..module_utils.ec2 import get_ec2_security_group_ids_from_names +from ..module_utils.ec2 import boto3_tag_list_to_ansible_dict +from ..module_utils.ec2 import compare_aws_tags +from ..module_utils.waiters import get_waiter def get_eni_info(interface): # Private addresses private_addresses = [] - for ip in interface.private_ip_addresses: - private_addresses.append({'private_ip_address': ip.private_ip_address, 'primary_address': ip.primary}) - - interface_info = {'id': interface.id, - 'subnet_id': interface.subnet_id, - 'vpc_id': interface.vpc_id, - 'description': interface.description, - 'owner_id': interface.owner_id, - 'status': interface.status, - 'mac_address': interface.mac_address, - 'private_ip_address': interface.private_ip_address, - 'source_dest_check': interface.source_dest_check, - 'groups': dict((group.id, group.name) for group in interface.groups), + if "PrivateIpAddresses" in interface: + for ip in interface["PrivateIpAddresses"]: + private_addresses.append({'private_ip_address': ip["PrivateIpAddress"], 'primary_address': ip["Primary"]}) + + groups = {} + if "Groups" in interface: + for group in interface["Groups"]: + groups[group["GroupId"]] = group["GroupName"] + + interface_info = {'id': interface.get("NetworkInterfaceId"), + 'subnet_id': interface.get("SubnetId"), + 'vpc_id': interface.get("VpcId"), + 'description': interface.get("Description"), + 'owner_id': interface.get("OwnerId"), + 'status': interface.get("Status"), + 'mac_address': interface.get("MacAddress"), + 'private_ip_address': interface.get("PrivateIpAddress"), + 'source_dest_check': interface.get("SourceDestCheck"), + 'groups': groups, 'private_ip_addresses': private_addresses } - if interface.attachment is not None: - interface_info['attachment'] = {'attachment_id': interface.attachment.id, - 'instance_id': interface.attachment.instance_id, - 'device_index': interface.attachment.device_index, - 'status': interface.attachment.status, - 'attach_time': interface.attachment.attach_time, - 'delete_on_termination': interface.attachment.delete_on_termination, - } + if "TagSet" in interface: + tags = {} + name = None + for tag in interface["TagSet"]: + tags[tag["Key"]] = tag["Value"] + if tag["Key"] == "Name": + name = tag["Value"] + interface_info["tags"] = tags + + if name is not None: + interface_info["name"] = name + + if "Attachment" in interface: + interface_info['attachment'] = { + 'attachment_id': interface["Attachment"].get("AttachmentId"), + 'instance_id': interface["Attachment"].get("InstanceId"), + 'device_index': interface["Attachment"].get("DeviceIndex"), + 'status': interface["Attachment"].get("Status"), + 'attach_time': interface["Attachment"].get("AttachTime"), + 'delete_on_termination': interface["Attachment"].get("DeleteOnTermination"), + } return interface_info -def wait_for_eni(eni, status): +def correct_ips(connection, ip_list, module, eni=None): + all_there = True + eni = uniquely_find_eni(connection, module, eni) + private_addresses = set() + if "PrivateIpAddresses" in eni: + for ip in eni["PrivateIpAddresses"]: + private_addresses.add(ip["PrivateIpAddress"]) + + for ip in ip_list: + if ip not in private_addresses: + all_there = False + break + + if all_there: + return True + else: + return False + + +def correct_ip_count(connection, ip_count, module, eni=None): + eni = uniquely_find_eni(connection, module, eni) + private_addresses = set() + if "PrivateIpAddresses" in eni: + for ip in eni["PrivateIpAddresses"]: + private_addresses.add(ip["PrivateIpAddress"]) + + if len(private_addresses) == ip_count: + return True + else: + return False - while True: - time.sleep(3) - eni.update() - # If the status is detached we just need attachment to disappear - if eni.attachment is None: - if status == "detached": - break - else: - if status == "attached" and eni.attachment.status == "attached": - break + +def wait_for(function_pointer, *args): + max_wait = 30 + interval_time = 3 + current_wait = 0 + while current_wait < max_wait: + time.sleep(interval_time) + current_wait += interval_time + if function_pointer(*args): + break def create_eni(connection, vpc_id, module): @@ -323,50 +414,87 @@ def create_eni(connection, vpc_id, module): subnet_id = module.params.get('subnet_id') private_ip_address = module.params.get('private_ip_address') description = module.params.get('description') - security_groups = get_ec2_security_group_ids_from_names(module.params.get('security_groups'), connection, vpc_id=vpc_id, boto3=False) + security_groups = get_ec2_security_group_ids_from_names( + module.params.get('security_groups'), + connection, + vpc_id=vpc_id, + boto3=True + ) secondary_private_ip_addresses = module.params.get("secondary_private_ip_addresses") secondary_private_ip_address_count = module.params.get("secondary_private_ip_address_count") changed = False + tags = module.params.get("tags") + name = module.params.get("name") + purge_tags = module.params.get("purge_tags") try: - eni = connection.create_network_interface(subnet_id, private_ip_address, description, security_groups) + args = {"SubnetId": subnet_id} + if private_ip_address: + args["PrivateIpAddress"] = private_ip_address + if description: + args["Description"] = description + if len(security_groups) > 0: + args["Groups"] = security_groups + eni_dict = connection.create_network_interface(aws_retry=True, **args) + eni = eni_dict["NetworkInterface"] if attached and instance_id is not None: try: - eni.attach(instance_id, device_index) - except BotoServerError: - eni.delete() + connection.attach_network_interface( + aws_retry=True, + InstanceId=instance_id, + DeviceIndex=device_index, + NetworkInterfaceId=eni["NetworkInterfaceId"] + ) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError): + connection.delete_network_interface(aws_retry=True, NetworkInterfaceId=eni["NetworkInterfaceId"]) raise # Wait to allow creation / attachment to finish - wait_for_eni(eni, "attached") - eni.update() + get_waiter(connection.client, 'network_interface_attached').wait(NetworkInterfaceIds=[eni["NetworkInterfaceId"]]) + eni = uniquely_find_eni(connection, module, eni) if secondary_private_ip_address_count is not None: try: - connection.assign_private_ip_addresses(network_interface_id=eni.id, secondary_private_ip_address_count=secondary_private_ip_address_count) - except BotoServerError: - eni.delete() + connection.assign_private_ip_addresses( + aws_retry=True, + NetworkInterfaceId=eni["NetworkInterfaceId"], + SecondaryPrivateIpAddressCount=secondary_private_ip_address_count + ) + eni = uniquely_find_eni(connection, module, eni) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError): + connection.delete_network_interface(aws_retry=True, NetworkInterfaceId=eni["NetworkInterfaceId"]) raise if secondary_private_ip_addresses is not None: try: - connection.assign_private_ip_addresses(network_interface_id=eni.id, private_ip_addresses=secondary_private_ip_addresses) - except BotoServerError: - eni.delete() + connection.assign_private_ip_addresses( + NetworkInterfaceId=eni["NetworkInterfaceId"], + PrivateIpAddresses=secondary_private_ip_addresses + ) + eni = uniquely_find_eni(connection, module, eni) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError): + connection.delete_network_interface(aws_retry=True, NetworkInterfaceId=eni["NetworkInterfaceId"]) raise + manage_tags(eni, name, tags, purge_tags, connection) + + # Refresh the eni data on last time + eni = uniquely_find_eni(connection, module, eni) + changed = True - except BotoServerError as e: - module.fail_json_aws(e) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws( + e, + "Failed to create eni {0} for {1} in {2} with {3}".format(name, subnet_id, vpc_id, private_ip_address) + ) module.exit_json(changed=changed, interface=get_eni_info(eni)) -def modify_eni(connection, vpc_id, module, eni): +def modify_eni(connection, module, eni): instance_id = module.params.get("instance_id") attached = module.params.get("attached") - do_detach = module.params.get('state') == 'detached' device_index = module.params.get("device_index") description = module.params.get('description') security_groups = module.params.get('security_groups') @@ -378,161 +506,234 @@ def modify_eni(connection, vpc_id, module, eni): secondary_private_ip_address_count = module.params.get("secondary_private_ip_address_count") allow_reassignment = module.params.get("allow_reassignment") changed = False + tags = module.params.get("tags") + name = module.params.get("name") + purge_tags = module.params.get("purge_tags") + + eni = uniquely_find_eni(connection, module, eni) try: if description is not None: - if eni.description != description: - connection.modify_network_interface_attribute(eni.id, "description", description) + if "Description" not in eni or eni["Description"] != description: + connection.modify_network_interface_attribute( + aws_retry=True, + NetworkInterfaceId=eni["NetworkInterfaceId"], + Description={'Value': description} + ) changed = True if len(security_groups) > 0: - groups = get_ec2_security_group_ids_from_names(security_groups, connection, vpc_id=vpc_id, boto3=False) - if sorted(get_sec_group_list(eni.groups)) != sorted(groups): - connection.modify_network_interface_attribute(eni.id, "groupSet", groups) + groups = get_ec2_security_group_ids_from_names(security_groups, connection, vpc_id=eni["VpcId"], boto3=True) + if sorted(get_sec_group_list(eni["Groups"])) != sorted(groups): + connection.modify_network_interface_attribute( + aws_retry=True, + NetworkInterfaceId=eni["NetworkInterfaceId"], + Groups=groups + ) changed = True if source_dest_check is not None: - if eni.source_dest_check != source_dest_check: - connection.modify_network_interface_attribute(eni.id, "sourceDestCheck", source_dest_check) + if "SourceDestCheck" not in eni or eni["SourceDestCheck"] != source_dest_check: + connection.modify_network_interface_attribute( + aws_retry=True, + NetworkInterfaceId=eni["NetworkInterfaceId"], + SourceDestCheck={'Value': source_dest_check} + ) changed = True - if delete_on_termination is not None and eni.attachment is not None: - if eni.attachment.delete_on_termination is not delete_on_termination: - connection.modify_network_interface_attribute(eni.id, "deleteOnTermination", delete_on_termination, eni.attachment.id) + if delete_on_termination is not None and "Attachment" in eni: + if eni["Attachment"]["DeleteOnTermination"] is not delete_on_termination: + connection.modify_network_interface_attribute( + aws_retry=True, + NetworkInterfaceId=eni["NetworkInterfaceId"], + Attachment={'AttachmentId': eni["Attachment"]["AttachmentId"], + 'DeleteOnTermination': delete_on_termination} + ) changed = True - current_secondary_addresses = [i.private_ip_address for i in eni.private_ip_addresses if not i.primary] + current_secondary_addresses = [] + if "PrivateIpAddresses" in eni: + current_secondary_addresses = [i["PrivateIpAddress"] for i in eni["PrivateIpAddresses"] if not i["Primary"]] + if secondary_private_ip_addresses is not None: secondary_addresses_to_remove = list(set(current_secondary_addresses) - set(secondary_private_ip_addresses)) if secondary_addresses_to_remove and purge_secondary_private_ip_addresses: - connection.unassign_private_ip_addresses(network_interface_id=eni.id, - private_ip_addresses=list(set(current_secondary_addresses) - - set(secondary_private_ip_addresses)), - dry_run=False) + connection.unassign_private_ip_addresses( + aws_retry=True, + NetworkInterfaceId=eni["NetworkInterfaceId"], + PrivateIpAddresses=list(set(current_secondary_addresses) - set(secondary_private_ip_addresses)), + ) changed = True - secondary_addresses_to_add = list(set(secondary_private_ip_addresses) - set(current_secondary_addresses)) if secondary_addresses_to_add: - connection.assign_private_ip_addresses(network_interface_id=eni.id, - private_ip_addresses=secondary_addresses_to_add, - secondary_private_ip_address_count=None, - allow_reassignment=allow_reassignment, dry_run=False) + connection.assign_private_ip_addresses( + aws_retry=True, + NetworkInterfaceId=eni["NetworkInterfaceId"], + PrivateIpAddresses=secondary_addresses_to_add, + AllowReassignment=allow_reassignment + ) + wait_for(correct_ips, connection, secondary_addresses_to_add, module, eni) changed = True + if secondary_private_ip_address_count is not None: current_secondary_address_count = len(current_secondary_addresses) - if secondary_private_ip_address_count > current_secondary_address_count: - connection.assign_private_ip_addresses(network_interface_id=eni.id, - private_ip_addresses=None, - secondary_private_ip_address_count=(secondary_private_ip_address_count - - current_secondary_address_count), - allow_reassignment=allow_reassignment, dry_run=False) + connection.assign_private_ip_addresses( + aws_retry=True, + NetworkInterfaceId=eni["NetworkInterfaceId"], + SecondaryPrivateIpAddressCount=(secondary_private_ip_address_count - current_secondary_address_count), + AllowReassignment=allow_reassignment + ) + wait_for(correct_ip_count, connection, secondary_private_ip_address_count, module, eni) changed = True elif secondary_private_ip_address_count < current_secondary_address_count: # How many of these addresses do we want to remove secondary_addresses_to_remove_count = current_secondary_address_count - secondary_private_ip_address_count - connection.unassign_private_ip_addresses(network_interface_id=eni.id, - private_ip_addresses=current_secondary_addresses[:secondary_addresses_to_remove_count], - dry_run=False) + connection.unassign_private_ip_addresses( + aws_retry=True, + NetworkInterfaceId=eni["NetworkInterfaceId"], + PrivateIpAddresses=current_secondary_addresses[:secondary_addresses_to_remove_count] + ) if attached is True: - if eni.attachment and eni.attachment.instance_id != instance_id: - detach_eni(eni, module) - eni.attach(instance_id, device_index) - wait_for_eni(eni, "attached") + if "Attachment" in eni and eni["Attachment"]["InstanceId"] != instance_id: + detach_eni(connection, eni, module) + connection.attach_network_interface( + aws_retry=True, + InstanceId=instance_id, + DeviceIndex=device_index, + NetworkInterfaceId=eni["NetworkInterfaceId"] + ) + get_waiter(connection.client, 'network_interface_attached').wait(NetworkInterfaceIds=[eni["NetworkInterfaceId"]]) changed = True - if eni.attachment is None: - eni.attach(instance_id, device_index) - wait_for_eni(eni, "attached") + if "Attachment" not in eni: + connection.attach_network_interface( + aws_retry=True, + InstanceId=instance_id, + DeviceIndex=device_index, + NetworkInterfaceId=eni["NetworkInterfaceId"] + ) + get_waiter(connection.client, 'network_interface_attached').wait(NetworkInterfaceIds=[eni["NetworkInterfaceId"]]) changed = True + elif attached is False: - detach_eni(eni, module) + detach_eni(connection, eni, module) - except BotoServerError as e: - module.fail_json_aws(e) + changed |= manage_tags(eni, name, tags, purge_tags, connection) - eni.update() + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, "Failed to modify eni {0}".format(eni['NetworkInterfaceId'])) + + eni = uniquely_find_eni(connection, module, eni) module.exit_json(changed=changed, interface=get_eni_info(eni)) def delete_eni(connection, module): - eni_id = module.params.get("eni_id") + eni = uniquely_find_eni(connection, module) + if not eni: + module.exit_json(changed=False) + + eni_id = eni["NetworkInterfaceId"] force_detach = module.params.get("force_detach") try: - eni_result_set = connection.get_all_network_interfaces(eni_id) - eni = eni_result_set[0] - if force_detach is True: - if eni.attachment is not None: - eni.detach(force_detach) + if "Attachment" in eni: + connection.detach_network_interface( + aws_retry=True, + AttachmentId=eni["Attachment"]["AttachmentId"], + Force=True + ) # Wait to allow detachment to finish - wait_for_eni(eni, "detached") - eni.update() - eni.delete() + connection.get_waiter('network_interface_available').wait(NetworkInterfaceIds=[eni["NetworkInterfaceId"]]) + connection.delete_network_interface(aws_retry=True, NetworkInterfaceId=eni_id) changed = True else: - eni.delete() + connection.delete_network_interface(aws_retry=True, NetworkInterfaceId=eni_id) changed = True module.exit_json(changed=changed) - except BotoServerError as e: - regex = re.compile('The networkInterface ID \'.*\' does not exist') - if regex.search(e.message) is not None: - module.exit_json(changed=False) - else: - module.fail_json_aws(e) + except is_boto3_error_code('InvalidNetworkInterfaceID.NotFound'): + module.exit_json(changed=False) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except + module.fail_json_aws(e, "Failure during delete of {0}".format(eni_id)) -def detach_eni(eni, module): +def detach_eni(connection, eni, module): attached = module.params.get("attached") force_detach = module.params.get("force_detach") - if eni.attachment is not None: - eni.detach(force_detach) - wait_for_eni(eni, "detached") + if "Attachment" in eni: + connection.detach_network_interface( + aws_retry=True, + AttachmentId=eni["Attachment"]["AttachmentId"], + Force=force_detach + ) + connection.get_waiter('network_interface_available').wait(NetworkInterfaceIds=[eni["NetworkInterfaceId"]]) if attached: return - eni.update() + eni = uniquely_find_eni(connection, module) module.exit_json(changed=True, interface=get_eni_info(eni)) else: module.exit_json(changed=False, interface=get_eni_info(eni)) -def uniquely_find_eni(connection, module): +def uniquely_find_eni(connection, module, eni=None): + + if eni: + # In the case of create, eni_id will not be a param but we can still get the eni_id after creation + if "NetworkInterfaceId" in eni: + eni_id = eni["NetworkInterfaceId"] + else: + eni_id = None + else: + eni_id = module.params.get("eni_id") - eni_id = module.params.get("eni_id") private_ip_address = module.params.get('private_ip_address') subnet_id = module.params.get('subnet_id') instance_id = module.params.get('instance_id') device_index = module.params.get('device_index') attached = module.params.get('attached') + name = module.params.get("name") - try: - filters = {} + filters = [] - # proceed only if we're univocally specifying an ENI - if eni_id is None and private_ip_address is None and (instance_id is None and device_index is None): - return None + # proceed only if we're unequivocally specifying an ENI + if eni_id is None and private_ip_address is None and (instance_id is None and device_index is None): + return None - if private_ip_address and subnet_id: - filters['private-ip-address'] = private_ip_address - filters['subnet-id'] = subnet_id + if eni_id: + filters.append({'Name': 'network-interface-id', + 'Values': [eni_id]}) - if not attached and instance_id and device_index: - filters['attachment.instance-id'] = instance_id - filters['attachment.device-index'] = device_index + if private_ip_address and subnet_id and not filters: + filters.append({'Name': 'private-ip-address', + 'Values': [private_ip_address]}) + filters.append({'Name': 'subnet-id', + 'Values': [subnet_id]}) - if eni_id is None and len(filters) == 0: - return None + if not attached and instance_id and device_index and not filters: + filters.append({'Name': 'attachment.instance-id', + 'Values': [instance_id]}) + filters.append({'Name': 'attachment.device-index', + 'Values': [device_index]}) + + if name and subnet_id and not filters: + filters.append({'Name': 'tag:Name', + 'Values': [name]}) + filters.append({'Name': 'subnet-id', + 'Values': [subnet_id]}) + + if not filters: + return None - eni_result = connection.get_all_network_interfaces(eni_id, filters=filters) + try: + eni_result = connection.describe_network_interfaces(aws_retry=True, Filters=filters)["NetworkInterfaces"] if len(eni_result) == 1: return eni_result[0] else: return None - - except BotoServerError as e: - module.fail_json_aws(e) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, "Failed to find unique eni with filters: {0}".format(filters)) return None @@ -542,7 +743,7 @@ def get_sec_group_list(groups): # Build list of remote security groups remote_security_groups = [] for group in groups: - remote_security_groups.append(group.id.encode()) + remote_security_groups.append(group["GroupId"].encode()) return remote_security_groups @@ -550,9 +751,49 @@ def get_sec_group_list(groups): def _get_vpc_id(connection, module, subnet_id): try: - return connection.get_all_subnets(subnet_ids=[subnet_id])[0].vpc_id - except BotoServerError as e: - module.fail_json_aws(e) + subnets = connection.describe_subnets(aws_retry=True, SubnetIds=[subnet_id]) + return subnets["Subnets"][0]["VpcId"] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, "Failed to get vpc_id for {0}".format(subnet_id)) + + +def manage_tags(eni, name, new_tags, purge_tags, connection): + changed = False + + if "TagSet" in eni: + old_tags = boto3_tag_list_to_ansible_dict(eni['TagSet']) + elif new_tags: + old_tags = {} + else: + # No new tags and nothing in TagSet + return False + + # Do not purge tags unless tags is not None + if new_tags is None: + purge_tags = False + new_tags = {} + + if name: + new_tags['Name'] = name + + tags_to_set, tags_to_delete = compare_aws_tags( + old_tags, new_tags, + purge_tags=purge_tags, + ) + if tags_to_set: + connection.create_tags( + aws_retry=True, + Resources=[eni['NetworkInterfaceId']], + Tags=ansible_dict_to_boto3_tag_list(tags_to_set)) + changed |= True + if tags_to_delete: + delete_with_current_values = dict((k, old_tags.get(k)) for k in tags_to_delete) + connection.delete_tags( + aws_retry=True, + Resources=[eni['NetworkInterfaceId']], + Tags=ansible_dict_to_boto3_tag_list(delete_with_current_values)) + changed |= True + return changed def main(): @@ -572,36 +813,24 @@ def main(): purge_secondary_private_ip_addresses=dict(default=False, type='bool'), secondary_private_ip_address_count=dict(default=None, type='int'), allow_reassignment=dict(default=False, type='bool'), - attached=dict(default=None, type='bool') + attached=dict(default=None, type='bool'), + name=dict(default=None, type='str'), + tags=dict(type='dict'), + purge_tags=dict(default=True, type='bool') ) module = AnsibleAWSModule( argument_spec=argument_spec, - check_boto3=False, mutually_exclusive=[ ['secondary_private_ip_addresses', 'secondary_private_ip_address_count'] ], required_if=([ - ('state', 'absent', ['eni_id']), ('attached', True, ['instance_id']), ('purge_secondary_private_ip_addresses', True, ['secondary_private_ip_addresses']) ]) ) - if not HAS_BOTO: - module.fail_json(msg='boto required for this module') - - region, ec2_url, aws_connect_params = get_aws_connection_info(module) - - if region: - try: - connection = connect_to_aws(boto.ec2, region, **aws_connect_params) - vpc_connection = connect_to_aws(boto.vpc, region, **aws_connect_params) - except (boto.exception.NoAuthHandlerFound, AnsibleAWSError) as e: - module.fail_json_aws(e) - else: - module.fail_json(msg="region must be specified") - + connection = module.client('ec2', retry_decorator=AWSRetry.jittered_backoff()) state = module.params.get("state") if state == 'present': @@ -609,13 +838,12 @@ def main(): if eni is None: subnet_id = module.params.get("subnet_id") if subnet_id is None: - module.fail_json(msg="subnet_id is required when creating a new ENI") + module.fail_json(msg='subnet_id is required when creating a new ENI') - vpc_id = _get_vpc_id(vpc_connection, module, subnet_id) + vpc_id = _get_vpc_id(connection, module, subnet_id) create_eni(connection, vpc_id, module) else: - vpc_id = eni.vpc_id - modify_eni(connection, vpc_id, module, eni) + modify_eni(connection, module, eni) elif state == 'absent': delete_eni(connection, module) diff --git a/plugins/modules/ec2_eni_info.py b/plugins/modules/ec2_eni_info.py index 9776ec5837c..b6bb7f8cff2 100644 --- a/plugins/modules/ec2_eni_info.py +++ b/plugins/modules/ec2_eni_info.py @@ -17,10 +17,17 @@ author: "Rob White (@wimnat)" requirements: [ boto3 ] options: + eni_id: + description: + - The ID of the ENI. + - This option is mutually exclusive of I(filters). + type: str + version_added: 1.3.0 filters: description: - A dict of filters to apply. Each dict item consists of a filter key and a filter value. See U(https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeNetworkInterfaces.html) for possible filters. + - This option is mutually exclusive of I(eni_id). type: dict extends_documentation_fragment: - amazon.aws.aws @@ -49,7 +56,7 @@ contains: association: description: Info of associated elastic IP (EIP) - returned: always, empty dict if no association exists + returned: When an ENI is associated with an EIP type: dict sample: { allocation_id: "eipalloc-5sdf123", @@ -60,7 +67,7 @@ } attachment: description: Info about attached ec2 instance - returned: always, empty dict if ENI is not attached + returned: When an ENI is attached to an ec2 instance type: dict sample: { attach_time: "2017-08-05T15:25:47+00:00", @@ -111,6 +118,11 @@ returned: always type: str sample: "0a:f8:10:2f:ab:a1" + name: + description: The Name tag of the ENI, often displayed in the AWS UIs as Name + returned: When a Name tag has been set + type: str + version_added: 1.3.0 network_interface_id: description: The id of the ENI returned: always @@ -161,6 +173,12 @@ returned: always type: str sample: "subnet-7bbf01234" + tags: + description: Dictionary of tags added to the ENI + returned: always + type: dict + sample: {} + version_added: 1.3.0 tag_set: description: Dictionary of tags added to the ENI returned: always @@ -183,18 +201,23 @@ from ..module_utils.core import AnsibleAWSModule from ..module_utils.ec2 import ansible_dict_to_boto3_filter_list +from ..module_utils.ec2 import AWSRetry from ..module_utils.ec2 import boto3_tag_list_to_ansible_dict def list_eni(connection, module): - if module.params.get("filters") is None: - filters = [] + # Options are mutually exclusive + if module.params.get("eni_id"): + filters = {'network-interface-id': module.params.get("eni_id")} + elif module.params.get("filters"): + filters = module.params.get("filters") else: - filters = ansible_dict_to_boto3_filter_list(module.params.get("filters")) + filters = {} + filters = ansible_dict_to_boto3_filter_list(filters) try: - network_interfaces_result = connection.describe_network_interfaces(Filters=filters)['NetworkInterfaces'] + network_interfaces_result = connection.describe_network_interfaces(Filters=filters, aws_retry=True)['NetworkInterfaces'] except (ClientError, NoCredentialsError) as e: module.fail_json_aws(e) @@ -202,9 +225,12 @@ def list_eni(connection, module): camel_network_interfaces = [] for network_interface in network_interfaces_result: network_interface['TagSet'] = boto3_tag_list_to_ansible_dict(network_interface['TagSet']) + network_interface['Tags'] = network_interface['TagSet'] + if 'Name' in network_interface['Tags']: + network_interface['Name'] = network_interface['Tags']['Name'] # Added id to interface info to be compatible with return values of ec2_eni module: network_interface['Id'] = network_interface['NetworkInterfaceId'] - camel_network_interfaces.append(camel_dict_to_snake_dict(network_interface)) + camel_network_interfaces.append(camel_dict_to_snake_dict(network_interface, ignore_list=['Tags', 'TagSet'])) module.exit_json(network_interfaces=camel_network_interfaces) @@ -249,14 +275,18 @@ def get_eni_info(interface): def main(): argument_spec = dict( + eni_id=dict(type='str'), filters=dict(default=None, type='dict') ) + mutually_exclusive = [ + ['eni_id', 'filters'] + ] module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True) if module._name == 'ec2_eni_facts': module.deprecate("The 'ec2_eni_facts' module has been renamed to 'ec2_eni_info'", date='2021-12-01', collection_name='amazon.aws') - connection = module.client('ec2') + connection = module.client('ec2', retry_decorator=AWSRetry.jittered_backoff()) list_eni(connection, module) diff --git a/tests/integration/targets/ec2_eni/aliases b/tests/integration/targets/ec2_eni/aliases new file mode 100644 index 00000000000..6370a8a7daf --- /dev/null +++ b/tests/integration/targets/ec2_eni/aliases @@ -0,0 +1,3 @@ +cloud/aws +shippable/aws/group2 +ec2_eni_info diff --git a/tests/integration/targets/ec2_eni/defaults/main.yml b/tests/integration/targets/ec2_eni/defaults/main.yml new file mode 100644 index 00000000000..cb3895af089 --- /dev/null +++ b/tests/integration/targets/ec2_eni/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_eni/tasks/main.yaml b/tests/integration/targets/ec2_eni/tasks/main.yaml new file mode 100644 index 00000000000..3a0996617bf --- /dev/null +++ b/tests/integration/targets/ec2_eni/tasks/main.yaml @@ -0,0 +1,166 @@ +--- +- 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: 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') }}" + + # ============================================================ + + - name: Create 2 instances to test attaching and detaching network interfaces + ec2_instance: + name: "{{ resource_prefix }}-eni-instance-{{ item }}" + image_id: "{{ image_id }}" + vpc_subnet_id: "{{ vpc_subnet_id }}" + instance_type: t2.micro + wait: false + security_group: "{{ vpc_sg_id }}" + network: + private_ip_address: '{{ ec2_ips[item] }}' + register: ec2_instances + loop: + - 0 + - 1 + + # We only need these instances to be running + - name: set variables for the instance IDs + set_fact: + instance_id_1: "{{ ec2_instances.results[0].instance_ids[0] }}" + instance_id_2: "{{ ec2_instances.results[1].instance_ids[0] }}" + + # ============================================================ + - name: test attaching and detaching network interfaces + include_tasks: ./test_eni_basic_creation.yaml + + - name: test attaching and detaching network interfaces + include_tasks: ./test_ipaddress_assign.yaml + + - name: test attaching and detaching network interfaces + include_tasks: ./test_attachment.yaml + + - name: test modifying source_dest_check + include_tasks: ./test_modifying_source_dest_check.yaml + + - name: test modifying tags + include_tasks: ./test_modifying_tags.yaml + + # Note: will delete *both* EC2 instances + - name: test modifying delete_on_termination + include_tasks: ./test_modifying_delete_on_termination.yaml + + - name: test deleting ENIs + include_tasks: ./test_deletion.yaml + + always: + + # ============================================================ + - name: remove the network interfaces + ec2_eni: + eni_id: "{{ item }}" + force_detach: True + state: absent + ignore_errors: true + retries: 5 + loop: + - "{{ eni_id_1 | default(omit) }}" + - "{{ eni_id_2 | default(omit) }}" + + - name: terminate the instances + ec2_instance: + state: absent + instance_ids: + - "{{ instance_id_1 }}" + - "{{ instance_id_2 }}" + wait: True + ignore_errors: true + retries: 5 + when: instance_id_1 is defined and instance_id_2 is defined + + - 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 diff --git a/tests/integration/targets/ec2_eni/tasks/test_attachment.yaml b/tests/integration/targets/ec2_eni/tasks/test_attachment.yaml new file mode 100644 index 00000000000..bb0e13362ca --- /dev/null +++ b/tests/integration/targets/ec2_eni/tasks/test_attachment.yaml @@ -0,0 +1,204 @@ + # ============================================================ +- name: attach the network interface to instance 1 + ec2_eni: + instance_id: "{{ instance_id_1 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + state: present + attached: True + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- assert: + that: + - result.changed + - result.interface.attachment is defined + - result.interface.attachment is mapping + - result.interface.attachment.instance_id == instance_id_1 + - _interface_0.attachment is defined + - _interface_0.attachment is mapping + - '"attach_time" in _interface_0.attachment' + - _interface_0.attachment.attach_time is string + - '"attachment_id" in _interface_0.attachment' + - _interface_0.attachment.attachment_id.startswith("eni-attach-") + - '"delete_on_termination" in _interface_0.attachment' + - _interface_0.attachment.delete_on_termination == False + - '"device_index" in _interface_0.attachment' + - _interface_0.attachment.device_index == 1 + - '"instance_id" in _interface_0.attachment' + - _interface_0.attachment.instance_id == instance_id_1 + - '"instance_owner_id" in _interface_0.attachment' + - _interface_0.attachment.instance_owner_id is string + - '"status" in _interface_0.attachment' + - _interface_0.attachment.status == "attached" + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +- name: verify the eni is attached + ec2_eni: + instance_id: "{{ instance_id_1 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + state: present + attached: True + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- assert: + that: + - not result.changed + - result.interface.attachment is defined + - result.interface.attachment.instance_id == instance_id_1 + - _interface_0.attachment is defined + - _interface_0.attachment is mapping + - '"attach_time" in _interface_0.attachment' + - _interface_0.attachment.attach_time is string + - '"attachment_id" in _interface_0.attachment' + - _interface_0.attachment.attachment_id.startswith("eni-attach-") + - '"delete_on_termination" in _interface_0.attachment' + - _interface_0.attachment.delete_on_termination == False + - '"device_index" in _interface_0.attachment' + - _interface_0.attachment.device_index == 1 + - '"instance_id" in _interface_0.attachment' + - _interface_0.attachment.instance_id == instance_id_1 + - '"instance_owner_id" in _interface_0.attachment' + - _interface_0.attachment.instance_owner_id is string + - '"status" in _interface_0.attachment' + - _interface_0.attachment.status == "attached" + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + + # ============================================================ +- name: test attaching the network interface to a different instance + ec2_eni: + instance_id: "{{ instance_id_2 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + state: present + attached: True + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- assert: + that: + - result.changed + - result.interface.attachment is defined + - result.interface.attachment.instance_id == instance_id_2 + - _interface_0.attachment is defined + - '"instance_id" in _interface_0.attachment' + - _interface_0.attachment.instance_id == instance_id_2 + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + + # ============================================================ +- name: detach the network interface + ec2_eni: + instance_id: "{{ instance_id_2 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + state: present + attached: False + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- assert: + that: + - result.changed + - result.interface.attachment is undefined + - _interface_0.attachment is undefined + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +- name: verify the network interface was detached + ec2_eni: + instance_id: "{{ instance_id_2 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + state: present + attached: False + register: result + +- assert: + that: + - not result.changed + - result.interface.attachment is undefined + + # ============================================================ +- name: reattach the network interface to test deleting it + ec2_eni: + instance_id: "{{ instance_id_2 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + state: present + attached: True + register: result + +- assert: + that: + - result.changed + - result.interface.attachment is defined + - result.interface.attachment.instance_id == instance_id_2 + +- name: test that deleting the network interface while attached must be intentional + ec2_eni: + eni_id: "{{ eni_id_1 }}" + state: absent + register: result + ignore_errors: True + +- assert: + that: + - result.failed + - '"currently in use" in result.msg' + +# ============================================================ +- name: delete an attached network interface with force_detach + ec2_eni: + force_detach: True + eni_id: "{{ eni_id_1 }}" + state: absent + register: result + ignore_errors: True + +- assert: + that: + - result.changed + - result.interface.attachment is undefined + +- name: test removing a network interface that does not exist + ec2_eni: + force_detach: True + eni_id: "{{ eni_id_1 }}" + state: absent + register: result + +- assert: + that: + - not result.changed + - result.interface.attachment is undefined + +# ============================================================ +- name: recreate the network interface + ec2_eni: + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + register: result + +- set_fact: + eni_id_1: "{{ result.interface.id }}" diff --git a/tests/integration/targets/ec2_eni/tasks/test_deletion.yaml b/tests/integration/targets/ec2_eni/tasks/test_deletion.yaml new file mode 100644 index 00000000000..aecb625eb32 --- /dev/null +++ b/tests/integration/targets/ec2_eni/tasks/test_deletion.yaml @@ -0,0 +1,92 @@ +--- +# ============================================================ +- name: test deleting the unattached network interface by using the ID + ec2_eni: + eni_id: "{{ eni_id_1 }}" + name: "{{ resource_prefix }}" + subnet_id: "{{ vpc_subnet_id }}" + state: absent + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - result.changed + - result.interface is undefined + - '"network_interfaces" in eni_info' + - eni_id_1 not in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + +- name: test removing the network interface by ID is idempotent + ec2_eni: + eni_id: "{{ eni_id_1 }}" + name: "{{ resource_prefix }}" + subnet_id: "{{ vpc_subnet_id }}" + state: absent + register: result + +- assert: + that: + - not result.changed + - result.interface is undefined + +# ============================================================ +- name: add a name tag to the other network interface before deleting it + ec2_eni: + eni_id: "{{ eni_id_2 }}" + name: "{{ resource_prefix }}" + state: present + +- name: test deleting the unattached network interface by using the name + ec2_eni: + name: "{{ resource_prefix }}" + subnet_id: "{{ vpc_subnet_id }}" + state: absent + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_2 }}" + register: eni_info + +- assert: + that: + - result.changed + - result.interface is undefined + - '"network_interfaces" in eni_info' + - eni_id_2 not in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + +- name: test removing the network interface by name is idempotent + ec2_eni: + name: "{{ resource_prefix }}" + subnet_id: "{{ vpc_subnet_id }}" + state: absent + register: result + +- assert: + that: + - not result.changed + - result.interface is undefined + +- name: verify that the network interface ID does not exist (retry-delete by ID) + ec2_eni: + eni_id: "{{ eni_id_2 }}" + state: absent + register: result + +- assert: + that: + - not result.changed + - result.interface is undefined + +# ============================================================ + +- name: Fetch ENI info without filter + ec2_eni_info: + register: eni_info + +- name: Assert that ec2_eni_info doesn't contain the two interfaces we just deleted + assert: + that: + - '"network_interfaces" in eni_info' + - eni_id_1 not in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + - eni_id_2 not in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) diff --git a/tests/integration/targets/ec2_eni/tasks/test_eni_basic_creation.yaml b/tests/integration/targets/ec2_eni/tasks/test_eni_basic_creation.yaml new file mode 100644 index 00000000000..b18af2dc9b3 --- /dev/null +++ b/tests/integration/targets/ec2_eni/tasks/test_eni_basic_creation.yaml @@ -0,0 +1,219 @@ +--- +# ============================================================ +- name: create a network interface + ec2_eni: + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + register: result + +- assert: + that: + - result.changed + - result.interface.private_ip_addresses | length == 1 + +- set_fact: + eni_id_1: "{{ result.interface.id }}" + +- name: Fetch ENI info (by ID) + ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- name: Assert that ec2_eni_info returns all the values we expect + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + assert: + that: + - '"network_interfaces" in eni_info' + - eni_info.network_interfaces | length == 1 + - '"association" not in _interface_0' + - '"attachment" not in _interface_0' + - '"availability_zone" in _interface_0' + - _interface_0.availability_zone.startswith(aws_region) + - '"description" in _interface_0' + - _interface_0.description == "" + - '"groups" in _interface_0' + - _interface_0.groups is iterable + - _interface_0.groups | length == 1 + - '"id" in _interface_0' + - _interface_0.id.startswith("eni-") + - _interface_0.id == eni_id_1 + - '"interface_type" in _interface_0' + - _interface_0.owner_id is string + - '"ipv6_addresses" in _interface_0' + - _interface_0.ipv6_addresses is iterable + - _interface_0.ipv6_addresses | length == 0 + - '"mac_address" in _interface_0' + - _interface_0.owner_id is string + - _interface_0.mac_address | length == 17 + - '"network_interface_id" in _interface_0' + - _interface_0.network_interface_id.startswith("eni-") + - _interface_0.network_interface_id == eni_id_1 + - '"owner_id" in _interface_0' + - _interface_0.owner_id is string + - '"private_dns_name" in _interface_0' + - _interface_0.private_dns_name is string + - _interface_0.private_dns_name.endswith("ec2.internal") + - '"private_ip_address" in _interface_0' + - _interface_0.private_ip_address | ipaddr() + - _interface_0.private_ip_address == ip_1 + - '"private_ip_addresses" in _interface_0' + - _interface_0.private_ip_addresses | length == 1 + - ip_1 in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + - '"requester_id" in _interface_0' + - _interface_0.requester_id is string + - '"requester_managed" in _interface_0' + - _interface_0.requester_managed == False + - '"source_dest_check" in _interface_0' + - _interface_0.source_dest_check == True + - '"status" in _interface_0' + - _interface_0.status == "available" + - '"subnet_id" in _interface_0' + - _interface_0.subnet_id == vpc_subnet_id + - '"tag_set" in _interface_0' + - _interface_0.tag_set is mapping + - '"vpc_id" in _interface_0' + - _interface_0.vpc_id == vpc_id + +- name: test idempotence by using the same private_ip_address + ec2_eni: + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + register: result + +- assert: + that: + - not result.changed + - result.interface.id == eni_id_1 + - result.interface.private_ip_addresses | length == 1 + +# ============================================================ + +- name: create a second network interface to test IP reassignment + ec2_eni: + device_index: 1 + private_ip_address: "{{ ip_5 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + register: result + +- assert: + that: + - result.changed + - result.interface.id != eni_id_1 + +- name: save the second network interface ID for cleanup + set_fact: + eni_id_2: "{{ result.interface.id }}" + +- name: Fetch ENI info (using filter) + ec2_eni_info: + filters: + network-interface-id: '{{ eni_id_2 }}' + register: eni_info + +- name: Assert that ec2_eni_info returns all the values we expect + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + assert: + that: + - '"network_interfaces" in eni_info' + - eni_info.network_interfaces | length == 1 + - '"association" not in _interface_0' + - '"attachment" not in _interface_0' + - '"availability_zone" in _interface_0' + - _interface_0.availability_zone.startswith(aws_region) + - '"description" in _interface_0' + - _interface_0.description == "" + - '"groups" in _interface_0' + - _interface_0.groups is iterable + - _interface_0.groups | length == 1 + - '"id" in _interface_0' + - _interface_0.id.startswith("eni-") + - _interface_0.id == eni_id_2 + - '"interface_type" in _interface_0' + - _interface_0.owner_id is string + - '"ipv6_addresses" in _interface_0' + - _interface_0.ipv6_addresses is iterable + - _interface_0.ipv6_addresses | length == 0 + - '"mac_address" in _interface_0' + - _interface_0.owner_id is string + - _interface_0.mac_address | length == 17 + - '"network_interface_id" in _interface_0' + - _interface_0.network_interface_id.startswith("eni-") + - _interface_0.network_interface_id == eni_id_2 + - '"owner_id" in _interface_0' + - _interface_0.owner_id is string + - '"private_dns_name" in _interface_0' + - _interface_0.private_dns_name is string + - _interface_0.private_dns_name.endswith("ec2.internal") + - '"private_ip_address" in _interface_0' + - _interface_0.private_ip_address | ipaddr() + - _interface_0.private_ip_address == ip_5 + - '"private_ip_addresses" in _interface_0' + - _interface_0.private_ip_addresses | length == 1 + - ip_5 in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + - '"requester_id" in _interface_0' + - _interface_0.requester_id is string + - '"requester_managed" in _interface_0' + - _interface_0.requester_managed == False + - '"source_dest_check" in _interface_0' + - _interface_0.source_dest_check == True + - '"status" in _interface_0' + - _interface_0.status == "available" + - '"subnet_id" in _interface_0' + - _interface_0.subnet_id == vpc_subnet_id + - '"tag_set" in _interface_0' + - _interface_0.tag_set is mapping + - '"vpc_id" in _interface_0' + - _interface_0.vpc_id == vpc_id + +- name: Fetch ENI info without filter + ec2_eni_info: + register: eni_info + +- name: Assert that ec2_eni_info contains at least the two interfaces we expect + assert: + that: + - '"network_interfaces" in eni_info' + - eni_info.network_interfaces | length >= 2 + - eni_id_1 in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + - eni_id_2 in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + +# ============================================================ +# Run some VPC filter based tests of ec2_eni_info + +- name: Fetch ENI info with VPC filters - Available + ec2_eni_info: + filters: + vpc-id: '{{ vpc_id }}' + status: 'available' + register: eni_info + +- name: Assert that ec2_eni_info contains at least the two interfaces we expect + assert: + that: + - '"network_interfaces" in eni_info' + - eni_info.network_interfaces | length == 2 + - eni_id_1 in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + - eni_id_2 in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + +- name: Fetch ENI info with VPC filters - VPC + ec2_eni_info: + filters: + vpc-id: '{{ vpc_id }}' + register: eni_info + +- name: Assert that ec2_eni_info contains at least the two interfaces we expect + assert: + that: + - '"network_interfaces" in eni_info' + - eni_info.network_interfaces | length == 4 + - eni_id_1 in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + - eni_id_2 in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + - ec2_ips[0] in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + - ec2_ips[1] in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) diff --git a/tests/integration/targets/ec2_eni/tasks/test_ipaddress_assign.yaml b/tests/integration/targets/ec2_eni/tasks/test_ipaddress_assign.yaml new file mode 100644 index 00000000000..a0a3696e9b5 --- /dev/null +++ b/tests/integration/targets/ec2_eni/tasks/test_ipaddress_assign.yaml @@ -0,0 +1,267 @@ +--- +# ============================================================ +- name: add two implicit secondary IPs + ec2_eni: + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_address_count: 2 + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- assert: + that: + - result.changed + - result.interface.id == eni_id_1 + - result.interface.private_ip_addresses | length == 3 + - _interface_0.private_ip_addresses | length == 3 + - ip_1 in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +- name: test idempotence with two implicit secondary IPs + ec2_eni: + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_address_count: 2 + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- assert: + that: + - not result.changed + - result.interface.id == eni_id_1 + - result.interface.private_ip_addresses | length == 3 + - _interface_0.private_ip_addresses | length == 3 + - ip_1 in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +# ============================================================ +- name: ensure secondary addresses are only removed if purge is set to true + ec2_eni: + purge_secondary_private_ip_addresses: false + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_addresses: [] + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- assert: + that: + - not result.changed + - result.interface.id == eni_id_1 + - result.interface.private_ip_addresses | length == 3 + - _interface_0.private_ip_addresses | length == 3 + - ip_1 in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +# ============================================================ + +# Using secondary_private_ip_address_count leads to unpredicable IP assignment +# For the following test, first find an IP that has not been used yet + +- name: save the list of private IPs in use + set_fact: + current_private_ips: "{{ result.interface | json_query('private_ip_addresses[*].private_ip_address') | list }}" + +- name: set new_secondary_ip to an IP that has not been used + set_fact: + new_secondary_ip: "{{ [ip_2, ip_3, ip_4] | difference(current_private_ips) | first }}" + +- name: add an explicit secondary address without purging the ones added implicitly + ec2_eni: + purge_secondary_private_ip_addresses: false + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_addresses: + - "{{ new_secondary_ip }}" + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- assert: + that: + - result.changed + - result.interface.id == eni_id_1 + - result.interface.private_ip_addresses | length == 4 + - _interface_0.private_ip_addresses | length == 4 + # Only ip_1 and the explicitly requested IP are guaranteed to be present + - ip_1 in _private_ips + - new_secondary_ip in _private_ips + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + _private_ips: '{{ eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list }}' + +# ============================================================ +- name: remove secondary address + ec2_eni: + purge_secondary_private_ip_addresses: true + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_addresses: [] + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- assert: + that: + - result.changed + - result.interface.id == eni_id_1 + - result.interface.private_ip_addresses | length == 1 + - _interface_0.private_ip_addresses | length == 1 + - ip_1 in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +- name: test idempotent behavior purging secondary addresses + ec2_eni: + purge_secondary_private_ip_addresses: true + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_addresses: [] + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + +- assert: + that: + - not result.changed + - result.interface.id == eni_id_1 + - result.interface.private_ip_addresses | length == 1 + - result.interface.private_ip_addresses | length == 1 + - _interface_0.private_ip_addresses | length == 1 + - ip_1 in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +# ============================================================ + +- name: Assign secondary IP addess to second ENI + ec2_eni: + device_index: 1 + private_ip_address: "{{ ip_5 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_addresses: + - "{{ ip_4 }}" + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_2 }}' + register: eni_info + +- assert: + that: + - result.changed + - result.interface.id == eni_id_2 + - result.interface.private_ip_addresses | length == 2 + - _interface_0.private_ip_addresses | length == 2 + - ip_5 in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + - ip_4 in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +- name: test that reassignment of an IP already in use fails when not explcitly allowed (default for allow_reassignment == False) + ec2_eni: + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_addresses: + - "{{ ip_2 }}" + - "{{ ip_3 }}" + - "{{ ip_4 }}" + register: result + ignore_errors: yes + +- assert: + that: + - result.failed + - '"move is not allowed" in result.msg' + +# ============================================================ +- name: allow reassignment to add the list of secondary addresses + ec2_eni: + allow_reassignment: true + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_addresses: + - "{{ ip_2 }}" + - "{{ ip_3 }}" + - "{{ ip_4 }}" + register: result + +- assert: + that: + - result.changed + - result.interface.id == eni_id_1 + - result.interface.private_ip_addresses | length == 4 + +- name: test reassigment is idempotent + ec2_eni: + allow_reassignment: true + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_addresses: + - "{{ ip_2 }}" + - "{{ ip_3 }}" + - "{{ ip_4 }}" + register: result + +- assert: + that: + - not result.changed + - result.interface.id == eni_id_1 + +# ============================================================ + +- name: purge all the secondary addresses + ec2_eni: + purge_secondary_private_ip_addresses: true + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + secondary_private_ip_addresses: [] + register: result +- ec2_eni_info: + eni_id: '{{ eni_id_1 }}' + register: eni_info + until: _interface_0.private_ip_addresses | length == 1 + retries: 5 + delay: 2 + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +- assert: + that: + - result.changed + - _interface_0.private_ip_addresses | length == 1 + - ip_1 in ( eni_info | community.general.json_query("network_interfaces[].private_ip_addresses[].private_ip_address") | list ) + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' diff --git a/tests/integration/targets/ec2_eni/tasks/test_modifying_delete_on_termination.yaml b/tests/integration/targets/ec2_eni/tasks/test_modifying_delete_on_termination.yaml new file mode 100644 index 00000000000..8e8bd0596d4 --- /dev/null +++ b/tests/integration/targets/ec2_eni/tasks/test_modifying_delete_on_termination.yaml @@ -0,0 +1,166 @@ +# ============================================================ + +- name: ensure delete_on_termination defaults to False + ec2_eni: + instance_id: "{{ instance_id_2 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + state: present + attached: True + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - result is successful + - result.interface.attachment.delete_on_termination == false + - _interface_0.attachment.delete_on_termination == False + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +# ============================================================ + +- name: enable delete_on_termination + ec2_eni: + instance_id: "{{ instance_id_2 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + state: present + attached: True + delete_on_termination: True + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - result.changed + - result.interface.attachment.delete_on_termination == true + - _interface_0.attachment.delete_on_termination == True + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +- name: test idempotent behavior enabling delete_on_termination + ec2_eni: + instance_id: "{{ instance_id_2 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + state: present + attached: True + delete_on_termination: True + register: result + +- assert: + that: + - not result.changed + - result.interface.attachment.delete_on_termination == true + +# ============================================================ + +- name: disable delete_on_termination + ec2_eni: + instance_id: "{{ instance_id_2 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + state: present + attached: True + delete_on_termination: False + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - result.changed + - result.interface.attachment.delete_on_termination == false + - _interface_0.attachment.delete_on_termination == False + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +# ============================================================ + +- name: terminate the instance to make sure the attached ENI remains + ec2_instance: + state: absent + instance_ids: + - "{{ instance_id_2 }}" + wait: True + +- name: verify the eni still exists + ec2_eni: + eni_id: "{{ eni_id_1 }}" + state: present + register: result + +- assert: + that: + - not result.changed + - result.interface.id == eni_id_1 + - result.interface.attachment is undefined + +# ============================================================ + +- name: ensure the network interface is attached + ec2_eni: + instance_id: "{{ instance_id_1 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + attached: True + register: result + +- name: ensure delete_on_termination is true + ec2_eni: + instance_id: "{{ instance_id_1 }}" + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + attached: True + delete_on_termination: True + register: result + +- name: test terminating the instance after setting delete_on_termination to true + ec2_instance: + state: absent + instance_ids: + - "{{ instance_id_1 }}" + wait: True + +- name: verify the eni was also removed + ec2_eni: + eni_id: "{{ eni_id_1 }}" + state: absent + register: result +- ec2_eni_info: + register: eni_info + +- assert: + that: + - not result.changed + - '"network_interfaces" in eni_info' + - eni_info.network_interfaces | length >= 1 + - eni_id_1 not in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + - eni_id_2 in ( eni_info | community.general.json_query("network_interfaces[].id") | list ) + +# ============================================================ + +- name: recreate the network interface + ec2_eni: + device_index: 1 + private_ip_address: "{{ ip_1 }}" + subnet_id: "{{ vpc_subnet_id }}" + state: present + register: result + +- set_fact: + eni_id_1: "{{ result.interface.id }}" diff --git a/tests/integration/targets/ec2_eni/tasks/test_modifying_source_dest_check.yaml b/tests/integration/targets/ec2_eni/tasks/test_modifying_source_dest_check.yaml new file mode 100644 index 00000000000..3ba6c2574f1 --- /dev/null +++ b/tests/integration/targets/ec2_eni/tasks/test_modifying_source_dest_check.yaml @@ -0,0 +1,74 @@ + # ============================================================ +- name: test source_dest_check defaults to true + ec2_eni: + eni_id: "{{ eni_id_1 }}" + source_dest_check: true + state: present + register: result + +- assert: + that: + - not result.changed + - result.interface.source_dest_check == true + + # ============================================================ +- name: disable source_dest_check + ec2_eni: + eni_id: "{{ eni_id_1 }}" + source_dest_check: false + state: present + register: result + +- name: Check source_dest_check state + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + until: _interface_0.source_dest_check == False + retries: 5 + delay: 2 + +- assert: + that: + - result.changed + - _interface_0.source_dest_check == False + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +- name: test idempotence disabling source_dest_check + ec2_eni: + eni_id: "{{ eni_id_1 }}" + source_dest_check: false + state: present + register: result + +- assert: + that: + - not result.changed + - result.interface.source_dest_check == false + + # ============================================================ +- name: enable source_dest_check + ec2_eni: + eni_id: "{{ eni_id_1 }}" + source_dest_check: true + state: present + register: result + +- name: Check source_dest_check state + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + until: _interface_0.source_dest_check == True + retries: 5 + delay: 2 + +- assert: + that: + - result.changed + - _interface_0.source_dest_check == True + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' diff --git a/tests/integration/targets/ec2_eni/tasks/test_modifying_tags.yaml b/tests/integration/targets/ec2_eni/tasks/test_modifying_tags.yaml new file mode 100644 index 00000000000..8164f869f52 --- /dev/null +++ b/tests/integration/targets/ec2_eni/tasks/test_modifying_tags.yaml @@ -0,0 +1,213 @@ + # ============================================================ +- name: verify there are no tags associated with the network interface + ec2_eni: + eni_id: "{{ eni_id_1 }}" + state: present + tags: {} + register: result + +- assert: + that: + - not result.changed + - not result.interface.tags + - result.interface.name is undefined + + # ============================================================ +- name: add tags to the network interface + ec2_eni: + eni_id: "{{ eni_id_1 }}" + state: present + name: "{{ resource_prefix }}" + tags: + CreatedBy: "{{ resource_prefix }}" + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - result.changed + - result.interface.id == eni_id_1 + - result.interface.tags | length == 2 + - result.interface.tags.CreatedBy == resource_prefix + - result.interface.tags.Name == resource_prefix + - result.interface.name == resource_prefix + - _interface_0.tags | length == 2 + - _interface_0.tags.CreatedBy == resource_prefix + - _interface_0.tags.Name == resource_prefix + - _interface_0.tag_set | length == 2 + - _interface_0.tag_set.CreatedBy == resource_prefix + - _interface_0.tag_set.Name == resource_prefix + - _interface_0.name == resource_prefix + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + + # ============================================================ +- name: test idempotence by using the Name tag and the subnet + ec2_eni: + name: "{{ resource_prefix }}" + state: present + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + register: result + +- assert: + that: + - not result.changed + - result.interface.id == eni_id_1 + + # ============================================================ +- name: test tags are not purged if tags are null even if name is provided + ec2_eni: + name: "{{ resource_prefix }}" + state: present + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - not result.changed + - result.interface.id == eni_id_1 + - result.interface.tags | length == 2 + - result.interface.tags.CreatedBy == resource_prefix + - result.interface.tags.Name == resource_prefix + - result.interface.name == resource_prefix + - _interface_0.tag_set | length == 2 + - _interface_0.tag_set.CreatedBy == resource_prefix + - _interface_0.tag_set.Name == resource_prefix + - _interface_0.name == resource_prefix + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + + # ============================================================ +- name: test setting purge tags to false + ec2_eni: + eni_id: "{{ eni_id_1 }}" + state: present + purge_tags: false + tags: {} + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - not result.changed + - result.interface.tags | length == 2 + - result.interface.tags.CreatedBy == resource_prefix + - result.interface.tags.Name == resource_prefix + - result.interface.name == resource_prefix + - _interface_0.tag_set | length == 2 + - _interface_0.tag_set.CreatedBy == resource_prefix + - _interface_0.tag_set.Name == resource_prefix + - _interface_0.name == resource_prefix + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + + # ============================================================ +- name: test adding a new tag without removing any others + ec2_eni: + eni_id: "{{ eni_id_1 }}" + state: present + purge_tags: false + tags: + environment: test + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - result.changed + - result.interface.tags | length == 3 + - result.interface.tags.environment == 'test' + - result.interface.tags.CreatedBy == resource_prefix + - result.interface.tags.Name == resource_prefix + - result.interface.name == resource_prefix + - _interface_0.tag_set | length == 3 + - _interface_0.tag_set.environment == 'test' + - _interface_0.tag_set.CreatedBy == resource_prefix + - _interface_0.tag_set.Name == resource_prefix + - _interface_0.name == resource_prefix + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + + # ============================================================ +- name: test purging tags and adding a new one + ec2_eni: + name: "{{ resource_prefix }}" + state: present + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + tags: + Description: "{{ resource_prefix }}" + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - result.changed + - result.interface.id == eni_id_1 + - result.interface.tags | length == 2 + - result.interface.tags.Description == resource_prefix + - result.interface.tags.Name == resource_prefix + - result.interface.name == resource_prefix + - _interface_0.tag_set | length == 2 + - _interface_0.tag_set.Description == resource_prefix + - _interface_0.tag_set.Name == resource_prefix + - _interface_0.name == resource_prefix + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + +- name: test purging tags and adding a new one is idempotent + ec2_eni: + name: "{{ resource_prefix }}" + state: present + subnet_id: "{{ vpc_subnet_result.subnet.id }}" + tags: + Description: "{{ resource_prefix }}" + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - not result.changed + - result.interface.id == eni_id_1 + - result.interface.tags | length == 2 + - result.interface.tags.Description == resource_prefix + - result.interface.tags.Name == resource_prefix + - result.interface.name == resource_prefix + - _interface_0.tag_set | length == 2 + - _interface_0.tag_set.Description == resource_prefix + - _interface_0.tag_set.Name == resource_prefix + - _interface_0.name == resource_prefix + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}' + + # ============================================================ +- name: test purging all tags + ec2_eni: + eni_id: "{{ eni_id_1 }}" + state: present + tags: {} + register: result +- ec2_eni_info: + eni_id: "{{ eni_id_1 }}" + register: eni_info + +- assert: + that: + - result.changed + - not result.interface.tags + - result.interface.name is undefined + - _interface_0.tag_set | length == 0 + vars: + _interface_0: '{{ eni_info.network_interfaces[0] }}'