Repository: ansible/ansible

Issue:
ec2_eni doesn't honour description for uniqueness
##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
ec2_eni

##### ANSIBLE VERSION
```
ansible 2.2.0.0
  config file = /Users/vlfig/projects/ansible-confluent-orchestration/ansible.cfg
  configured module search path = Default w/o overrides
```

Reproducible in 
```
ansible 2.3.0 (devel ad1e066540) last updated 2017/01/06 11:47:13 (GMT +100)
  config file = /Users/vlfig/projects/ansible-confluent-orchestration/ansible.cfg
  configured module search path = Default w/o overrides
```

*Not* reproducible in 2.1.3.0 (works as expected).

##### CONFIGURATION
plain

##### OS / ENVIRONMENT
N/A

##### SUMMARY
The ec2_eni module does not create a new ENI and instead updates the description of an existing ENI that matches its other args. This is important for the cases when we don't know the IP address nor want to attach it to an instance and just want to create an ENI.

##### STEPS TO REPRODUCE

```yaml
- ec2_eni:
    description: "my-eni-1"
    region: "{{ region }}"
    subnet_id: "{{ my_subnet }}"
    state: present
    security_groups: "{{ my_sg }}"
```
and then
```yaml
- ec2_eni:
    description: "my-eni-2"
    region: "{{ region }}"
    subnet_id: "{{ my_subnet }}"
    state: present
    security_groups: "{{ my_sg }}"
```

(one could also use `with_items` with the same results)

##### EXPECTED RESULTS

Two ENIs created with descriptions "my-eni-1" and "my-eni-2".

##### ACTUAL RESULTS

Only one ENI gets created, with description "my-eni-2". First task creates, second task updates the same ENI.

Note `"id": "eni-57cf9d28"` is the same in both invocations.
```
 _____________________________
< TASK [test-image : ec2_eni] >
 -----------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

task path: [snip]/roles/test-image/tasks/main.yml:34
Using module file [snip]/lib/python2.7/site-packages/ansible/modules/extras/cloud/amazon/ec2_eni.py
<127.0.0.1> ESTABLISH LOCAL CONNECTION FOR USER: vlfig
<127.0.0.1> EXEC /bin/sh -c '/usr/bin/env python && sleep 0'
changed: [localhost] => {
    "changed": true,
    "interface": {
        "description": "my-eni-1",
        "groups": {
            "sg-483a9d20": "[snip]"
        },
        "id": "eni-57cf9d28",
        "mac_address": "06:2f:d2:cb:38:f7",
        "owner_id": "[snip]",
        "private_ip_address": "10.1.12.57",
        "private_ip_addresses": [
            {
                "primary_address": true,
                "private_ip_address": "10.1.12.57"
            }
        ],
        "source_dest_check": true,
        "status": "pending",
        "subnet_id": "subnet-1f3b6364",
        "vpc_id": "vpc-[snip]cb"
    },
    "invocation": {
        "module_args": {
            "attached": null,
            "aws_access_key": "[snip]",
            "aws_secret_key": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER",
            "delete_on_termination": null,
            "description": "my-eni-1",
            "device_index": 0,
            "ec2_url": null,
            "eni_id": null,
            "force_detach": false,
            "instance_id": null,
            "private_ip_address": null,
            "profile": null,
            "region": "eu-central-1",
            "secondary_private_ip_address_count": null,
            "secondary_private_ip_addresses": null,
            "security_groups": [
                "sg-483a9d20",
                "sg-483a9d20"
            ],
            "security_token": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER",
            "source_dest_check": null,
            "state": "present",
            "subnet_id": "subnet-1f3b6364",
            "validate_certs": true
        },
        "module_name": "ec2_eni"
    }
}
 _____________________________
< TASK [test-image : ec2_eni] >
 -----------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

task path: [snip]/roles/test-image/tasks/main.yml:44
Using module file [snip]/lib/python2.7/site-packages/ansible/modules/extras/cloud/amazon/ec2_eni.py
<127.0.0.1> ESTABLISH LOCAL CONNECTION FOR USER: vlfig
<127.0.0.1> EXEC /bin/sh -c '/usr/bin/env python && sleep 0'
changed: [localhost] => {
    "changed": true,
    "interface": {
        "description": "my-eni-2",
        "groups": {
            "sg-483a9d20": "[snip]"
        },
        "id": "eni-57cf9d28",
        "mac_address": "06:2f:d2:cb:38:f7",
        "owner_id": "[snip]",
        "private_ip_address": "10.1.12.57",
        "private_ip_addresses": [
            {
                "primary_address": true,
                "private_ip_address": "10.1.12.57"
            }
        ],
        "source_dest_check": true,
        "status": "available",
        "subnet_id": "subnet-1f3b6364",
        "vpc_id": "vpc-[snip]cb"
    },
    "invocation": {
        "module_args": {
            "attached": null,
            "aws_access_key": "[snip]",
            "aws_secret_key": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER",
            "delete_on_termination": null,
            "description": "my-eni-2",
            "device_index": 0,
            "ec2_url": null,
            "eni_id": null,
            "force_detach": false,
            "instance_id": null,
            "private_ip_address": null,
            "profile": null,
            "region": "eu-central-1",
            "secondary_private_ip_address_count": null,
            "secondary_private_ip_addresses": null,
            "security_groups": [
                "sg-483a9d20",
                "sg-483a9d20"
            ],
            "security_token": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER",
            "source_dest_check": null,
            "state": "present",
            "subnet_id": "subnet-1f3b6364",
            "validate_certs": true
        },
        "module_name": "ec2_eni"
    }
}
[snip]
localhost                  : ok=12   changed=3    unreachable=0    failed=0
```



