Repository: ansible/ansible

Issue:
route53_zone does not support split horizon setup
##### Issue Type:
- Bug Report
##### Plugin Name:

route53_zone
##### Ansible Version:

```
ansible 2.0.0.2
```
##### Ansible Configuration:

N/A
##### Environment:

Mac OS X against AWS api
##### Summary:

Split horizon dns setup fails in ansible.
##### Steps To Reproduce:

```
- name: "register new private zone for {{ domain }}"
  route53_zone:
    vpc_id: "{{ vpc.vpc_id }}"
    vpc_region: "{{ ec2_region }}"
    zone: "{{ domain }}"
    state: present
  register: priv_zone_out

- debug: var=priv_zone_out

- name: "register new zone for {{ domain }}"
  route53_zone:
    zone: "{{ domain }}"
    state: present
  register: pub_zone_out

- debug: var=pub_zone_out
```

<!-- You can also paste gist.github.com links for larger files.  -->
##### Expected Results:

I expect two zones in AWS one public and one private. 
##### Actual Results:

Ansible does not discern between public and private dns zones if they have the same name. It creates one first and next attempt it reuses the private one for the public one.

```
TASK [vpc : register new private zone for qa.tst.<****>] *****************
task path: /Users/olvesh/utvikling/vimond-ansible/_roles/vpc/tasks/eip.yml:39
ESTABLISH LOCAL CONNECTION FOR USER: olvesh
127.0.0.1 EXEC ( umask 22 && mkdir -p "$( echo $HOME/.ansible/tmp/ansible-tmp-1456736155.85-14866030392441 )" && echo "$( echo $HOME/.ansible/tmp/ansible-tmp-1456736155.85-14866030392441 )" )
127.0.0.1 PUT /var/folders/cw/pnp93xgs3zq16021bb6zc_680000gn/T/tmpTA3526 TO /Users/olvesh/.ansible/tmp/ansible-tmp-1456736155.85-14866030392441/route53_zone
127.0.0.1 EXEC LANG=no_NO.UTF-8 LC_ALL=no_NO.UTF-8 LC_MESSAGES=no_NO.UTF-8 /Library/Frameworks/Python.framework/Versions/2.7/Resources/Python.app/Contents/MacOS/Python /Users/olvesh/.ansible/tmp/ansible-tmp-1456736155.85-14866030392441/route53_zone; rm -rf "/Users/olvesh/.ansible/tmp/ansible-tmp-1456736155.85-14866030392441/" > /dev/null 2>&1
changed: [localhost] => {"changed": true, "invocation": {"module_args": {"aws_access_key": null, "aws_secret_key": null, "comment": "", "ec2_url": null, "profile": null, "region": null, "security_token": null, "state": "present", "validate_certs": true, "vpc_id": "vpc-59ec7d30", "vpc_region": "eu-central-1", "zone": "qa.tst.<****>"}, "module_name": "route53_zone"}, "set": {"comment": "", "name": "qa.tst.<****>.", "private_zone": true, "vpc_id": "vpc-59ec7d30", "vpc_region": "eu-central-1", "zone_id": "Z3IM3APUOSPX29"}}

TASK [vpc : debug] *************************************************************
task path: /Users/olvesh/utvikling/vimond-ansible/_roles/vpc/tasks/eip.yml:47
ok: [localhost] => {
    "priv_zone_out": {
        "changed": true,
        "set": {
            "comment": "",
            "name": "qa.tst.<****>.",
            "private_zone": true,
            "vpc_id": "vpc-****",
            "vpc_region": "eu-central-1",
            "zone_id": "Z3IM3APUOSPX29"
        }
    }
}

TASK [vpc : register new zone for qa.tst.<****>] *************************
task path: /Users/olvesh/utvikling/vimond-ansible/_roles/vpc/tasks/eip.yml:49
ESTABLISH LOCAL CONNECTION FOR USER: olvesh
127.0.0.1 EXEC ( umask 22 && mkdir -p "$( echo $HOME/.ansible/tmp/ansible-tmp-1456736158.72-146896956005879 )" && echo "$( echo $HOME/.ansible/tmp/ansible-tmp-1456736158.72-146896956005879 )" )
127.0.0.1 PUT /var/folders/cw/pnp93xgs3zq16021bb6zc_680000gn/T/tmpwMfLtN TO /Users/olvesh/.ansible/tmp/ansible-tmp-1456736158.72-146896956005879/route53_zone
127.0.0.1 EXEC LANG=no_NO.UTF-8 LC_ALL=no_NO.UTF-8 LC_MESSAGES=no_NO.UTF-8 /Library/Frameworks/Python.framework/Versions/2.7/Resources/Python.app/Contents/MacOS/Python /Users/olvesh/.ansible/tmp/ansible-tmp-1456736158.72-146896956005879/route53_zone; rm -rf "/Users/olvesh/.ansible/tmp/ansible-tmp-1456736158.72-146896956005879/" > /dev/null 2>&1
ok: [localhost] => {"changed": false, "invocation": {"module_args": {"aws_access_key": null, "aws_secret_key": null, "comment": "", "ec2_url": null, "profile": null, "region": null, "security_token": null, "state": "present", "validate_certs": true, "vpc_id": null, "vpc_region": null, "zone": "qa.tst.<****>"}, "module_name": "route53_zone"}, "set": {"comment": "", "name": "qa.tst.<****>.", "private_zone": false, "vpc_id": null, "vpc_region": null, "zone_id": "Z3IM3APUOSPX29"}}

TASK [vpc : debug] *************************************************************
task path: /Users/olvesh/utvikling/vimond-ansible/_roles/vpc/tasks/eip.yml:55
ok: [localhost] => {
    "pub_zone_out": {
        "changed": false,
        "set": {
            "comment": "",
            "name": "qa.tst.<****>.",
            "private_zone": false,
            "vpc_id": null,
            "vpc_region": null,
            "zone_id": "Z3IM3APUOSPX29"
        }
    }
}
```


