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