1""" 2Execution module for Amazon Route53 written against Boto 3 3 4.. versionadded:: 2017.7.0 5 6:configuration: This module accepts explicit route53 credentials but can also 7 utilize IAM roles assigned to the instance through Instance Profiles. 8 Dynamic credentials are then automatically obtained from AWS API and no 9 further configuration is necessary. More Information available at: 10 11 .. code-block:: yaml 12 13 http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html 14 15 If IAM roles are not used you need to specify them either in a pillar or 16 in the minion's config file: 17 18 .. code-block:: yaml 19 20 route53.keyid: GKTADJGHEIQSXMKKRBJ08H 21 route53.key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs 22 23 A region may also be specified in the configuration: 24 25 .. code-block:: yaml 26 27 route53.region: us-east-1 28 29 It's also possible to specify key, keyid and region via a profile, either 30 as a passed in dict, or as a string to pull from pillars or minion config: 31 32 .. code-block:: yaml 33 34 myprofile: 35 keyid: GKTADJGHEIQSXMKKRBJ08H 36 key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs 37 region: us-east-1 38 39 Note that Route53 essentially ignores all (valid) settings for 'region', 40 since there is only one Endpoint (in us-east-1 if you care) and any (valid) 41 region setting will just send you there. It is entirely safe to set it to 42 None as well. 43 44:depends: boto3 45""" 46 47# keep lint from choking on _get_conn and _cache_id 48# pylint: disable=E0602,W0106 49 50 51import logging 52import re 53import time 54 55import salt.utils.compat 56import salt.utils.versions 57from salt.exceptions import CommandExecutionError, SaltInvocationError 58 59log = logging.getLogger(__name__) 60 61try: 62 # pylint: disable=unused-import 63 import boto3 64 65 # pylint: enable=unused-import 66 from botocore.exceptions import ClientError 67 68 logging.getLogger("boto3").setLevel(logging.CRITICAL) 69 HAS_BOTO3 = True 70except ImportError: 71 HAS_BOTO3 = False 72 73 74def __virtual__(): 75 """ 76 Only load if boto libraries exist and if boto libraries are greater than 77 a given version. 78 """ 79 return salt.utils.versions.check_boto_reqs() 80 81 82def __init__(opts): 83 if HAS_BOTO3: 84 __utils__["boto3.assign_funcs"](__name__, "route53") 85 86 87def _collect_results(func, item, args, marker="Marker", nextmarker="NextMarker"): 88 ret = [] 89 Marker = args.get(marker, "") 90 tries = 10 91 while Marker is not None: 92 try: 93 r = func(**args) 94 except ClientError as e: 95 if tries and e.response.get("Error", {}).get("Code") == "Throttling": 96 # Rate limited - retry 97 log.debug("Throttled by AWS API.") 98 time.sleep(3) 99 tries -= 1 100 continue 101 log.error("Could not collect results from %s(): %s", func, e) 102 return [] 103 i = r.get(item, []) if item else r 104 i.pop("ResponseMetadata", None) if isinstance(i, dict) else None 105 ret += i if isinstance(i, list) else [i] 106 Marker = r.get(nextmarker) 107 args.update({marker: Marker}) 108 return ret 109 110 111def _wait_for_sync(change, conn, tries=10, sleep=20): 112 for retry in range(1, tries + 1): 113 log.info("Getting route53 status (attempt %s)", retry) 114 status = "wait" 115 try: 116 status = conn.get_change(Id=change)["ChangeInfo"]["Status"] 117 except ClientError as e: 118 if e.response.get("Error", {}).get("Code") == "Throttling": 119 log.debug("Throttled by AWS API.") 120 else: 121 raise 122 if status == "INSYNC": 123 return True 124 time.sleep(sleep) 125 log.error("Timed out waiting for Route53 INSYNC status.") 126 return False 127 128 129def find_hosted_zone( 130 Id=None, 131 Name=None, 132 PrivateZone=None, 133 region=None, 134 key=None, 135 keyid=None, 136 profile=None, 137): 138 """ 139 Find a hosted zone with the given characteristics. 140 141 Id 142 The unique Zone Identifier for the Hosted Zone. Exclusive with Name. 143 144 Name 145 The domain name associated with the Hosted Zone. Exclusive with Id. 146 Note this has the potential to match more then one hosted zone (e.g. a public and a private 147 if both exist) which will raise an error unless PrivateZone has also been passed in order 148 split the different. 149 150 PrivateZone 151 Boolean - Set to True if searching for a private hosted zone. 152 153 region 154 Region to connect to. 155 156 key 157 Secret key to be used. 158 159 keyid 160 Access key to be used. 161 162 profile 163 Dict, or pillar key pointing to a dict, containing AWS region/key/keyid. 164 165 CLI Example: 166 167 .. code-block:: bash 168 169 salt myminion boto3_route53.find_hosted_zone Name=salt.org. \ 170 profile='{"region": "us-east-1", "keyid": "A12345678AB", "key": "xblahblahblah"}' 171 """ 172 if not _exactly_one((Id, Name)): 173 raise SaltInvocationError("Exactly one of either Id or Name is required.") 174 if PrivateZone is not None and not isinstance(PrivateZone, bool): 175 raise SaltInvocationError( 176 "If set, PrivateZone must be a bool (e.g. True / False)." 177 ) 178 if Id: 179 ret = get_hosted_zone(Id, region=region, key=key, keyid=keyid, profile=profile) 180 else: 181 ret = get_hosted_zones_by_domain( 182 Name, region=region, key=key, keyid=keyid, profile=profile 183 ) 184 if PrivateZone is not None: 185 ret = [ 186 m for m in ret if m["HostedZone"]["Config"]["PrivateZone"] is PrivateZone 187 ] 188 if len(ret) > 1: 189 log.error( 190 "Request matched more than one Hosted Zone (%s). Refine your " 191 "criteria and try again.", 192 [z["HostedZone"]["Id"] for z in ret], 193 ) 194 ret = [] 195 return ret 196 197 198def get_hosted_zone(Id, region=None, key=None, keyid=None, profile=None): 199 """ 200 Return detailed info about the given zone. 201 202 Id 203 The unique Zone Identifier for the Hosted Zone. 204 205 region 206 Region to connect to. 207 208 key 209 Secret key to be used. 210 211 keyid 212 Access key to be used. 213 214 profile 215 Dict, or pillar key pointing to a dict, containing AWS region/key/keyid. 216 217 CLI Example: 218 219 .. code-block:: bash 220 221 salt myminion boto3_route53.get_hosted_zone Z1234567690 \ 222 profile='{"region": "us-east-1", "keyid": "A12345678AB", "key": "xblahblahblah"}' 223 """ 224 conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile) 225 args = {"Id": Id} 226 return _collect_results(conn.get_hosted_zone, None, args) 227 228 229def get_hosted_zones_by_domain(Name, region=None, key=None, keyid=None, profile=None): 230 """ 231 Find any zones with the given domain name and return detailed info about them. 232 Note that this can return multiple Route53 zones, since a domain name can be used in 233 both public and private zones. 234 235 Name 236 The domain name associated with the Hosted Zone(s). 237 238 region 239 Region to connect to. 240 241 key 242 Secret key to be used. 243 244 keyid 245 Access key to be used. 246 247 profile 248 Dict, or pillar key pointing to a dict, containing AWS region/key/keyid. 249 250 CLI Example: 251 252 .. code-block:: bash 253 254 salt myminion boto3_route53.get_hosted_zones_by_domain salt.org. \ 255 profile='{"region": "us-east-1", "keyid": "A12345678AB", "key": "xblahblahblah"}' 256 """ 257 conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile) 258 zones = [ 259 z 260 for z in _collect_results(conn.list_hosted_zones, "HostedZones", {}) 261 if z["Name"] == aws_encode(Name) 262 ] 263 ret = [] 264 for z in zones: 265 ret += get_hosted_zone( 266 Id=z["Id"], region=region, key=key, keyid=keyid, profile=profile 267 ) 268 return ret 269 270 271def list_hosted_zones( 272 DelegationSetId=None, region=None, key=None, keyid=None, profile=None 273): 274 """ 275 Return detailed info about all zones in the bound account. 276 277 DelegationSetId 278 If you're using reusable delegation sets and you want to list all of the hosted zones that 279 are associated with a reusable delegation set, specify the ID of that delegation set. 280 281 region 282 Region to connect to. 283 284 key 285 Secret key to be used. 286 287 keyid 288 Access key to be used. 289 290 profile 291 Dict, or pillar key pointing to a dict, containing AWS region/key/keyid. 292 293 CLI Example: 294 295 .. code-block:: bash 296 297 salt myminion boto3_route53.describe_hosted_zones \ 298 profile='{"region": "us-east-1", "keyid": "A12345678AB", "key": "xblahblahblah"}' 299 """ 300 conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile) 301 args = {"DelegationSetId": DelegationSetId} if DelegationSetId else {} 302 return _collect_results(conn.list_hosted_zones, "HostedZones", args) 303 304 305def create_hosted_zone( 306 Name, 307 VPCId=None, 308 VPCName=None, 309 VPCRegion=None, 310 CallerReference=None, 311 Comment="", 312 PrivateZone=False, 313 DelegationSetId=None, 314 region=None, 315 key=None, 316 keyid=None, 317 profile=None, 318): 319 """ 320 Create a new Route53 Hosted Zone. Returns a Python data structure with information about the 321 newly created Hosted Zone. 322 323 Name 324 The name of the domain. This should be a fully-specified domain, and should terminate with 325 a period. This is the name you have registered with your DNS registrar. It is also the name 326 you will delegate from your registrar to the Amazon Route 53 delegation servers returned in 327 response to this request. 328 329 VPCId 330 When creating a private hosted zone, either the VPC ID or VPC Name to associate with is 331 required. Exclusive with VPCName. Ignored if passed for a non-private zone. 332 333 VPCName 334 When creating a private hosted zone, either the VPC ID or VPC Name to associate with is 335 required. Exclusive with VPCId. Ignored if passed for a non-private zone. 336 337 VPCRegion 338 When creating a private hosted zone, the region of the associated VPC is required. If not 339 provided, an effort will be made to determine it from VPCId or VPCName, if possible. If 340 this fails, you'll need to provide an explicit value for this option. Ignored if passed for 341 a non-private zone. 342 343 CallerReference 344 A unique string that identifies the request and that allows create_hosted_zone() calls to be 345 retried without the risk of executing the operation twice. This is a required parameter 346 when creating new Hosted Zones. Maximum length of 128. 347 348 Comment 349 Any comments you want to include about the hosted zone. 350 351 PrivateZone 352 Boolean - Set to True if creating a private hosted zone. 353 354 DelegationSetId 355 If you want to associate a reusable delegation set with this hosted zone, the ID that Amazon 356 Route 53 assigned to the reusable delegation set when you created it. Note that XXX TODO 357 create_delegation_set() is not yet implemented, so you'd need to manually create any 358 delegation sets before utilizing this. 359 360 region 361 Region endpoint to connect to. 362 363 key 364 AWS key to bind with. 365 366 keyid 367 AWS keyid to bind with. 368 369 profile 370 Dict, or pillar key pointing to a dict, containing AWS region/key/keyid. 371 372 CLI Example: 373 374 .. code-block:: bash 375 376 salt myminion boto3_route53.create_hosted_zone example.org. 377 """ 378 if not Name.endswith("."): 379 raise SaltInvocationError( 380 "Domain must be fully-qualified, complete with trailing period." 381 ) 382 Name = aws_encode(Name) 383 conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile) 384 deets = find_hosted_zone( 385 Name=Name, 386 PrivateZone=PrivateZone, 387 region=region, 388 key=key, 389 keyid=keyid, 390 profile=profile, 391 ) 392 if deets: 393 log.info( 394 "Route 53 hosted zone %s already exists. You may want to pass " 395 "e.g. 'PrivateZone=True' or similar...", 396 Name, 397 ) 398 return None 399 args = { 400 "Name": Name, 401 "CallerReference": CallerReference, 402 "HostedZoneConfig": {"Comment": Comment, "PrivateZone": PrivateZone}, 403 } 404 args.update({"DelegationSetId": DelegationSetId}) if DelegationSetId else None 405 if PrivateZone: 406 if not _exactly_one((VPCName, VPCId)): 407 raise SaltInvocationError( 408 "Either VPCName or VPCId is required when creating a private zone." 409 ) 410 vpcs = __salt__["boto_vpc.describe_vpcs"]( 411 vpc_id=VPCId, 412 name=VPCName, 413 region=region, 414 key=key, 415 keyid=keyid, 416 profile=profile, 417 ).get("vpcs", []) 418 if VPCRegion and vpcs: 419 vpcs = [v for v in vpcs if v["region"] == VPCRegion] 420 if not vpcs: 421 log.error( 422 "Private zone requested but no VPC matching given criteria found." 423 ) 424 return None 425 if len(vpcs) > 1: 426 log.error( 427 "Private zone requested but multiple VPCs matching given " 428 "criteria found: %s.", 429 [v["id"] for v in vpcs], 430 ) 431 return None 432 vpc = vpcs[0] 433 if VPCName: 434 VPCId = vpc["id"] 435 if not VPCRegion: 436 VPCRegion = vpc["region"] 437 args.update({"VPC": {"VPCId": VPCId, "VPCRegion": VPCRegion}}) 438 else: 439 if any((VPCId, VPCName, VPCRegion)): 440 log.info( 441 "Options VPCId, VPCName, and VPCRegion are ignored when creating " 442 "non-private zones." 443 ) 444 tries = 10 445 while tries: 446 try: 447 r = conn.create_hosted_zone(**args) 448 r.pop("ResponseMetadata", None) 449 if _wait_for_sync(r["ChangeInfo"]["Id"], conn): 450 return [r] 451 return [] 452 except ClientError as e: 453 if tries and e.response.get("Error", {}).get("Code") == "Throttling": 454 log.debug("Throttled by AWS API.") 455 time.sleep(3) 456 tries -= 1 457 continue 458 log.error("Failed to create hosted zone %s: %s", Name, e) 459 return [] 460 return [] 461 462 463def update_hosted_zone_comment( 464 Id=None, 465 Name=None, 466 Comment=None, 467 PrivateZone=None, 468 region=None, 469 key=None, 470 keyid=None, 471 profile=None, 472): 473 """ 474 Update the comment on an existing Route 53 hosted zone. 475 476 Id 477 The unique Zone Identifier for the Hosted Zone. 478 479 Name 480 The domain name associated with the Hosted Zone(s). 481 482 Comment 483 Any comments you want to include about the hosted zone. 484 485 PrivateZone 486 Boolean - Set to True if changing a private hosted zone. 487 488 CLI Example: 489 490 .. code-block:: bash 491 492 salt myminion boto3_route53.update_hosted_zone_comment Name=example.org. \ 493 Comment="This is an example comment for an example zone" 494 """ 495 if not _exactly_one((Id, Name)): 496 raise SaltInvocationError("Exactly one of either Id or Name is required.") 497 conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile) 498 if Name: 499 args = { 500 "Name": Name, 501 "PrivateZone": PrivateZone, 502 "region": region, 503 "key": key, 504 "keyid": keyid, 505 "profile": profile, 506 } 507 zone = find_hosted_zone(**args) 508 if not zone: 509 log.error("Couldn't resolve domain name %s to a hosted zone ID.", Name) 510 return [] 511 Id = zone[0]["HostedZone"]["Id"] 512 tries = 10 513 while tries: 514 try: 515 r = conn.update_hosted_zone_comment(Id=Id, Comment=Comment) 516 r.pop("ResponseMetadata", None) 517 return [r] 518 except ClientError as e: 519 if tries and e.response.get("Error", {}).get("Code") == "Throttling": 520 log.debug("Throttled by AWS API.") 521 time.sleep(3) 522 tries -= 1 523 continue 524 log.error("Failed to update comment on hosted zone %s: %s", Name or Id, e) 525 return [] 526 527 528def associate_vpc_with_hosted_zone( 529 HostedZoneId=None, 530 Name=None, 531 VPCId=None, 532 VPCName=None, 533 VPCRegion=None, 534 Comment=None, 535 region=None, 536 key=None, 537 keyid=None, 538 profile=None, 539): 540 """ 541 Associates an Amazon VPC with a private hosted zone. 542 543 To perform the association, the VPC and the private hosted zone must already exist. You can't 544 convert a public hosted zone into a private hosted zone. If you want to associate a VPC from 545 one AWS account with a zone from a another, the AWS account owning the hosted zone must first 546 submit a CreateVPCAssociationAuthorization (using create_vpc_association_authorization() or by 547 other means, such as the AWS console). With that done, the account owning the VPC can then call 548 associate_vpc_with_hosted_zone() to create the association. 549 550 Note that if both sides happen to be within the same account, associate_vpc_with_hosted_zone() 551 is enough on its own, and there is no need for the CreateVPCAssociationAuthorization step. 552 553 Also note that looking up hosted zones by name (e.g. using the Name parameter) only works 554 within a single account - if you're associating a VPC to a zone in a different account, as 555 outlined above, you unfortunately MUST use the HostedZoneId parameter exclusively. 556 557 HostedZoneId 558 The unique Zone Identifier for the Hosted Zone. 559 560 Name 561 The domain name associated with the Hosted Zone(s). 562 563 VPCId 564 When working with a private hosted zone, either the VPC ID or VPC Name to associate with is 565 required. Exclusive with VPCName. 566 567 VPCName 568 When working with a private hosted zone, either the VPC ID or VPC Name to associate with is 569 required. Exclusive with VPCId. 570 571 VPCRegion 572 When working with a private hosted zone, the region of the associated VPC is required. If 573 not provided, an effort will be made to determine it from VPCId or VPCName, if possible. If 574 this fails, you'll need to provide an explicit value for VPCRegion. 575 576 Comment 577 Any comments you want to include about the change being made. 578 579 CLI Example: 580 581 .. code-block:: bash 582 583 salt myminion boto3_route53.associate_vpc_with_hosted_zone \ 584 Name=example.org. VPCName=myVPC \ 585 VPCRegion=us-east-1 Comment="Whoo-hoo! I added another VPC." 586 587 """ 588 if not _exactly_one((HostedZoneId, Name)): 589 raise SaltInvocationError( 590 "Exactly one of either HostedZoneId or Name is required." 591 ) 592 if not _exactly_one((VPCId, VPCName)): 593 raise SaltInvocationError("Exactly one of either VPCId or VPCName is required.") 594 if Name: 595 # {'PrivateZone': True} because you can only associate VPCs with private hosted zones. 596 args = { 597 "Name": Name, 598 "PrivateZone": True, 599 "region": region, 600 "key": key, 601 "keyid": keyid, 602 "profile": profile, 603 } 604 zone = find_hosted_zone(**args) 605 if not zone: 606 log.error( 607 "Couldn't resolve domain name %s to a private hosted zone ID.", Name 608 ) 609 return False 610 HostedZoneId = zone[0]["HostedZone"]["Id"] 611 vpcs = __salt__["boto_vpc.describe_vpcs"]( 612 vpc_id=VPCId, name=VPCName, region=region, key=key, keyid=keyid, profile=profile 613 ).get("vpcs", []) 614 if VPCRegion and vpcs: 615 vpcs = [v for v in vpcs if v["region"] == VPCRegion] 616 if not vpcs: 617 log.error("No VPC matching the given criteria found.") 618 return False 619 if len(vpcs) > 1: 620 log.error( 621 "Multiple VPCs matching the given criteria found: %s.", 622 ", ".join([v["id"] for v in vpcs]), 623 ) 624 return False 625 vpc = vpcs[0] 626 if VPCName: 627 VPCId = vpc["id"] 628 if not VPCRegion: 629 VPCRegion = vpc["region"] 630 args = { 631 "HostedZoneId": HostedZoneId, 632 "VPC": {"VPCId": VPCId, "VPCRegion": VPCRegion}, 633 } 634 args.update({"Comment": Comment}) if Comment is not None else None 635 636 conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile) 637 tries = 10 638 while tries: 639 try: 640 r = conn.associate_vpc_with_hosted_zone(**args) 641 return _wait_for_sync(r["ChangeInfo"]["Id"], conn) 642 except ClientError as e: 643 if e.response.get("Error", {}).get("Code") == "ConflictingDomainExists": 644 log.debug("VPC Association already exists.") 645 # return True since the current state is the desired one 646 return True 647 if tries and e.response.get("Error", {}).get("Code") == "Throttling": 648 log.debug("Throttled by AWS API.") 649 time.sleep(3) 650 tries -= 1 651 continue 652 log.error( 653 "Failed to associate VPC %s with hosted zone %s: %s", 654 VPCName or VPCId, 655 Name or HostedZoneId, 656 e, 657 ) 658 return False 659 660 661def disassociate_vpc_from_hosted_zone( 662 HostedZoneId=None, 663 Name=None, 664 VPCId=None, 665 VPCName=None, 666 VPCRegion=None, 667 Comment=None, 668 region=None, 669 key=None, 670 keyid=None, 671 profile=None, 672): 673 """ 674 Disassociates an Amazon VPC from a private hosted zone. 675 676 You can't disassociate the last VPC from a private hosted zone. You also can't convert a 677 private hosted zone into a public hosted zone. 678 679 Note that looking up hosted zones by name (e.g. using the Name parameter) only works XXX FACTCHECK 680 within a single AWS account - if you're disassociating a VPC in one account from a hosted zone 681 in a different account you unfortunately MUST use the HostedZoneId parameter exclusively. XXX FIXME DOCU 682 683 HostedZoneId 684 The unique Zone Identifier for the Hosted Zone. 685 686 Name 687 The domain name associated with the Hosted Zone(s). 688 689 VPCId 690 When working with a private hosted zone, either the VPC ID or VPC Name to associate with is 691 required. Exclusive with VPCName. 692 693 VPCName 694 When working with a private hosted zone, either the VPC ID or VPC Name to associate with is 695 required. Exclusive with VPCId. 696 697 VPCRegion 698 When working with a private hosted zone, the region of the associated VPC is required. If 699 not provided, an effort will be made to determine it from VPCId or VPCName, if possible. If 700 this fails, you'll need to provide an explicit value for VPCRegion. 701 702 Comment 703 Any comments you want to include about the change being made. 704 705 CLI Example: 706 707 .. code-block:: bash 708 709 salt myminion boto3_route53.disassociate_vpc_from_hosted_zone \ 710 Name=example.org. VPCName=myVPC \ 711 VPCRegion=us-east-1 Comment="Whoops! Don't wanna talk to this-here zone no more." 712 713 """ 714 if not _exactly_one((HostedZoneId, Name)): 715 raise SaltInvocationError( 716 "Exactly one of either HostedZoneId or Name is required." 717 ) 718 if not _exactly_one((VPCId, VPCName)): 719 raise SaltInvocationError("Exactly one of either VPCId or VPCName is required.") 720 if Name: 721 # {'PrivateZone': True} because you can only associate VPCs with private hosted zones. 722 args = { 723 "Name": Name, 724 "PrivateZone": True, 725 "region": region, 726 "key": key, 727 "keyid": keyid, 728 "profile": profile, 729 } 730 zone = find_hosted_zone(**args) 731 if not zone: 732 log.error( 733 "Couldn't resolve domain name %s to a private hosted zone ID.", Name 734 ) 735 return False 736 HostedZoneId = zone[0]["HostedZone"]["Id"] 737 vpcs = __salt__["boto_vpc.describe_vpcs"]( 738 vpc_id=VPCId, name=VPCName, region=region, key=key, keyid=keyid, profile=profile 739 ).get("vpcs", []) 740 if VPCRegion and vpcs: 741 vpcs = [v for v in vpcs if v["region"] == VPCRegion] 742 if not vpcs: 743 log.error("No VPC matching the given criteria found.") 744 return False 745 if len(vpcs) > 1: 746 log.error( 747 "Multiple VPCs matching the given criteria found: %s.", 748 ", ".join([v["id"] for v in vpcs]), 749 ) 750 return False 751 vpc = vpcs[0] 752 if VPCName: 753 VPCId = vpc["id"] 754 if not VPCRegion: 755 VPCRegion = vpc["region"] 756 args = { 757 "HostedZoneId": HostedZoneId, 758 "VPC": {"VPCId": VPCId, "VPCRegion": VPCRegion}, 759 } 760 args.update({"Comment": Comment}) if Comment is not None else None 761 762 conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile) 763 tries = 10 764 while tries: 765 try: 766 r = conn.disassociate_vpc_from_hosted_zone(**args) 767 return _wait_for_sync(r["ChangeInfo"]["Id"], conn) 768 except ClientError as e: 769 if e.response.get("Error", {}).get("Code") == "VPCAssociationNotFound": 770 log.debug("No VPC Association exists.") 771 # return True since the current state is the desired one 772 return True 773 if tries and e.response.get("Error", {}).get("Code") == "Throttling": 774 log.debug("Throttled by AWS API.") 775 time.sleep(3) 776 tries -= 1 777 continue 778 log.error( 779 "Failed to associate VPC %s with hosted zone %s: %s", 780 VPCName or VPCId, 781 Name or HostedZoneId, 782 e, 783 ) 784 return False 785 786 787# def create_vpc_association_authorization(*args, **kwargs): 788# ''' 789# unimplemented 790# ''' 791# pass 792 793 794# def delete_vpc_association_authorization(*args, **kwargs): 795# ''' 796# unimplemented 797# ''' 798# pass 799 800 801# def list_vpc_association_authorizations(*args, **kwargs): 802# ''' 803# unimplemented 804# ''' 805# pass 806 807 808def delete_hosted_zone(Id, region=None, key=None, keyid=None, profile=None): 809 """ 810 Delete a Route53 hosted zone. 811 812 CLI Example: 813 814 .. code-block:: bash 815 816 salt myminion boto3_route53.delete_hosted_zone Z1234567890 817 """ 818 conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile) 819 try: 820 r = conn.delete_hosted_zone(Id=Id) 821 return _wait_for_sync(r["ChangeInfo"]["Id"], conn) 822 except ClientError as e: 823 log.error("Failed to delete hosted zone %s: %s", Id, e) 824 return False 825 826 827def delete_hosted_zone_by_domain( 828 Name, PrivateZone=None, region=None, key=None, keyid=None, profile=None 829): 830 """ 831 Delete a Route53 hosted zone by domain name, and PrivateZone status if provided. 832 833 CLI Example: 834 835 .. code-block:: bash 836 837 salt myminion boto3_route53.delete_hosted_zone_by_domain example.org. 838 """ 839 args = { 840 "Name": Name, 841 "PrivateZone": PrivateZone, 842 "region": region, 843 "key": key, 844 "keyid": keyid, 845 "profile": profile, 846 } 847 # Be extra pedantic in the service of safety - if public/private is not provided and the domain 848 # name resolves to both, fail and require them to declare it explicitly. 849 zone = find_hosted_zone(**args) 850 if not zone: 851 log.error("Couldn't resolve domain name %s to a hosted zone ID.", Name) 852 return False 853 Id = zone[0]["HostedZone"]["Id"] 854 return delete_hosted_zone( 855 Id=Id, region=region, key=key, keyid=keyid, profile=profile 856 ) 857 858 859def aws_encode(x): 860 """ 861 An implementation of the encoding required to support AWS's domain name 862 rules defined here__: 863 864 .. __: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html 865 866 While AWS's documentation specifies individual ASCII characters which need 867 to be encoded, we instead just try to force the string to one of 868 escaped unicode or idna depending on whether there are non-ASCII characters 869 present. 870 871 This means that we support things like ドメイン.テスト as a domain name string. 872 873 More information about IDNA encoding in python is found here__: 874 875 .. __: https://pypi.org/project/idna 876 877 """ 878 ret = None 879 try: 880 x.encode("ascii") 881 ret = re.sub(r"\\x([a-f0-8]{2})", _hexReplace, x.encode("unicode_escape")) 882 except UnicodeEncodeError: 883 ret = x.encode("idna") 884 except Exception as e: # pylint: disable=broad-except 885 log.error( 886 "Couldn't encode %s using either 'unicode_escape' or 'idna' codecs", x 887 ) 888 raise CommandExecutionError(e) 889 log.debug("AWS-encoded result for %s: %s", x, ret) 890 return ret 891 892 893def _aws_encode_changebatch(o): 894 """ 895 helper method to process a change batch & encode the bits which need encoding. 896 """ 897 change_idx = 0 898 while change_idx < len(o["Changes"]): 899 o["Changes"][change_idx]["ResourceRecordSet"]["Name"] = aws_encode( 900 o["Changes"][change_idx]["ResourceRecordSet"]["Name"] 901 ) 902 if "ResourceRecords" in o["Changes"][change_idx]["ResourceRecordSet"]: 903 rr_idx = 0 904 while rr_idx < len( 905 o["Changes"][change_idx]["ResourceRecordSet"]["ResourceRecords"] 906 ): 907 o["Changes"][change_idx]["ResourceRecordSet"]["ResourceRecords"][ 908 rr_idx 909 ]["Value"] = aws_encode( 910 o["Changes"][change_idx]["ResourceRecordSet"]["ResourceRecords"][ 911 rr_idx 912 ]["Value"] 913 ) 914 rr_idx += 1 915 if "AliasTarget" in o["Changes"][change_idx]["ResourceRecordSet"]: 916 o["Changes"][change_idx]["ResourceRecordSet"]["AliasTarget"][ 917 "DNSName" 918 ] = aws_encode( 919 o["Changes"][change_idx]["ResourceRecordSet"]["AliasTarget"]["DNSName"] 920 ) 921 change_idx += 1 922 return o 923 924 925def _aws_decode(x): 926 """ 927 An implementation of the decoding required to support AWS's domain name 928 rules defined here__: 929 930 .. __: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html 931 932 The important part is this: 933 934 If the domain name includes any characters other than a to z, 0 to 9, - (hyphen), 935 or _ (underscore), Route 53 API actions return the characters as escape codes. 936 This is true whether you specify the characters as characters or as escape 937 codes when you create the entity. 938 The Route 53 console displays the characters as characters, not as escape codes." 939 940 For a list of ASCII characters the corresponding octal codes, do an internet search on "ascii table". 941 942 We look for the existence of any escape codes which give us a clue that 943 we're received an escaped unicode string; or we assume it's idna encoded 944 and then decode as necessary. 945 """ 946 if "\\" in x: 947 return x.decode("unicode_escape") 948 return x.decode("idna") 949 950 951def _hexReplace(x): 952 """ 953 Converts a hex code to a base 16 int then the octal of it, minus the leading 954 zero. 955 956 This is necessary because x.encode('unicode_escape') automatically assumes 957 you want a hex string, which AWS will accept but doesn't result in what 958 you really want unless it's an octal escape sequence 959 """ 960 c = int(x.group(1), 16) 961 return "\\" + str(oct(c))[1:] 962 963 964def get_resource_records( 965 HostedZoneId=None, 966 Name=None, 967 StartRecordName=None, 968 StartRecordType=None, 969 PrivateZone=None, 970 region=None, 971 key=None, 972 keyid=None, 973 profile=None, 974): 975 """ 976 Get all resource records from a given zone matching the provided StartRecordName (if given) or all 977 records in the zone (if not), optionally filtered by a specific StartRecordType. This will return 978 any and all RRs matching, regardless of their special AWS flavors (weighted, geolocation, alias, 979 etc.) so your code should be prepared for potentially large numbers of records back from this 980 function - for example, if you've created a complex geolocation mapping with lots of entries all 981 over the world providing the same server name to many different regional clients. 982 983 If you want EXACTLY ONE record to operate on, you'll need to implement any logic required to 984 pick the specific RR you care about from those returned. 985 986 Note that if you pass in Name without providing a value for PrivateZone (either True or 987 False), CommandExecutionError can be raised in the case of both public and private zones 988 matching the domain. XXX FIXME DOCU 989 990 CLI Example: 991 992 .. code-block:: bash 993 994 salt myminion boto3_route53.get_records test.example.org example.org A 995 """ 996 if not _exactly_one((HostedZoneId, Name)): 997 raise SaltInvocationError( 998 "Exactly one of either HostedZoneId or Name must be provided." 999 ) 1000 if Name: 1001 args = { 1002 "Name": Name, 1003 "region": region, 1004 "key": key, 1005 "keyid": keyid, 1006 "profile": profile, 1007 } 1008 args.update({"PrivateZone": PrivateZone}) if PrivateZone is not None else None 1009 zone = find_hosted_zone(**args) 1010 if not zone: 1011 log.error("Couldn't resolve domain name %s to a hosted zone ID.", Name) 1012 return [] 1013 HostedZoneId = zone[0]["HostedZone"]["Id"] 1014 1015 conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile) 1016 ret = [] 1017 next_rr_name = StartRecordName 1018 next_rr_type = StartRecordType 1019 next_rr_id = None 1020 done = False 1021 while True: 1022 if done: 1023 return ret 1024 args = {"HostedZoneId": HostedZoneId} 1025 args.update( 1026 {"StartRecordName": aws_encode(next_rr_name)} 1027 ) if next_rr_name else None 1028 # Grrr, can't specify type unless name is set... We'll do this via filtering later instead 1029 args.update( 1030 {"StartRecordType": next_rr_type} 1031 ) if next_rr_name and next_rr_type else None 1032 args.update({"StartRecordIdentifier": next_rr_id}) if next_rr_id else None 1033 try: 1034 r = conn.list_resource_record_sets(**args) 1035 rrs = r["ResourceRecordSets"] 1036 next_rr_name = r.get("NextRecordName") 1037 next_rr_type = r.get("NextRecordType") 1038 next_rr_id = r.get("NextRecordIdentifier") 1039 for rr in rrs: 1040 rr["Name"] = _aws_decode(rr["Name"]) 1041 # now iterate over the ResourceRecords and replace any encoded 1042 # value strings with the decoded versions 1043 if "ResourceRecords" in rr: 1044 x = 0 1045 while x < len(rr["ResourceRecords"]): 1046 if "Value" in rr["ResourceRecords"][x]: 1047 rr["ResourceRecords"][x]["Value"] = _aws_decode( 1048 rr["ResourceRecords"][x]["Value"] 1049 ) 1050 x += 1 1051 # or if we are an AliasTarget then decode the DNSName 1052 if "AliasTarget" in rr: 1053 rr["AliasTarget"]["DNSName"] = _aws_decode( 1054 rr["AliasTarget"]["DNSName"] 1055 ) 1056 if StartRecordName and rr["Name"] != StartRecordName: 1057 done = True 1058 break 1059 if StartRecordType and rr["Type"] != StartRecordType: 1060 if StartRecordName: 1061 done = True 1062 break 1063 else: 1064 # We're filtering by type alone, and there might be more later, so... 1065 continue 1066 ret += [rr] 1067 if not next_rr_name: 1068 done = True 1069 except ClientError as e: 1070 # Try forever on a simple thing like this... 1071 if e.response.get("Error", {}).get("Code") == "Throttling": 1072 log.debug("Throttled by AWS API.") 1073 time.sleep(3) 1074 continue 1075 raise 1076 1077 1078def change_resource_record_sets( 1079 HostedZoneId=None, 1080 Name=None, 1081 PrivateZone=None, 1082 ChangeBatch=None, 1083 region=None, 1084 key=None, 1085 keyid=None, 1086 profile=None, 1087): 1088 """ 1089 See the `AWS Route53 API docs`__ as well as the `Boto3 documentation`__ for all the details... 1090 1091 .. __: https://docs.aws.amazon.com/Route53/latest/APIReference/API_ChangeResourceRecordSets.html 1092 .. __: http://boto3.readthedocs.io/en/latest/reference/services/route53.html#Route53.Client.change_resource_record_sets 1093 1094 The syntax for a ChangeBatch parameter is as follows, but note that the permutations of allowed 1095 parameters and combinations thereof are quite varied, so perusal of the above linked docs is 1096 highly recommended for any non-trival configurations. 1097 1098 .. code-block:: text 1099 1100 { 1101 "Comment": "string", 1102 "Changes": [ 1103 { 1104 "Action": "CREATE"|"DELETE"|"UPSERT", 1105 "ResourceRecordSet": { 1106 "Name": "string", 1107 "Type": "SOA"|"A"|"TXT"|"NS"|"CNAME"|"MX"|"NAPTR"|"PTR"|"SRV"|"SPF"|"AAAA", 1108 "SetIdentifier": "string", 1109 "Weight": 123, 1110 "Region": "us-east-1"|"us-east-2"|"us-west-1"|"us-west-2"|"ca-central-1"|"eu-west-1"|"eu-west-2"|"eu-central-1"|"ap-southeast-1"|"ap-southeast-2"|"ap-northeast-1"|"ap-northeast-2"|"sa-east-1"|"cn-north-1"|"ap-south-1", 1111 "GeoLocation": { 1112 "ContinentCode": "string", 1113 "CountryCode": "string", 1114 "SubdivisionCode": "string" 1115 }, 1116 "Failover": "PRIMARY"|"SECONDARY", 1117 "TTL": 123, 1118 "ResourceRecords": [ 1119 { 1120 "Value": "string" 1121 }, 1122 ], 1123 "AliasTarget": { 1124 "HostedZoneId": "string", 1125 "DNSName": "string", 1126 "EvaluateTargetHealth": True|False 1127 }, 1128 "HealthCheckId": "string", 1129 "TrafficPolicyInstanceId": "string" 1130 } 1131 }, 1132 ] 1133 } 1134 1135 CLI Example: 1136 1137 .. code-block:: bash 1138 1139 foo='{ 1140 "Name": "my-cname.example.org.", 1141 "TTL": 600, 1142 "Type": "CNAME", 1143 "ResourceRecords": [ 1144 { 1145 "Value": "my-host.example.org" 1146 } 1147 ] 1148 }' 1149 foo=`echo $foo` # Remove newlines 1150 salt myminion boto3_route53.change_resource_record_sets DomainName=example.org. \ 1151 keyid=A1234567890ABCDEF123 key=xblahblahblah \ 1152 ChangeBatch="{'Changes': [{'Action': 'UPSERT', 'ResourceRecordSet': $foo}]}" 1153 """ 1154 if not _exactly_one((HostedZoneId, Name)): 1155 raise SaltInvocationError( 1156 "Exactly one of either HostZoneId or Name must be provided." 1157 ) 1158 if Name: 1159 args = { 1160 "Name": Name, 1161 "region": region, 1162 "key": key, 1163 "keyid": keyid, 1164 "profile": profile, 1165 } 1166 args.update({"PrivateZone": PrivateZone}) if PrivateZone is not None else None 1167 zone = find_hosted_zone(**args) 1168 if not zone: 1169 log.error("Couldn't resolve domain name %s to a hosted zone ID.", Name) 1170 return [] 1171 HostedZoneId = zone[0]["HostedZone"]["Id"] 1172 1173 args = { 1174 "HostedZoneId": HostedZoneId, 1175 "ChangeBatch": _aws_encode_changebatch(ChangeBatch), 1176 } 1177 1178 conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile) 1179 tries = 20 # A bit more headroom 1180 while tries: 1181 try: 1182 r = conn.change_resource_record_sets(**args) 1183 return _wait_for_sync( 1184 r["ChangeInfo"]["Id"], conn, 30 1185 ) # And a little extra time here 1186 except ClientError as e: 1187 if tries and e.response.get("Error", {}).get("Code") == "Throttling": 1188 log.debug("Throttled by AWS API.") 1189 time.sleep(3) 1190 tries -= 1 1191 continue 1192 log.error( 1193 "Failed to apply requested changes to the hosted zone %s: %s", 1194 (Name or HostedZoneId), 1195 str(e), 1196 ) 1197 raise e 1198 return False 1199