_Copied from original issue: ansible/ansible-modules-extras#1756_
route53_zone does not support split horizon setup
##### Issue Type:
- Bug Report
##### Plugin Name:

route53_zone
##### Ansible Version:

```
ansible 2.0.0.2
```
##### Ansible Configuration:

N/A
##### Environment:

Mac OS X against AWS api
##### Summary:

Split horizon dns setup fails in ansible.
##### Steps To Reproduce:

```
- name: "register new private zone for {{ domain }}"
  route53_zone:
    vpc_id: "{{ vpc.vpc_id }}"
    vpc_region: "{{ ec2_region }}"
    zone: "{{ domain }}"
    state: present
  register: priv_zone_out

- debug: var=priv_zone_out

- name: "register new zone for {{ domain }}"
  route53_zone:
    zone: "{{ domain }}"
    state: present
  register: pub_zone_out

- debug: var=pub_zone_out
```

<!-- You can also paste gist.github.com links for larger files.  -->
##### Expected Results:

I expect two zones in AWS one public and one private. 
##### Actual Results:

Ansible does not discern between public and private dns zones if they have the same name. It creates one first and next attempt it reuses the private one for the public one.

```
TASK [vpc : register new private zone for qa.tst.<****>] *****************
task path: /Users/olvesh/utvikling/vimond-ansible/_roles/vpc/tasks/eip.yml:39
ESTABLISH LOCAL CONNECTION FOR USER: olvesh
127.0.0.1 EXEC ( umask 22 && mkdir -p "$( echo $HOME/.ansible/tmp/ansible-tmp-1456736155.85-14866030392441 )" && echo "$( echo $HOME/.ansible/tmp/ansible-tmp-1456736155.85-14866030392441 )" )
127.0.0.1 PUT /var/folders/cw/pnp93xgs3zq16021bb6zc_680000gn/T/tmpTA3526 TO /Users/olvesh/.ansible/tmp/ansible-tmp-1456736155.85-14866030392441/route53_zone
127.0.0.1 EXEC LANG=no_NO.UTF-8 LC_ALL=no_NO.UTF-8 LC_MESSAGES=no_NO.UTF-8 /Library/Frameworks/Python.framework/Versions/2.7/Resources/Python.app/Contents/MacOS/Python /Users/olvesh/.ansible/tmp/ansible-tmp-1456736155.85-14866030392441/route53_zone; rm -rf "/Users/olvesh/.ansible/tmp/ansible-tmp-1456736155.85-14866030392441/" > /dev/null 2>&1
changed: [localhost] => {"changed": true, "invocation": {"module_args": {"aws_access_key": null, "aws_secret_key": null, "comment": "", "ec2_url": null, "profile": null, "region": null, "security_token": null, "state": "present", "validate_certs": true, "vpc_id": "vpc-59ec7d30", "vpc_region": "eu-central-1", "zone": "qa.tst.<****>"}, "module_name": "route53_zone"}, "set": {"comment": "", "name": "qa.tst.<****>.", "private_zone": true, "vpc_id": "vpc-59ec7d30", "vpc_region": "eu-central-1", "zone_id": "Z3IM3APUOSPX29"}}

TASK [vpc : debug] *************************************************************
task path: /Users/olvesh/utvikling/vimond-ansible/_roles/vpc/tasks/eip.yml:47
ok: [localhost] => {
    "priv_zone_out": {
        "changed": true,
        "set": {
            "comment": "",
            "name": "qa.tst.<****>.",
            "private_zone": true,
            "vpc_id": "vpc-****",
            "vpc_region": "eu-central-1",
            "zone_id": "Z3IM3APUOSPX29"
        }
    }
}

TASK [vpc : register new zone for qa.tst.<****>] *************************
task path: /Users/olvesh/utvikling/vimond-ansible/_roles/vpc/tasks/eip.yml:49
ESTABLISH LOCAL CONNECTION FOR USER: olvesh
127.0.0.1 EXEC ( umask 22 && mkdir -p "$( echo $HOME/.ansible/tmp/ansible-tmp-1456736158.72-146896956005879 )" && echo "$( echo $HOME/.ansible/tmp/ansible-tmp-1456736158.72-146896956005879 )" )
127.0.0.1 PUT /var/folders/cw/pnp93xgs3zq16021bb6zc_680000gn/T/tmpwMfLtN TO /Users/olvesh/.ansible/tmp/ansible-tmp-1456736158.72-146896956005879/route53_zone
127.0.0.1 EXEC LANG=no_NO.UTF-8 LC_ALL=no_NO.UTF-8 LC_MESSAGES=no_NO.UTF-8 /Library/Frameworks/Python.framework/Versions/2.7/Resources/Python.app/Contents/MacOS/Python /Users/olvesh/.ansible/tmp/ansible-tmp-1456736158.72-146896956005879/route53_zone; rm -rf "/Users/olvesh/.ansible/tmp/ansible-tmp-1456736158.72-146896956005879/" > /dev/null 2>&1
ok: [localhost] => {"changed": false, "invocation": {"module_args": {"aws_access_key": null, "aws_secret_key": null, "comment": "", "ec2_url": null, "profile": null, "region": null, "security_token": null, "state": "present", "validate_certs": true, "vpc_id": null, "vpc_region": null, "zone": "qa.tst.<****>"}, "module_name": "route53_zone"}, "set": {"comment": "", "name": "qa.tst.<****>.", "private_zone": false, "vpc_id": null, "vpc_region": null, "zone_id": "Z3IM3APUOSPX29"}}

TASK [vpc : debug] *************************************************************
task path: /Users/olvesh/utvikling/vimond-ansible/_roles/vpc/tasks/eip.yml:55
ok: [localhost] => {
    "pub_zone_out": {
        "changed": false,
        "set": {
            "comment": "",
            "name": "qa.tst.<****>.",
            "private_zone": false,
            "vpc_id": null,
            "vpc_region": null,
            "zone_id": "Z3IM3APUOSPX29"
        }
    }
}
```


_Copied from original issue: ansible/ansible-modules-extras#1756_


Discussion:
To complement the original filer's report, if one switches the order by first creating the public zone and then the private one, the 2nd task/iteration would fail with **"msg": "Can't change VPC from public to private"**.
Happens on `ansible 2.2.0.0`, as well.
To complement the original filer's report, if one switches the order by first creating the public zone and then the private one, the 2nd task/iteration would fail with **"msg": "Can't change VPC from public to private"**.
Happens on `ansible 2.2.0.0`, as well.

Resolving patch:
diff --git a/lib/ansible/modules/cloud/amazon/route53_zone.py b/lib/ansible/modules/cloud/amazon/route53_zone.py
--- a/lib/ansible/modules/cloud/amazon/route53_zone.py
+++ b/lib/ansible/modules/cloud/amazon/route53_zone.py
@@ -51,6 +51,13 @@
             - Comment associated with the zone
         required: false
         default: ''
+    hosted_zone_id:
+        description:
+            - The unique zone identifier you want to delete or "all" if there are many zones with the same domain name.
+              Required if there are multiple zones identified with the above options
+        required: false
+        default: null
+        version_added: 2.4
 extends_documentation_fragment:
     - aws
     - ec2
@@ -89,7 +96,7 @@
     var: zone_out
 '''
 
-RETURN='''
+RETURN = '''
 comment:
     description: optional hosted zone comment
     returned: when hosted zone exists
@@ -124,8 +131,6 @@
 
 try:
     import boto
-    import boto.ec2
-    from boto import route53
     from boto.route53 import Route53Connection
     from boto.route53.zone import Zone
     HAS_BOTO = True
@@ -136,6 +141,177 @@
 from ansible.module_utils.ec2 import ec2_argument_spec, get_aws_connection_info
 
 
+def find_zones(conn, zone_in, private_zone):
+    results = conn.get_all_hosted_zones()
+    zones = {}
+    for r53zone in results['ListHostedZonesResponse']['HostedZones']:
+        if r53zone['Name'] != zone_in:
+            continue
+        # only save zone names that match the public/private setting
+        if r53zone['Config']['PrivateZone'] == 'true' and private_zone:
+            zones[r53zone.get('Id', '').replace('/hostedzone/', '')] = r53zone['Name']
+        if r53zone['Config']['PrivateZone'] == 'false' and not private_zone:
+            zones[r53zone.get('Id', '').replace('/hostedzone/', '')] = r53zone['Name']
+
+    return zones
+
+
+def create(conn, module, matching_zones):
+    zone_in = module.params.get('zone').lower()
+    vpc_id = module.params.get('vpc_id')
+    vpc_region = module.params.get('vpc_region')
+    comment = module.params.get('comment')
+
+    if not zone_in.endswith('.'):
+        zone_in += "."
+
+    private_zone = bool(vpc_id and vpc_region)
+
+    record = {
+        'private_zone': private_zone,
+        'vpc_id': vpc_id,
+        'vpc_region': vpc_region,
+        'comment': comment,
+    }
+
+    if private_zone:
+        changed, result = create_private(conn, matching_zones, vpc_id, vpc_region, zone_in, record)
+    else:
+        changed, result = create_public(conn, matching_zones, zone_in, record)
+
+    return changed, result
+
+
+def create_private(conn, matching_zones, vpc_id, vpc_region, zone_in, record):
+    for z in matching_zones:
+        zone_details = conn.get_hosted_zone(z)['GetHostedZoneResponse']  # could be in different regions or have different VPCids
+        current_vpc_id = None
+        current_vpc_region = None
+        if isinstance(zone_details['VPCs'], dict):
+            if zone_details['VPCs']['VPC']['VPCId'] == vpc_id:
+                current_vpc_id = zone_details['VPCs']['VPC']['VPCId']
+                current_vpc_region = zone_details['VPCs']['VPC']['VPCRegion']
+        else:
+            if vpc_id in [v['VPCId'] for v in zone_details['VPCs']]:
+                current_vpc_id = vpc_id
+                if vpc_region in [v['VPCRegion'] for v in zone_details['VPCs']]:
+                    current_vpc_region = vpc_region
+        if vpc_id == current_vpc_id and vpc_region == current_vpc_region:
+            record['zone_id'] = z
+            record['name'] = zone_in
+            record['msg'] = "There is already a private hosted zone in the same region with the same VPC \
+                you chose. Unable to create a new private hosted zone in the same name space."
+            changed = False
+            return changed, record
+
+    result = conn.create_hosted_zone(zone_in, **record)
+    hosted_zone = result['CreateHostedZoneResponse']['HostedZone']
+    zone_id = hosted_zone['Id'].replace('/hostedzone/', '')
+    record['zone_id'] = zone_id
+    record['name'] = zone_in
+    changed = True
+    return changed, record
+
+
+def create_public(conn, matching_zones, zone_in, record):
+    if zone_in in matching_zones.values():
+        zone_details = conn.get_hosted_zone(
+            list(matching_zones)[0])['GetHostedZoneResponse']['HostedZone']
+        changed = False
+    else:
+        result = conn.create_hosted_zone(zone_in, **record)
+        zone_details = result['CreateHostedZoneResponse']['HostedZone']
+        changed = True
+
+    record['zone_id'] = zone_details['Id'].replace('/hostedzone/', '')
+    record['name'] = zone_details['Name']
+
+    return changed, record
+
+
+def delete_private(conn, matching_zones, vpc_id, vpc_region):
+    changed = False
+    for z in matching_zones:
+        zone_details = conn.get_hosted_zone(z)['GetHostedZoneResponse']
+        if isinstance(zone_details['VPCs'], dict):
+            if zone_details['VPCs']['VPC']['VPCId'] == vpc_id and vpc_region == zone_details['VPCs']['VPC']['VPCRegion']:
+                conn.delete_hosted_zone(z)
+                changed = True
+                msg = "Successfully deleted %s" % matching_zones[z]
+                break
+            else:
+                changed = False
+        else:
+            if vpc_id in [v['VPCId'] for v in zone_details['VPCs']] and vpc_region in [v['VPCRegion'] for v in zone_details['VPCs']]:
+                conn.delete_hosted_zone(z)
+                changed = True
+                msg = "Successfully deleted %s" % matching_zones[z]
+                break
+            else:
+                changed = False
+    if not changed:
+        msg = "The vpc_id and the vpc_region do not match a private hosted zone."
+
+    return changed, msg
+
+
+def delete_public(conn, matching_zones):
+    if len(matching_zones) > 1:
+        changed = False
+        msg = "There are multiple zones that match. Use hosted_zone_id to specify the correct zone."
+    else:
+        for z in matching_zones:
+            conn.delete_hosted_zone(z)
+            changed = True
+            msg = "Successfully deleted %s" % matching_zones[z]
+    return changed, msg
+
+
+def delete_hosted_id(conn, hosted_zone_id, matching_zones):
+    if hosted_zone_id == "all":
+        deleted = []
+        for z in matching_zones:
+            deleted.append(z)
+            conn.delete_hosted_zone(z)
+        changed = True
+        msg = "Successfully deleted zones: %s" % deleted
+    elif hosted_zone_id in matching_zones:
+        conn.delete_hosted_zone(hosted_zone_id)
+        changed = True
+        msg = "Successfully deleted zone: %s" % hosted_zone_id
+    else:
+        changed = False
+        msg = "There is no zone to delete that matches hosted_zone_id %s." % hosted_zone_id
+    return changed, msg
+
+
+def delete(conn, module, matching_zones):
+    zone_in = module.params.get('zone').lower()
+    vpc_id = module.params.get('vpc_id')
+    vpc_region = module.params.get('vpc_region')
+    comment = module.params.get('comment')
+    hosted_zone_id = module.params.get('hosted_zone_id')
+
+    if not zone_in.endswith('.'):
+        zone_in += "."
+
+    private_zone = bool(vpc_id and vpc_region)
+
+    if zone_in in matching_zones.values():
+        if hosted_zone_id:
+            changed, result = delete_hosted_id(conn, hosted_zone_id, matching_zones)
+        else:
+            if private_zone:
+                changed, result = delete_private(conn, matching_zones, vpc_id, vpc_region)
+            else:
+                changed, result = delete_public(conn, matching_zones)
+    else:
+        changed = False
+        result = "No zone to delete."
+
+    return changed, result
+
+
 def main():
     argument_spec = ec2_argument_spec()
     argument_spec.update(dict(
@@ -143,7 +319,8 @@ def main():
         state=dict(default='present', choices=['present', 'absent']),
         vpc_id=dict(default=None),
         vpc_region=dict(default=None),
-        comment=dict(default='')))
+        comment=dict(default=''),
+        hosted_zone_id=dict()))
     module = AnsibleModule(argument_spec=argument_spec)
 
     if not HAS_BOTO:
@@ -153,12 +330,11 @@ def main():
     state = module.params.get('state').lower()
     vpc_id = module.params.get('vpc_id')
     vpc_region = module.params.get('vpc_region')
-    comment = module.params.get('comment')
 
-    if zone_in[-1:] != '.':
+    if not zone_in.endswith('.'):
         zone_in += "."
 
-    private_zone = vpc_id is not None and vpc_region is not None
+    private_zone = bool(vpc_id and vpc_region)
 
     _, _, aws_connect_kwargs = get_aws_connection_info(module)
 
@@ -168,70 +344,13 @@ def main():
     except boto.exception.BotoServerError as e:
         module.fail_json(msg=e.error_message)
 
-    results = conn.get_all_hosted_zones()
-    zones = {}
-
-    for r53zone in results['ListHostedZonesResponse']['HostedZones']:
-        zone_id = r53zone['Id'].replace('/hostedzone/', '')
-        zone_details = conn.get_hosted_zone(zone_id)['GetHostedZoneResponse']
-        if vpc_id and 'VPCs' in zone_details:
-            # this is to deal with this boto bug: https://github.com/boto/boto/pull/2882
-            if isinstance(zone_details['VPCs'], dict):
-                if zone_details['VPCs']['VPC']['VPCId'] == vpc_id:
-                    zones[r53zone['Name']] = zone_id
-            else: # Forward compatibility for when boto fixes that bug
-                if vpc_id in [v['VPCId'] for v in zone_details['VPCs']]:
-                    zones[r53zone['Name']] = zone_id
-        else:
-            zones[r53zone['Name']] = zone_id
-
-    record = {
-        'private_zone': private_zone,
-        'vpc_id': vpc_id,
-        'vpc_region': vpc_region,
-        'comment': comment,
-    }
-
-    if state == 'present' and zone_in in zones:
-        if private_zone:
-            details = conn.get_hosted_zone(zones[zone_in])
-
-            if 'VPCs' not in details['GetHostedZoneResponse']:
-                module.fail_json(
-                    msg="Can't change VPC from public to private"
-                )
-
-            vpc_details = details['GetHostedZoneResponse']['VPCs']['VPC']
-            current_vpc_id = vpc_details['VPCId']
-            current_vpc_region = vpc_details['VPCRegion']
-
-            if current_vpc_id != vpc_id:
-                module.fail_json(
-                    msg="Can't change VPC ID once a zone has been created"
-                )
-            if current_vpc_region != vpc_region:
-                module.fail_json(
-                    msg="Can't change VPC Region once a zone has been created"
-                )
-
-        record['zone_id'] = zones[zone_in]
-        record['name'] = zone_in
-        module.exit_json(changed=False, set=record)
-
-    elif state == 'present':
-        result = conn.create_hosted_zone(zone_in, **record)
-        hosted_zone = result['CreateHostedZoneResponse']['HostedZone']
-        zone_id = hosted_zone['Id'].replace('/hostedzone/', '')
-        record['zone_id'] = zone_id
-        record['name'] = zone_in
-        module.exit_json(changed=True, set=record)
-
-    elif state == 'absent' and zone_in in zones:
-        conn.delete_hosted_zone(zones[zone_in])
-        module.exit_json(changed=True)
-
+    zones = find_zones(conn, zone_in, private_zone)
+    if state == 'present':
+        changed, result = create(conn, module, matching_zones=zones)
     elif state == 'absent':
-        module.exit_json(changed=False)
+        changed, result = delete(conn, module, matching_zones=zones)
+
+    module.exit_json(changed=changed, result=result)
 
 if __name__ == '__main__':
     main()


Test patch:
diff --git a/test/sanity/pep8/legacy-files.txt b/test/sanity/pep8/legacy-files.txt
--- a/test/sanity/pep8/legacy-files.txt
+++ b/test/sanity/pep8/legacy-files.txt
@@ -214,7 +214,6 @@ lib/ansible/modules/cloud/amazon/redshift.py
 lib/ansible/modules/cloud/amazon/route53.py
 lib/ansible/modules/cloud/amazon/route53_facts.py
 lib/ansible/modules/cloud/amazon/route53_health_check.py
-lib/ansible/modules/cloud/amazon/route53_zone.py
 lib/ansible/modules/cloud/amazon/s3.py
 lib/ansible/modules/cloud/amazon/s3_bucket.py
 lib/ansible/modules/cloud/amazon/s3_lifecycle.py