1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Copyright: (c) 2018, Ansible Project
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7from __future__ import absolute_import, division, print_function
8
9__metaclass__ = type
10
11
12DOCUMENTATION = r'''
13---
14module: route53
15version_added: 1.0.0
16requirements: [ "boto3", "botocore" ]
17short_description: add or delete entries in Amazons Route 53 DNS service
18description:
19     - Creates and deletes DNS records in Amazons Route 53 service.
20options:
21  state:
22    description:
23      - Specifies the state of the resource record. As of Ansible 2.4, the I(command) option has been changed
24        to I(state) as default and the choices C(present) and C(absent) have been added, but I(command) still works as well.
25    required: true
26    aliases: [ 'command' ]
27    choices: [ 'present', 'absent', 'get', 'create', 'delete' ]
28    type: str
29  zone:
30    description:
31      - The DNS zone to modify.
32      - This is a required parameter, if parameter I(hosted_zone_id) is not supplied.
33    type: str
34  hosted_zone_id:
35    description:
36      - The Hosted Zone ID of the DNS zone to modify.
37      - This is a required parameter, if parameter I(zone) is not supplied.
38    type: str
39  record:
40    description:
41      - The full DNS record to create or delete.
42    required: true
43    type: str
44  ttl:
45    description:
46      - The TTL, in second, to give the new record.
47      - Mutually exclusive with I(alias).
48    default: 3600
49    type: int
50  type:
51    description:
52      - The type of DNS record to create.
53    required: true
54    choices: [ 'A', 'CNAME', 'MX', 'AAAA', 'TXT', 'PTR', 'SRV', 'SPF', 'CAA', 'NS', 'SOA' ]
55    type: str
56  alias:
57    description:
58      - Indicates if this is an alias record.
59      - Mutually exclusive with I(ttl).
60      - Defaults to C(false).
61    type: bool
62  alias_hosted_zone_id:
63    description:
64      - The hosted zone identifier.
65    type: str
66  alias_evaluate_target_health:
67    description:
68      - Whether or not to evaluate an alias target health. Useful for aliases to Elastic Load Balancers.
69    type: bool
70    default: false
71  value:
72    description:
73      - The new value when creating a DNS record.  YAML lists or multiple comma-spaced values are allowed for non-alias records.
74      - When deleting a record all values for the record must be specified or Route 53 will not delete it.
75    type: list
76    elements: str
77  overwrite:
78    description:
79      - Whether an existing record should be overwritten on create if values do not match.
80    type: bool
81  retry_interval:
82    description:
83      - In the case that Route 53 is still servicing a prior request, this module will wait and try again after this many seconds.
84        If you have many domain names, the default of C(500) seconds may be too long.
85    default: 500
86    type: int
87  private_zone:
88    description:
89      - If set to C(true), the private zone matching the requested name within the domain will be used if there are both public and private zones.
90      - The default is to use the public zone.
91    type: bool
92    default: false
93  identifier:
94    description:
95      - Have to be specified for Weighted, latency-based and failover resource record sets only.
96        An identifier that differentiates among multiple resource record sets that have the same combination of DNS name and type.
97    type: str
98  weight:
99    description:
100      - Weighted resource record sets only. Among resource record sets that
101        have the same combination of DNS name and type, a value that
102        determines what portion of traffic for the current resource record set
103        is routed to the associated location.
104      - Mutually exclusive with I(region) and I(failover).
105    type: int
106  region:
107    description:
108      - Latency-based resource record sets only Among resource record sets
109        that have the same combination of DNS name and type, a value that
110        determines which region this should be associated with for the
111        latency-based routing
112      - Mutually exclusive with I(weight) and I(failover).
113    type: str
114  health_check:
115    description:
116      - Health check to associate with this record
117    type: str
118  failover:
119    description:
120      - Failover resource record sets only. Whether this is the primary or
121        secondary resource record set. Allowed values are PRIMARY and SECONDARY
122      - Mutually exclusive with I(weight) and I(region).
123    type: str
124    choices: ['SECONDARY', 'PRIMARY']
125  vpc_id:
126    description:
127      - "When used in conjunction with private_zone: true, this will only modify records in the private hosted zone attached to this VPC."
128      - This allows you to have multiple private hosted zones, all with the same name, attached to different VPCs.
129    type: str
130  wait:
131    description:
132      - Wait until the changes have been replicated to all Amazon Route 53 DNS servers.
133    type: bool
134    default: false
135  wait_timeout:
136    description:
137      - How long to wait for the changes to be replicated, in seconds.
138    default: 300
139    type: int
140author:
141- Bruce Pennypacker (@bpennypacker)
142- Mike Buzzetti (@jimbydamonk)
143extends_documentation_fragment:
144- amazon.aws.aws
145'''
146
147RETURN = r'''
148nameservers:
149  description: Nameservers associated with the zone.
150  returned: when state is 'get'
151  type: list
152  sample:
153  - ns-1036.awsdns-00.org.
154  - ns-516.awsdns-00.net.
155  - ns-1504.awsdns-00.co.uk.
156  - ns-1.awsdns-00.com.
157set:
158  description: Info specific to the resource record.
159  returned: when state is 'get'
160  type: complex
161  contains:
162    alias:
163      description: Whether this is an alias.
164      returned: always
165      type: bool
166      sample: false
167    failover:
168      description: Whether this is the primary or secondary resource record set.
169      returned: always
170      type: str
171      sample: PRIMARY
172    health_check:
173      description: health_check associated with this record.
174      returned: always
175      type: str
176    identifier:
177      description: An identifier that differentiates among multiple resource record sets that have the same combination of DNS name and type.
178      returned: always
179      type: str
180    record:
181      description: Domain name for the record set.
182      returned: always
183      type: str
184      sample: new.foo.com.
185    region:
186      description: Which region this should be associated with for latency-based routing.
187      returned: always
188      type: str
189      sample: us-west-2
190    ttl:
191      description: Resource record cache TTL.
192      returned: always
193      type: str
194      sample: '3600'
195    type:
196      description: Resource record set type.
197      returned: always
198      type: str
199      sample: A
200    value:
201      description: Record value.
202      returned: always
203      type: str
204      sample: 52.43.18.27
205    values:
206      description: Record Values.
207      returned: always
208      type: list
209      sample:
210      - 52.43.18.27
211    weight:
212      description: Weight of the record.
213      returned: always
214      type: str
215      sample: '3'
216    zone:
217      description: Zone this record set belongs to.
218      returned: always
219      type: str
220      sample: foo.bar.com.
221'''
222
223EXAMPLES = r'''
224- name: Add new.foo.com as an A record with 3 IPs and wait until the changes have been replicated
225  community.aws.route53:
226    state: present
227    zone: foo.com
228    record: new.foo.com
229    type: A
230    ttl: 7200
231    value: 1.1.1.1,2.2.2.2,3.3.3.3
232    wait: yes
233- name: Update new.foo.com as an A record with a list of 3 IPs and wait until the changes have been replicated
234  community.aws.route53:
235    state: present
236    zone: foo.com
237    record: new.foo.com
238    type: A
239    ttl: 7200
240    value:
241      - 1.1.1.1
242      - 2.2.2.2
243      - 3.3.3.3
244    wait: yes
245- name: Retrieve the details for new.foo.com
246  community.aws.route53:
247    state: get
248    zone: foo.com
249    record: new.foo.com
250    type: A
251  register: rec
252- name: Delete new.foo.com A record using the results from the get command
253  community.aws.route53:
254    state: absent
255    zone: foo.com
256    record: "{{ rec.set.record }}"
257    ttl: "{{ rec.set.ttl }}"
258    type: "{{ rec.set.type }}"
259    value: "{{ rec.set.value }}"
260# Add an AAAA record.  Note that because there are colons in the value
261# that the IPv6 address must be quoted. Also shows using the old form command=create.
262- name: Add an AAAA record
263  community.aws.route53:
264    command: create
265    zone: foo.com
266    record: localhost.foo.com
267    type: AAAA
268    ttl: 7200
269    value: "::1"
270# For more information on SRV records see:
271# https://en.wikipedia.org/wiki/SRV_record
272- name: Add a SRV record with multiple fields for a service on port 22222
273  community.aws.route53:
274    state: present
275    zone: foo.com
276    record: "_example-service._tcp.foo.com"
277    type: SRV
278    value: "0 0 22222 host1.foo.com,0 0 22222 host2.foo.com"
279# Note that TXT and SPF records must be surrounded
280# by quotes when sent to Route 53:
281- name: Add a TXT record.
282  community.aws.route53:
283    state: present
284    zone: foo.com
285    record: localhost.foo.com
286    type: TXT
287    ttl: 7200
288    value: '"bar"'
289- name: Add an alias record that points to an Amazon ELB
290  community.aws.route53:
291    state: present
292    zone: foo.com
293    record: elb.foo.com
294    type: A
295    value: "{{ elb_dns_name }}"
296    alias: True
297    alias_hosted_zone_id: "{{ elb_zone_id }}"
298- name: Retrieve the details for elb.foo.com
299  community.aws.route53:
300    state: get
301    zone: foo.com
302    record: elb.foo.com
303    type: A
304  register: rec
305- name: Delete an alias record using the results from the get command
306  community.aws.route53:
307    state: absent
308    zone: foo.com
309    record: "{{ rec.set.record }}"
310    ttl: "{{ rec.set.ttl }}"
311    type: "{{ rec.set.type }}"
312    value: "{{ rec.set.value }}"
313    alias: True
314    alias_hosted_zone_id: "{{ rec.set.alias_hosted_zone_id }}"
315- name: Add an alias record that points to an Amazon ELB and evaluates it health
316  community.aws.route53:
317    state: present
318    zone: foo.com
319    record: elb.foo.com
320    type: A
321    value: "{{ elb_dns_name }}"
322    alias: True
323    alias_hosted_zone_id: "{{ elb_zone_id }}"
324    alias_evaluate_target_health: True
325- name: Add an AAAA record with Hosted Zone ID
326  community.aws.route53:
327    state: present
328    zone: foo.com
329    hosted_zone_id: Z2AABBCCDDEEFF
330    record: localhost.foo.com
331    type: AAAA
332    ttl: 7200
333    value: "::1"
334- name: Use a routing policy to distribute traffic
335  community.aws.route53:
336    state: present
337    zone: foo.com
338    record: www.foo.com
339    type: CNAME
340    value: host1.foo.com
341    ttl: 30
342    # Routing policy
343    identifier: "host1@www"
344    weight: 100
345    health_check: "d994b780-3150-49fd-9205-356abdd42e75"
346- name: Add a CAA record (RFC 6844)
347  community.aws.route53:
348    state: present
349    zone: example.com
350    record: example.com
351    type: CAA
352    value:
353      - 0 issue "ca.example.net"
354      - 0 issuewild ";"
355      - 0 iodef "mailto:security@example.com"
356'''
357
358from operator import itemgetter
359
360try:
361    import botocore
362except ImportError:
363    pass  # Handled by AnsibleAWSModule
364
365from ansible.module_utils._text import to_native
366from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict
367
368from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule
369from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_message
370from ansible_collections.amazon.aws.plugins.module_utils.core import scrub_none_parameters
371from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry
372
373MAX_AWS_RETRIES = 10  # How many retries to perform when an API call is failing
374WAIT_RETRY = 5  # how many seconds to wait between propagation status polls
375
376
377@AWSRetry.jittered_backoff(retries=MAX_AWS_RETRIES)
378def _list_record_sets(route53, **kwargs):
379    paginator = route53.get_paginator('list_resource_record_sets')
380    return paginator.paginate(**kwargs).build_full_result()['ResourceRecordSets']
381
382
383@AWSRetry.jittered_backoff(retries=MAX_AWS_RETRIES)
384def _list_hosted_zones(route53, **kwargs):
385    paginator = route53.get_paginator('list_hosted_zones')
386    return paginator.paginate(**kwargs).build_full_result()['HostedZones']
387
388
389def get_record(route53, zone_id, record_name, record_type, record_identifier):
390    record_sets_results = _list_record_sets(route53, HostedZoneId=zone_id)
391
392    for record_set in record_sets_results:
393        record_set['Name'] = record_set['Name'].encode().decode('unicode_escape')
394        # If the record name and type is not equal, move to the next record
395        if (record_name.lower(), record_type) != (record_set['Name'].lower(), record_set['Type']):
396            continue
397
398        if record_identifier and record_identifier != record_set.get("SetIdentifier"):
399            continue
400
401        return record_set
402
403    return None
404
405
406def get_zone_id_by_name(route53, module, zone_name, want_private, want_vpc_id):
407    """Finds a zone by name or zone_id"""
408    hosted_zones_results = _list_hosted_zones(route53)
409
410    for zone in hosted_zones_results:
411        # only save this zone id if the private status of the zone matches
412        # the private_zone_in boolean specified in the params
413        private_zone = module.boolean(zone['Config'].get('PrivateZone', False))
414        zone_id = zone['Id'].replace("/hostedzone/", "")
415
416        if private_zone == want_private and zone['Name'] == zone_name:
417            if want_vpc_id:
418                # NOTE: These details aren't available in other boto methods, hence the necessary
419                # extra API call
420                hosted_zone = route53.get_hosted_zone(aws_retry=True, Id=zone_id)
421                if want_vpc_id in [v['VPCId'] for v in hosted_zone['VPCs']]:
422                    return zone_id
423            else:
424                return zone_id
425    return None
426
427
428def format_record(record_in, zone_in, zone_id):
429    """
430    Formats a record in a way that's consistent with the pre-boto3 migration values
431    as well as returning the 'normal' boto3 style values
432    """
433    if not record_in:
434        return None
435
436    record = dict(record_in)
437    record['zone'] = zone_in
438    record['hosted_zone_id'] = zone_id
439
440    record['type'] = record_in.get('Type', None)
441    record['record'] = record_in.get('Name').encode().decode('unicode_escape')
442    record['ttl'] = record_in.get('TTL', None)
443    record['identifier'] = record_in.get('SetIdentifier', None)
444    record['weight'] = record_in.get('Weight', None)
445    record['region'] = record_in.get('Region', None)
446    record['failover'] = record_in.get('Failover', None)
447    record['health_check'] = record_in.get('HealthCheckId', None)
448
449    if record['ttl']:
450        record['ttl'] = str(record['ttl'])
451    if record['weight']:
452        record['weight'] = str(record['weight'])
453    if record['region']:
454        record['region'] = str(record['region'])
455
456    if record_in.get('AliasTarget'):
457        record['alias'] = True
458        record['value'] = record_in['AliasTarget'].get('DNSName')
459        record['values'] = [record_in['AliasTarget'].get('DNSName')]
460        record['alias_hosted_zone_id'] = record_in['AliasTarget'].get('HostedZoneId')
461        record['alias_evaluate_target_health'] = record_in['AliasTarget'].get('EvaluateTargetHealth')
462    else:
463        record['alias'] = False
464        records = [r.get('Value') for r in record_in.get('ResourceRecords')]
465        record['value'] = ','.join(sorted(records))
466        record['values'] = sorted(records)
467
468    return record
469
470
471def get_hosted_zone_nameservers(route53, zone_id):
472    hosted_zone_name = route53.get_hosted_zone(aws_retry=True, Id=zone_id)['HostedZone']['Name']
473    resource_records_sets = _list_record_sets(route53, HostedZoneId=zone_id)
474
475    nameservers_records = list(
476        filter(lambda record: record['Name'] == hosted_zone_name and record['Type'] == 'NS', resource_records_sets)
477    )[0]['ResourceRecords']
478
479    return [ns_record['Value'] for ns_record in nameservers_records]
480
481
482def main():
483    argument_spec = dict(
484        state=dict(type='str', required=True, choices=['absent', 'create', 'delete', 'get', 'present'], aliases=['command']),
485        zone=dict(type='str'),
486        hosted_zone_id=dict(type='str'),
487        record=dict(type='str', required=True),
488        ttl=dict(type='int', default=3600),
489        type=dict(type='str', required=True, choices=['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'PTR', 'SOA', 'SPF', 'SRV', 'TXT']),
490        alias=dict(type='bool'),
491        alias_hosted_zone_id=dict(type='str'),
492        alias_evaluate_target_health=dict(type='bool', default=False),
493        value=dict(type='list', elements='str'),
494        overwrite=dict(type='bool'),
495        retry_interval=dict(type='int', default=500),
496        private_zone=dict(type='bool', default=False),
497        identifier=dict(type='str'),
498        weight=dict(type='int'),
499        region=dict(type='str'),
500        health_check=dict(type='str'),
501        failover=dict(type='str', choices=['PRIMARY', 'SECONDARY']),
502        vpc_id=dict(type='str'),
503        wait=dict(type='bool', default=False),
504        wait_timeout=dict(type='int', default=300),
505    )
506
507    module = AnsibleAWSModule(
508        argument_spec=argument_spec,
509        supports_check_mode=True,
510        required_one_of=[['zone', 'hosted_zone_id']],
511        # If alias is True then you must specify alias_hosted_zone as well
512        required_together=[['alias', 'alias_hosted_zone_id']],
513        # state=present, absent, create, delete THEN value is required
514        required_if=(
515            ('state', 'present', ['value']),
516            ('state', 'create', ['value']),
517            ('state', 'absent', ['value']),
518            ('state', 'delete', ['value']),
519        ),
520        # failover, region and weight are mutually exclusive
521        mutually_exclusive=[
522            ('failover', 'region', 'weight'),
523            ('alias', 'ttl'),
524        ],
525        # failover, region and weight require identifier
526        required_by=dict(
527            failover=('identifier',),
528            region=('identifier',),
529            weight=('identifier',),
530        ),
531    )
532
533    if module.params['state'] in ('present', 'create'):
534        command_in = 'create'
535    elif module.params['state'] in ('absent', 'delete'):
536        command_in = 'delete'
537    elif module.params['state'] == 'get':
538        command_in = 'get'
539
540    zone_in = (module.params.get('zone') or '').lower()
541    hosted_zone_id_in = module.params.get('hosted_zone_id')
542    ttl_in = module.params.get('ttl')
543    record_in = module.params.get('record').lower()
544    type_in = module.params.get('type')
545    value_in = module.params.get('value') or []
546    alias_in = module.params.get('alias')
547    alias_hosted_zone_id_in = module.params.get('alias_hosted_zone_id')
548    alias_evaluate_target_health_in = module.params.get('alias_evaluate_target_health')
549    retry_interval_in = module.params.get('retry_interval')
550
551    if module.params['vpc_id'] is not None:
552        private_zone_in = True
553    else:
554        private_zone_in = module.params.get('private_zone')
555
556    identifier_in = module.params.get('identifier')
557    weight_in = module.params.get('weight')
558    region_in = module.params.get('region')
559    health_check_in = module.params.get('health_check')
560    failover_in = module.params.get('failover')
561    vpc_id_in = module.params.get('vpc_id')
562    wait_in = module.params.get('wait')
563    wait_timeout_in = module.params.get('wait_timeout')
564
565    if zone_in[-1:] != '.':
566        zone_in += "."
567
568    if record_in[-1:] != '.':
569        record_in += "."
570
571    if command_in == 'create' or command_in == 'delete':
572        if alias_in and len(value_in) != 1:
573            module.fail_json(msg="parameter 'value' must contain a single dns name for alias records")
574        if (weight_in is None and region_in is None and failover_in is None) and identifier_in is not None:
575            module.fail_json(msg="You have specified identifier which makes sense only if you specify one of: weight, region or failover.")
576
577    # connect to the route53 endpoint
578    try:
579        route53 = module.client(
580            'route53',
581            retry_decorator=AWSRetry.jittered_backoff(retries=MAX_AWS_RETRIES, delay=retry_interval_in)
582        )
583    except botocore.exceptions.HTTPClientError as e:
584        module.fail_json_aws(e, msg='Failed to connect to AWS')
585
586    # Find the named zone ID
587    zone_id = hosted_zone_id_in or get_zone_id_by_name(route53, module, zone_in, private_zone_in, vpc_id_in)
588
589    # Verify that the requested zone is already defined in Route53
590    if zone_id is None:
591        errmsg = "Zone %s does not exist in Route53" % (zone_in or hosted_zone_id_in)
592        module.fail_json(msg=errmsg)
593
594    aws_record = get_record(route53, zone_id, record_in, type_in, identifier_in)
595
596    resource_record_set = scrub_none_parameters({
597        'Name': record_in,
598        'Type': type_in,
599        'Weight': weight_in,
600        'Region': region_in,
601        'Failover': failover_in,
602        'TTL': ttl_in,
603        'ResourceRecords': [dict(Value=value) for value in value_in],
604        'HealthCheckId': health_check_in,
605    })
606
607    if alias_in:
608        resource_record_set['AliasTarget'] = dict(
609            HostedZoneId=alias_hosted_zone_id_in,
610            DNSName=value_in[0],
611            EvaluateTargetHealth=alias_evaluate_target_health_in
612        )
613        if 'ResourceRecords' in resource_record_set:
614            del resource_record_set['ResourceRecords']
615        if 'TTL' in resource_record_set:
616            del resource_record_set['TTL']
617
618    # On CAA records order doesn't matter
619    if type_in == 'CAA':
620        resource_record_set['ResourceRecords'] = sorted(resource_record_set['ResourceRecords'], key=itemgetter('Value'))
621        if aws_record:
622            aws_record['ResourceRecords'] = sorted(aws_record['ResourceRecords'], key=itemgetter('Value'))
623
624    if command_in == 'create' and aws_record == resource_record_set:
625        rr_sets = [camel_dict_to_snake_dict(resource_record_set)]
626        module.exit_json(changed=False, resource_records_sets=rr_sets)
627
628    if command_in == 'get':
629        if type_in == 'NS':
630            ns = aws_record.get('values', [])
631        else:
632            # Retrieve name servers associated to the zone.
633            ns = get_hosted_zone_nameservers(route53, zone_id)
634
635        formatted_aws = format_record(aws_record, zone_in, zone_id)
636        rr_sets = [camel_dict_to_snake_dict(aws_record)]
637        module.exit_json(changed=False, set=formatted_aws, nameservers=ns, resource_record_sets=rr_sets)
638
639    if command_in == 'delete' and not aws_record:
640        module.exit_json(changed=False)
641
642    if command_in == 'create' or command_in == 'delete':
643        if command_in == 'create' and aws_record:
644            if not module.params['overwrite']:
645                module.fail_json(msg="Record already exists with different value. Set 'overwrite' to replace it")
646            command = 'UPSERT'
647        else:
648            command = command_in.upper()
649
650    if not module.check_mode:
651        try:
652            change_resource_record_sets = route53.change_resource_record_sets(
653                aws_retry=True,
654                HostedZoneId=zone_id,
655                ChangeBatch=dict(
656                    Changes=[
657                        dict(
658                            Action=command,
659                            ResourceRecordSet=resource_record_set
660                        )
661                    ]
662                )
663            )
664
665            if wait_in:
666                waiter = route53.get_waiter('resource_record_sets_changed')
667                waiter.wait(
668                    Id=change_resource_record_sets['ChangeInfo']['Id'],
669                    WaiterConfig=dict(
670                        Delay=WAIT_RETRY,
671                        MaxAttemps=wait_timeout_in // WAIT_RETRY,
672                    )
673                )
674        except is_boto3_error_message('but it already exists'):
675            module.exit_json(changed=False)
676        except botocore.exceptions.WaiterError as e:
677            module.fail_json_aws(e, msg='Timeout waiting for resource records changes to be applied')
678        except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:  # pylint: disable=duplicate-except
679            module.fail_json_aws(e, msg='Failed to update records')
680        except Exception as e:
681            module.fail_json(msg='Unhandled exception. (%s)' % to_native(e))
682
683    rr_sets = [camel_dict_to_snake_dict(resource_record_set)]
684    formatted_aws = format_record(aws_record, zone_in, zone_id)
685    formatted_record = format_record(resource_record_set, zone_in, zone_id)
686
687    module.exit_json(
688        changed=True,
689        diff=dict(
690            before=formatted_aws,
691            after=formatted_record if command != 'delete' else {},
692            resource_record_sets=rr_sets,
693        ),
694    )
695
696
697if __name__ == '__main__':
698    main()
699