Discussion:
cc @wimnat
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/lib/ansible/modules/cloud/amazon/ec2_eni.py b/lib/ansible/modules/cloud/amazon/ec2_eni.py
--- a/lib/ansible/modules/cloud/amazon/ec2_eni.py
+++ b/lib/ansible/modules/cloud/amazon/ec2_eni.py
@@ -47,8 +47,8 @@
     default: null
   subnet_id:
     description:
-      - ID of subnet in which to create the ENI. Only required when state=present.
-    required: true
+      - ID of subnet in which to create the ENI.
+    required: false
   description:
     description:
       - Optional description of the ENI.
@@ -108,6 +108,9 @@
 extends_documentation_fragment:
     - aws
     - ec2
+notes:
+    - This module identifies and ENI based on either the eni_id, a combination of private_ip_address and subnet_id,
+      or a combination of instance_id and device_id. Any of these options will let you specify a particular ENI.
 '''
 
 EXAMPLES = '''
@@ -162,6 +165,12 @@
     description: "My new description"
     state: present
 
+# Update an ENI identifying it by private_ip_address and subnet_id
+- ec2_eni:
+    subnet_id: subnet-xxxxxxx
+    private_ip_address: 172.16.1.1
+    description: "My new description"
+
 # Detach an ENI from an instance
 - ec2_eni:
     eni_id: eni-xxxxxxx
@@ -318,34 +327,32 @@ def create_eni(connection, vpc_id, module):
     changed = False
 
     try:
-        eni = find_eni(connection, module)
-        if eni is None:
-            eni = connection.create_network_interface(subnet_id, private_ip_address, description, security_groups)
-            if attached is True and instance_id is not None:
-                try:
-                    eni.attach(instance_id, device_index)
-                except BotoServerError:
-                    eni.delete()
-                    raise
-                # Wait to allow creation / attachment to finish
-                wait_for_eni(eni, "attached")
-                eni.update()
+        eni = connection.create_network_interface(subnet_id, private_ip_address, description, security_groups)
+        if attached and instance_id is not None:
+            try:
+                eni.attach(instance_id, device_index)
+            except BotoServerError:
+                eni.delete()
+                raise
+            # Wait to allow creation / attachment to finish
+            wait_for_eni(eni, "attached")
+            eni.update()
 
-            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()
-                    raise
+        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()
+                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()
-                    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()
+                raise
 
-            changed = True
+        changed = True
 
     except BotoServerError as e:
         module.fail_json(msg=e.message)
@@ -475,25 +482,31 @@ def detach_eni(eni, module):
         module.exit_json(changed=False, interface=get_eni_info(eni))
 
 
-def find_eni(connection, module):
+def uniquely_find_eni(connection, module):
 
     eni_id = module.params.get("eni_id")
-    subnet_id = module.params.get('subnet_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')
 
     try:
         filters = {}
-        if subnet_id:
-            filters['subnet-id'] = subnet_id
-        if private_ip_address:
+
+        # 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
+
+        if private_ip_address and subnet_id:
             filters['private-ip-address'] = private_ip_address
-        else:
-            if instance_id:
-                filters['attachment.instance-id'] = instance_id
-            if device_index:
-                filters['attachment.device-index'] = device_index
+            filters['subnet-id'] = subnet_id
+
+        if instance_id and device_index:
+            filters['attachment.instance-id'] = instance_id
+            filters['attachment.device-index'] = device_index
+
+        if eni_id is None and len(filters) == 0:
+            return None
 
         eni_result = connection.get_all_network_interfaces(eni_id, filters=filters)
         if len(eni_result) == 1:
@@ -551,7 +564,6 @@ def main():
                                ['secondary_private_ip_addresses', 'secondary_private_ip_address_count']
                                ],
                            required_if=([
-                               ('state', 'present', ['subnet_id']),
                                ('state', 'absent', ['eni_id']),
                                ('attached', True, ['instance_id'])
                                ])
@@ -574,13 +586,16 @@ def main():
     state = module.params.get("state")
 
     if state == 'present':
-        subnet_id = module.params.get("subnet_id")
-        vpc_id = _get_vpc_id(vpc_connection, module, subnet_id)
-
-        eni = find_eni(connection, module)
+        eni = uniquely_find_eni(connection, module)
         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")
+
+            vpc_id = _get_vpc_id(vpc_connection, module, subnet_id)
             create_eni(connection, vpc_id, module)
         else:
+            vpc_id = eni.vpc_id
             modify_eni(connection, vpc_id, module, eni)
 
     elif state == 'absent':


Test patch: