Repository: ansible/ansible-modules-core

Issue:
ec2_eip module creates new ip on repeated run
##### Issue Type:

Bug Report
##### Ansible Version:

ansible 1.7.1
##### Environment:

Ubuntu 14.04.1
##### Summary:

If an EC2 instance already has an EIP associated to it, I would expect the ec2_eip module to do nothing/reuse the existing IP. Instead, it creates a new IP and associates this.

Note that I am not asking for the functionality provided by `reuse_existing_ip_allowed`.
##### Steps To Reproduce:
1. Associate an EIP to any instance using the ec2_eip module, for example `- ec2_eip: instance_id=i-1212f003`
2. repeat
##### Expected Results:

An instance without an EIP gets a new one allocated.
An instance with an EIP gets the same one allocated (idempotent),
##### Actual Results:

An instance with an EIP gets the another new EIP allocated (no idempotent, every new run creates and allocated a new EIP)

Here's the playbook that creates an instance and associates an EIP.
(vpc and security group exist already)

```
- name: EIP module bug
  hosts: 127.0.0.1
  connection: local
  gather_facts: no
  tasks:
    - include_vars: "aws/eip-bug/vpc_info"
    - name: Creating instance
      local_action:
        module: ec2
        key_name: "keyname"
        region: us-east-1
        group_id:
          - "{{ sg_default.group_id }}"
        instance_type: t2.micro
        image: "ami-0619bf6e"
        wait: yes
        vpc_subnet_id: "{{ subnet_test_id }}"
        assign_public_ip: yes
        exact_count: 1
        count_tag:
          Environment: "eip-bug"
          Class: eip-bug
        instance_tags:
          Environment: "eip-bug"
          Class: eip-bug
          Name: eip-bug
      register: instance
    - name: Allocating elastic IP to instance
      ec2_eip:
        in_vpc: yes
        reuse_existing_ip_allowed: yes
        state: present
        region: us-east-1
        instance_id: "{{ instance.tagged_instances[0].id }}"
      register: instance_eip
    - debug: var=instance_eip.public_ip
```

The instance task is idempotent thanks to exact_count/count_tag. The EIP task creates and associates a new EIP on every invocation. Accordingly, the number of EIPs increases; after 4 invocations the task fails due to Amazon's restriction to 3 unallocated EIPs.

EIP module output with -vvv:

```
TASK: [Allocating elastic IP to instance] ************************************* 
<127.0.0.1> REMOTE_MODULE ec2_eip region=us-east-1 state=present instance_id=i-xxx
<127.0.0.1> EXEC ['/bin/sh', '-c', 'mkdir -p $HOME/.ansible/tmp/ansible-tmp-1411248496.02-82225394781211 && chmod a+rx $HOME/.ansible/tmp/ansible-tmp-1411248496.02-82225394781211 && echo $HOME/.ansible/tmp/ansible-tmp-1411248496.02-82225394781211']
<127.0.0.1> PUT /tmp/tmpvxur9q TO /home/user/.ansible/tmp/ansible-tmp-1411248496.02-82225394781211/ec2_eip
<127.0.0.1> EXEC ['/bin/sh', '-c', u'LANG=en_US.UTF-8 LC_CTYPE=en_US.UTF-8 /home/user/.pvenvs/ansible/bin/python /home/user/.ansible/tmp/ansible-tmp-1411248496.02-82225394781211/ec2_eip; rm -rf /home/user/.ansible/tmp/ansible-tmp-1411248496.02-82225394781211/ >/dev/null 2>&1']
changed: [127.0.0.1] => {"changed": true, "public_ip": "xx.xx.xx.xx"}
```



Discussion:

Resolving patch:
diff --git a/cloud/amazon/ec2_eip.py b/cloud/amazon/ec2_eip.py
--- a/cloud/amazon/ec2_eip.py
+++ b/cloud/amazon/ec2_eip.py
@@ -74,6 +74,9 @@
 - name: output the IP
   debug: msg="Allocated IP is {{ eip.public_ip }}"
 
+- name: another way of allocating an elastic IP without associating it to anything
+  ec2_eip: state='present'
+
 - name: provision new instances with ec2
   ec2: keypair=mykey instance_type=c1.medium image=ami-40603AD1 wait=yes group=webserver count=3
   register: ec2
@@ -94,7 +97,6 @@
 except ImportError:
     HAS_BOTO = False
 
-
 wait_timeout = 0  
 
 def associate_ip_and_instance(ec2, address, instance_id, module):
@@ -141,7 +143,7 @@ def disassociate_ip_and_instance(ec2, address, instance_id, module):
         module.fail_json(msg="disassociation failed")
 
 
-def find_address(ec2, public_ip, module):
+def find_address(ec2, public_ip, module, fail_on_not_found=True):
     """ Find an existing Elastic IP address """  
     if wait_timeout != 0:
         timeout = time.time() + wait_timeout
@@ -151,7 +153,8 @@ def find_address(ec2, public_ip, module):
                 break
             except boto.exception.EC2ResponseError, e:
                 if "Address '%s' not found." % public_ip in e.message :
-                    pass
+                    if not fail_on_not_found:
+                        return None
                 else:
                     module.fail_json(msg=str(e.message))
             time.sleep(5)
@@ -162,6 +165,9 @@ def find_address(ec2, public_ip, module):
         try:
             addresses = ec2.get_all_addresses([public_ip])
         except boto.exception.EC2ResponseError, e:
+            if "Address '%s' not found." % public_ip in e.message :
+                if not fail_on_not_found:
+                    return None
             module.fail_json(msg=str(e.message))
 
     return addresses[0]
@@ -175,6 +181,16 @@ def ip_is_associated_with_instance(ec2, public_ip, instance_id, module):
     else:
         return False
 
+def instance_is_associated(ec2, instance, module):
+    """
+    Check if the given instance object is already associated with an
+    elastic IP
+    """
+    instance_ip = instance.ip_address
+    if not instance_ip:
+        return False
+    eip = find_address(ec2, instance_ip, module, fail_on_not_found=False)
+    return (eip and (eip.public_ip == instance_ip))
 
 def allocate_address(ec2, domain, module, reuse_existing_ip_allowed):
     """ Allocate a new elastic IP address (when needed) and return it """
@@ -189,7 +205,7 @@ def allocate_address(ec2, domain, module, reuse_existing_ip_allowed):
         domain_filter = { 'domain' : 'standard' }
       all_addresses = ec2.get_all_addresses(filters=domain_filter)
 
-      unassociated_addresses = filter(lambda a: a.instance_id == "", all_addresses)
+      unassociated_addresses = filter(lambda a: not a.instance_id, all_addresses)
       if unassociated_addresses:
         address = unassociated_addresses[0];
       else:
@@ -232,6 +248,15 @@ def find_instance(ec2, instance_id, module):
     module.fail_json(msg="could not find instance" + instance_id)
     
 
+def allocate_eip(ec2, eip_domain, module, reuse_existing_ip_allowed, new_eip_timeout):
+    # Allocate a new elastic IP
+    address = allocate_address(ec2, eip_domain, module, reuse_existing_ip_allowed)
+    # overriding the timeout since this is a a newly provisioned ip
+    global wait_timeout
+    wait_timeout = new_eip_timeout
+    return address
+
+
 def main():
     argument_spec = ec2_argument_spec()
     argument_spec.update(dict(
@@ -264,24 +289,32 @@ def main():
     new_eip_timeout = int(module.params.get('wait_timeout'))
 
     if state == 'present':
-        # Allocate an EIP and exit
-        if not instance_id and not public_ip:     
-            address = allocate_address(ec2, domain, module, reuse_existing_ip_allowed)
+        # If both instance_id and public_ip are not specified, allocate a new
+        # elastic IP, and exit.
+        if not instance_id and not public_ip:
+            address = allocate_eip(ec2, domain, module,
+                                   reuse_existing_ip_allowed, new_eip_timeout)
             module.exit_json(changed=True, public_ip=address.public_ip)
 
         # Return the EIP object since we've been given a public IP
         if public_ip:
             address = find_address(ec2, public_ip, module)
 
-        # Allocate an IP for instance since no public_ip was provided
-        if  instance_id and not public_ip: 
+        if instance_id and not public_ip:
             instance = find_instance(ec2, instance_id, module)
+
             if instance.vpc_id:
                 domain = "vpc"
-            address = allocate_address(ec2, domain, module, reuse_existing_ip_allowed)
-            # overriding the timeout since this is a a newly provisioned ip
-            global wait_timeout
-            wait_timeout = new_eip_timeout       
+
+            # Do nothing if the instance is already associated with an
+            # elastic IP.
+            if instance_is_associated(ec2, instance, module):
+                module.exit_json(changed=False, public_ip=instance.ip_address)
+
+            # If the instance is not already associated with an elastic IP,
+            # allocate a new one.
+            address = allocate_eip(
+                ec2, domain, module, reuse_existing_ip_allowed, new_eip_timeout)
 
         # Associate address object (provided or allocated) with instance           
         associate_ip_and_instance(ec2, address, instance_id, module)


Test patch: