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