Mini Shell
"""
Execution module for Amazon Route53 written against Boto 3
.. versionadded:: 2017.7.0
:configuration: This module accepts explicit route53 credentials but can also
utilize IAM roles assigned to the instance through Instance Profiles.
Dynamic credentials are then automatically obtained from AWS API and no
further configuration is necessary. More Information available at:
.. code-block:: yaml
http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html
If IAM roles are not used you need to specify them either in a pillar or
in the minion's config file:
.. code-block:: yaml
route53.keyid: GKTADJGHEIQSXMKKRBJ08H
route53.key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs
A region may also be specified in the configuration:
.. code-block:: yaml
route53.region: us-east-1
It's also possible to specify key, keyid and region via a profile, either
as a passed in dict, or as a string to pull from pillars or minion config:
.. code-block:: yaml
myprofile:
keyid: GKTADJGHEIQSXMKKRBJ08H
key: askdjghsdfjkghWupUjasdflkdfklgjsdfjajkghs
region: us-east-1
Note that Route53 essentially ignores all (valid) settings for 'region',
since there is only one Endpoint (in us-east-1 if you care) and any (valid)
region setting will just send you there. It is entirely safe to set it to
None as well.
:depends: boto3
"""
# keep lint from choking on _get_conn and _cache_id
# pylint: disable=E0602,W0106
import logging
import re
import time
import salt.utils.compat
import salt.utils.versions
from salt.exceptions import CommandExecutionError, SaltInvocationError
log = logging.getLogger(__name__)
try:
# pylint: disable=unused-import
import boto3
# pylint: enable=unused-import
from botocore.exceptions import ClientError
logging.getLogger("boto3").setLevel(logging.CRITICAL)
HAS_BOTO3 = True
except ImportError:
HAS_BOTO3 = False
def __virtual__():
"""
Only load if boto libraries exist and if boto libraries are greater than
a given version.
"""
return salt.utils.versions.check_boto_reqs()
def __init__(opts):
if HAS_BOTO3:
__utils__["boto3.assign_funcs"](__name__, "route53")
def _collect_results(func, item, args, marker="Marker", nextmarker="NextMarker"):
ret = []
Marker = args.get(marker, "")
tries = 10
while Marker is not None:
try:
r = func(**args)
except ClientError as e:
if tries and e.response.get("Error", {}).get("Code") == "Throttling":
# Rate limited - retry
log.debug("Throttled by AWS API.")
time.sleep(3)
tries -= 1
continue
log.error("Could not collect results from %s(): %s", func, e)
return []
i = r.get(item, []) if item else r
i.pop("ResponseMetadata", None) if isinstance(i, dict) else None
ret += i if isinstance(i, list) else [i]
Marker = r.get(nextmarker)
args.update({marker: Marker})
return ret
def _wait_for_sync(change, conn, tries=10, sleep=20):
for retry in range(1, tries + 1):
log.info("Getting route53 status (attempt %s)", retry)
status = "wait"
try:
status = conn.get_change(Id=change)["ChangeInfo"]["Status"]
except ClientError as e:
if e.response.get("Error", {}).get("Code") == "Throttling":
log.debug("Throttled by AWS API.")
else:
raise
if status == "INSYNC":
return True
time.sleep(sleep)
log.error("Timed out waiting for Route53 INSYNC status.")
return False
def find_hosted_zone(
Id=None,
Name=None,
PrivateZone=None,
region=None,
key=None,
keyid=None,
profile=None,
):
"""
Find a hosted zone with the given characteristics.
Id
The unique Zone Identifier for the Hosted Zone. Exclusive with Name.
Name
The domain name associated with the Hosted Zone. Exclusive with Id.
Note this has the potential to match more then one hosted zone (e.g. a public and a private
if both exist) which will raise an error unless PrivateZone has also been passed in order
split the different.
PrivateZone
Boolean - Set to True if searching for a private hosted zone.
region
Region to connect to.
key
Secret key to be used.
keyid
Access key to be used.
profile
Dict, or pillar key pointing to a dict, containing AWS region/key/keyid.
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.find_hosted_zone Name=salt.org. \
profile='{"region": "us-east-1", "keyid": "A12345678AB", "key": "xblahblahblah"}'
"""
if not _exactly_one((Id, Name)):
raise SaltInvocationError("Exactly one of either Id or Name is required.")
if PrivateZone is not None and not isinstance(PrivateZone, bool):
raise SaltInvocationError(
"If set, PrivateZone must be a bool (e.g. True / False)."
)
if Id:
ret = get_hosted_zone(Id, region=region, key=key, keyid=keyid, profile=profile)
else:
ret = get_hosted_zones_by_domain(
Name, region=region, key=key, keyid=keyid, profile=profile
)
if PrivateZone is not None:
ret = [
m for m in ret if m["HostedZone"]["Config"]["PrivateZone"] is PrivateZone
]
if len(ret) > 1:
log.error(
"Request matched more than one Hosted Zone (%s). Refine your "
"criteria and try again.",
[z["HostedZone"]["Id"] for z in ret],
)
ret = []
return ret
def get_hosted_zone(Id, region=None, key=None, keyid=None, profile=None):
"""
Return detailed info about the given zone.
Id
The unique Zone Identifier for the Hosted Zone.
region
Region to connect to.
key
Secret key to be used.
keyid
Access key to be used.
profile
Dict, or pillar key pointing to a dict, containing AWS region/key/keyid.
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.get_hosted_zone Z1234567690 \
profile='{"region": "us-east-1", "keyid": "A12345678AB", "key": "xblahblahblah"}'
"""
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
args = {"Id": Id}
return _collect_results(conn.get_hosted_zone, None, args)
def get_hosted_zones_by_domain(Name, region=None, key=None, keyid=None, profile=None):
"""
Find any zones with the given domain name and return detailed info about them.
Note that this can return multiple Route53 zones, since a domain name can be used in
both public and private zones.
Name
The domain name associated with the Hosted Zone(s).
region
Region to connect to.
key
Secret key to be used.
keyid
Access key to be used.
profile
Dict, or pillar key pointing to a dict, containing AWS region/key/keyid.
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.get_hosted_zones_by_domain salt.org. \
profile='{"region": "us-east-1", "keyid": "A12345678AB", "key": "xblahblahblah"}'
"""
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
zones = [
z
for z in _collect_results(conn.list_hosted_zones, "HostedZones", {})
if z["Name"] == _aws_encode(Name)
]
ret = []
for z in zones:
ret += get_hosted_zone(
Id=z["Id"], region=region, key=key, keyid=keyid, profile=profile
)
return ret
def list_hosted_zones(
DelegationSetId=None, region=None, key=None, keyid=None, profile=None
):
"""
Return detailed info about all zones in the bound account.
DelegationSetId
If you're using reusable delegation sets and you want to list all of the hosted zones that
are associated with a reusable delegation set, specify the ID of that delegation set.
region
Region to connect to.
key
Secret key to be used.
keyid
Access key to be used.
profile
Dict, or pillar key pointing to a dict, containing AWS region/key/keyid.
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.describe_hosted_zones \
profile='{"region": "us-east-1", "keyid": "A12345678AB", "key": "xblahblahblah"}'
"""
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
args = {"DelegationSetId": DelegationSetId} if DelegationSetId else {}
return _collect_results(conn.list_hosted_zones, "HostedZones", args)
def create_hosted_zone(
Name,
VPCId=None,
VPCName=None,
VPCRegion=None,
CallerReference=None,
Comment="",
PrivateZone=False,
DelegationSetId=None,
region=None,
key=None,
keyid=None,
profile=None,
):
"""
Create a new Route53 Hosted Zone. Returns a Python data structure with information about the
newly created Hosted Zone.
Name
The name of the domain. This should be a fully-specified domain, and should terminate with
a period. This is the name you have registered with your DNS registrar. It is also the name
you will delegate from your registrar to the Amazon Route 53 delegation servers returned in
response to this request.
VPCId
When creating a private hosted zone, either the VPC ID or VPC Name to associate with is
required. Exclusive with VPCName. Ignored if passed for a non-private zone.
VPCName
When creating a private hosted zone, either the VPC ID or VPC Name to associate with is
required. Exclusive with VPCId. Ignored if passed for a non-private zone.
VPCRegion
When creating a private hosted zone, the region of the associated VPC is required. If not
provided, an effort will be made to determine it from VPCId or VPCName, if possible. If
this fails, you'll need to provide an explicit value for this option. Ignored if passed for
a non-private zone.
CallerReference
A unique string that identifies the request and that allows create_hosted_zone() calls to be
retried without the risk of executing the operation twice. This is a required parameter
when creating new Hosted Zones. Maximum length of 128.
Comment
Any comments you want to include about the hosted zone.
PrivateZone
Boolean - Set to True if creating a private hosted zone.
DelegationSetId
If you want to associate a reusable delegation set with this hosted zone, the ID that Amazon
Route 53 assigned to the reusable delegation set when you created it. Note that XXX TODO
create_delegation_set() is not yet implemented, so you'd need to manually create any
delegation sets before utilizing this.
region
Region endpoint to connect to.
key
AWS key to bind with.
keyid
AWS keyid to bind with.
profile
Dict, or pillar key pointing to a dict, containing AWS region/key/keyid.
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.create_hosted_zone example.org.
"""
if not Name.endswith("."):
raise SaltInvocationError(
"Domain must be fully-qualified, complete with trailing period."
)
Name = _aws_encode(Name)
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
deets = find_hosted_zone(
Name=Name,
PrivateZone=PrivateZone,
region=region,
key=key,
keyid=keyid,
profile=profile,
)
if deets:
log.info(
"Route 53 hosted zone %s already exists. You may want to pass "
"e.g. 'PrivateZone=True' or similar...",
Name,
)
return None
args = {
"Name": Name,
"CallerReference": CallerReference,
"HostedZoneConfig": {"Comment": Comment, "PrivateZone": PrivateZone},
}
args.update({"DelegationSetId": DelegationSetId}) if DelegationSetId else None
if PrivateZone:
if not _exactly_one((VPCName, VPCId)):
raise SaltInvocationError(
"Either VPCName or VPCId is required when creating a private zone."
)
vpcs = __salt__["boto_vpc.describe_vpcs"](
vpc_id=VPCId,
name=VPCName,
region=region,
key=key,
keyid=keyid,
profile=profile,
).get("vpcs", [])
if VPCRegion and vpcs:
vpcs = [v for v in vpcs if v["region"] == VPCRegion]
if not vpcs:
log.error(
"Private zone requested but no VPC matching given criteria found."
)
return None
if len(vpcs) > 1:
log.error(
"Private zone requested but multiple VPCs matching given "
"criteria found: %s.",
[v["id"] for v in vpcs],
)
return None
vpc = vpcs[0]
if VPCName:
VPCId = vpc["id"]
if not VPCRegion:
VPCRegion = vpc["region"]
args.update({"VPC": {"VPCId": VPCId, "VPCRegion": VPCRegion}})
else:
if any((VPCId, VPCName, VPCRegion)):
log.info(
"Options VPCId, VPCName, and VPCRegion are ignored when creating "
"non-private zones."
)
tries = 10
while tries:
try:
r = conn.create_hosted_zone(**args)
r.pop("ResponseMetadata", None)
if _wait_for_sync(r["ChangeInfo"]["Id"], conn):
return [r]
return []
except ClientError as e:
if tries and e.response.get("Error", {}).get("Code") == "Throttling":
log.debug("Throttled by AWS API.")
time.sleep(3)
tries -= 1
continue
log.error("Failed to create hosted zone %s: %s", Name, e)
return []
return []
def update_hosted_zone_comment(
Id=None,
Name=None,
Comment=None,
PrivateZone=None,
region=None,
key=None,
keyid=None,
profile=None,
):
"""
Update the comment on an existing Route 53 hosted zone.
Id
The unique Zone Identifier for the Hosted Zone.
Name
The domain name associated with the Hosted Zone(s).
Comment
Any comments you want to include about the hosted zone.
PrivateZone
Boolean - Set to True if changing a private hosted zone.
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.update_hosted_zone_comment Name=example.org. \
Comment="This is an example comment for an example zone"
"""
if not _exactly_one((Id, Name)):
raise SaltInvocationError("Exactly one of either Id or Name is required.")
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
if Name:
args = {
"Name": Name,
"PrivateZone": PrivateZone,
"region": region,
"key": key,
"keyid": keyid,
"profile": profile,
}
zone = find_hosted_zone(**args)
if not zone:
log.error("Couldn't resolve domain name %s to a hosted zone ID.", Name)
return []
Id = zone[0]["HostedZone"]["Id"]
tries = 10
while tries:
try:
r = conn.update_hosted_zone_comment(Id=Id, Comment=Comment)
r.pop("ResponseMetadata", None)
return [r]
except ClientError as e:
if tries and e.response.get("Error", {}).get("Code") == "Throttling":
log.debug("Throttled by AWS API.")
time.sleep(3)
tries -= 1
continue
log.error("Failed to update comment on hosted zone %s: %s", Name or Id, e)
return []
def associate_vpc_with_hosted_zone(
HostedZoneId=None,
Name=None,
VPCId=None,
VPCName=None,
VPCRegion=None,
Comment=None,
region=None,
key=None,
keyid=None,
profile=None,
):
"""
Associates an Amazon VPC with a private hosted zone.
To perform the association, the VPC and the private hosted zone must already exist. You can't
convert a public hosted zone into a private hosted zone. If you want to associate a VPC from
one AWS account with a zone from a another, the AWS account owning the hosted zone must first
submit a CreateVPCAssociationAuthorization (using create_vpc_association_authorization() or by
other means, such as the AWS console). With that done, the account owning the VPC can then call
associate_vpc_with_hosted_zone() to create the association.
Note that if both sides happen to be within the same account, associate_vpc_with_hosted_zone()
is enough on its own, and there is no need for the CreateVPCAssociationAuthorization step.
Also note that looking up hosted zones by name (e.g. using the Name parameter) only works
within a single account - if you're associating a VPC to a zone in a different account, as
outlined above, you unfortunately MUST use the HostedZoneId parameter exclusively.
HostedZoneId
The unique Zone Identifier for the Hosted Zone.
Name
The domain name associated with the Hosted Zone(s).
VPCId
When working with a private hosted zone, either the VPC ID or VPC Name to associate with is
required. Exclusive with VPCName.
VPCName
When working with a private hosted zone, either the VPC ID or VPC Name to associate with is
required. Exclusive with VPCId.
VPCRegion
When working with a private hosted zone, the region of the associated VPC is required. If
not provided, an effort will be made to determine it from VPCId or VPCName, if possible. If
this fails, you'll need to provide an explicit value for VPCRegion.
Comment
Any comments you want to include about the change being made.
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.associate_vpc_with_hosted_zone \
Name=example.org. VPCName=myVPC \
VPCRegion=us-east-1 Comment="Whoo-hoo! I added another VPC."
"""
if not _exactly_one((HostedZoneId, Name)):
raise SaltInvocationError(
"Exactly one of either HostedZoneId or Name is required."
)
if not _exactly_one((VPCId, VPCName)):
raise SaltInvocationError("Exactly one of either VPCId or VPCName is required.")
if Name:
# {'PrivateZone': True} because you can only associate VPCs with private hosted zones.
args = {
"Name": Name,
"PrivateZone": True,
"region": region,
"key": key,
"keyid": keyid,
"profile": profile,
}
zone = find_hosted_zone(**args)
if not zone:
log.error(
"Couldn't resolve domain name %s to a private hosted zone ID.", Name
)
return False
HostedZoneId = zone[0]["HostedZone"]["Id"]
vpcs = __salt__["boto_vpc.describe_vpcs"](
vpc_id=VPCId, name=VPCName, region=region, key=key, keyid=keyid, profile=profile
).get("vpcs", [])
if VPCRegion and vpcs:
vpcs = [v for v in vpcs if v["region"] == VPCRegion]
if not vpcs:
log.error("No VPC matching the given criteria found.")
return False
if len(vpcs) > 1:
log.error(
"Multiple VPCs matching the given criteria found: %s.",
", ".join([v["id"] for v in vpcs]),
)
return False
vpc = vpcs[0]
if VPCName:
VPCId = vpc["id"]
if not VPCRegion:
VPCRegion = vpc["region"]
args = {
"HostedZoneId": HostedZoneId,
"VPC": {"VPCId": VPCId, "VPCRegion": VPCRegion},
}
args.update({"Comment": Comment}) if Comment is not None else None
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
tries = 10
while tries:
try:
r = conn.associate_vpc_with_hosted_zone(**args)
return _wait_for_sync(r["ChangeInfo"]["Id"], conn)
except ClientError as e:
if e.response.get("Error", {}).get("Code") == "ConflictingDomainExists":
log.debug("VPC Association already exists.")
# return True since the current state is the desired one
return True
if tries and e.response.get("Error", {}).get("Code") == "Throttling":
log.debug("Throttled by AWS API.")
time.sleep(3)
tries -= 1
continue
log.error(
"Failed to associate VPC %s with hosted zone %s: %s",
VPCName or VPCId,
Name or HostedZoneId,
e,
)
return False
def disassociate_vpc_from_hosted_zone(
HostedZoneId=None,
Name=None,
VPCId=None,
VPCName=None,
VPCRegion=None,
Comment=None,
region=None,
key=None,
keyid=None,
profile=None,
):
"""
Disassociates an Amazon VPC from a private hosted zone.
You can't disassociate the last VPC from a private hosted zone. You also can't convert a
private hosted zone into a public hosted zone.
Note that looking up hosted zones by name (e.g. using the Name parameter) only works XXX FACTCHECK
within a single AWS account - if you're disassociating a VPC in one account from a hosted zone
in a different account you unfortunately MUST use the HostedZoneId parameter exclusively. XXX FIXME DOCU
HostedZoneId
The unique Zone Identifier for the Hosted Zone.
Name
The domain name associated with the Hosted Zone(s).
VPCId
When working with a private hosted zone, either the VPC ID or VPC Name to associate with is
required. Exclusive with VPCName.
VPCName
When working with a private hosted zone, either the VPC ID or VPC Name to associate with is
required. Exclusive with VPCId.
VPCRegion
When working with a private hosted zone, the region of the associated VPC is required. If
not provided, an effort will be made to determine it from VPCId or VPCName, if possible. If
this fails, you'll need to provide an explicit value for VPCRegion.
Comment
Any comments you want to include about the change being made.
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.disassociate_vpc_from_hosted_zone \
Name=example.org. VPCName=myVPC \
VPCRegion=us-east-1 Comment="Whoops! Don't wanna talk to this-here zone no more."
"""
if not _exactly_one((HostedZoneId, Name)):
raise SaltInvocationError(
"Exactly one of either HostedZoneId or Name is required."
)
if not _exactly_one((VPCId, VPCName)):
raise SaltInvocationError("Exactly one of either VPCId or VPCName is required.")
if Name:
# {'PrivateZone': True} because you can only associate VPCs with private hosted zones.
args = {
"Name": Name,
"PrivateZone": True,
"region": region,
"key": key,
"keyid": keyid,
"profile": profile,
}
zone = find_hosted_zone(**args)
if not zone:
log.error(
"Couldn't resolve domain name %s to a private hosted zone ID.", Name
)
return False
HostedZoneId = zone[0]["HostedZone"]["Id"]
vpcs = __salt__["boto_vpc.describe_vpcs"](
vpc_id=VPCId, name=VPCName, region=region, key=key, keyid=keyid, profile=profile
).get("vpcs", [])
if VPCRegion and vpcs:
vpcs = [v for v in vpcs if v["region"] == VPCRegion]
if not vpcs:
log.error("No VPC matching the given criteria found.")
return False
if len(vpcs) > 1:
log.error(
"Multiple VPCs matching the given criteria found: %s.",
", ".join([v["id"] for v in vpcs]),
)
return False
vpc = vpcs[0]
if VPCName:
VPCId = vpc["id"]
if not VPCRegion:
VPCRegion = vpc["region"]
args = {
"HostedZoneId": HostedZoneId,
"VPC": {"VPCId": VPCId, "VPCRegion": VPCRegion},
}
args.update({"Comment": Comment}) if Comment is not None else None
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
tries = 10
while tries:
try:
r = conn.disassociate_vpc_from_hosted_zone(**args)
return _wait_for_sync(r["ChangeInfo"]["Id"], conn)
except ClientError as e:
if e.response.get("Error", {}).get("Code") == "VPCAssociationNotFound":
log.debug("No VPC Association exists.")
# return True since the current state is the desired one
return True
if tries and e.response.get("Error", {}).get("Code") == "Throttling":
log.debug("Throttled by AWS API.")
time.sleep(3)
tries -= 1
continue
log.error(
"Failed to associate VPC %s with hosted zone %s: %s",
VPCName or VPCId,
Name or HostedZoneId,
e,
)
return False
# def create_vpc_association_authorization(*args, **kwargs):
# '''
# unimplemented
# '''
# pass
# def delete_vpc_association_authorization(*args, **kwargs):
# '''
# unimplemented
# '''
# pass
# def list_vpc_association_authorizations(*args, **kwargs):
# '''
# unimplemented
# '''
# pass
def delete_hosted_zone(Id, region=None, key=None, keyid=None, profile=None):
"""
Delete a Route53 hosted zone.
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.delete_hosted_zone Z1234567890
"""
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
try:
r = conn.delete_hosted_zone(Id=Id)
return _wait_for_sync(r["ChangeInfo"]["Id"], conn)
except ClientError as e:
log.error("Failed to delete hosted zone %s: %s", Id, e)
return False
def delete_hosted_zone_by_domain(
Name, PrivateZone=None, region=None, key=None, keyid=None, profile=None
):
"""
Delete a Route53 hosted zone by domain name, and PrivateZone status if provided.
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.delete_hosted_zone_by_domain example.org.
"""
args = {
"Name": Name,
"PrivateZone": PrivateZone,
"region": region,
"key": key,
"keyid": keyid,
"profile": profile,
}
# Be extra pedantic in the service of safety - if public/private is not provided and the domain
# name resolves to both, fail and require them to declare it explicitly.
zone = find_hosted_zone(**args)
if not zone:
log.error("Couldn't resolve domain name %s to a hosted zone ID.", Name)
return False
Id = zone[0]["HostedZone"]["Id"]
return delete_hosted_zone(
Id=Id, region=region, key=key, keyid=keyid, profile=profile
)
def _aws_encode(x):
"""
An implementation of the encoding required to support AWS's domain name
rules defined here__:
.. __: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html
While AWS's documentation specifies individual ASCII characters which need
to be encoded, we instead just try to force the string to one of
escaped unicode or idna depending on whether there are non-ASCII characters
present.
This means that we support things like ドメイン.テスト as a domain name string.
More information about IDNA encoding in python is found here__:
.. __: https://pypi.org/project/idna
"""
ret = None
try:
x.encode("ascii")
ret = re.sub(rb"\\x([a-f0-8]{2})", _hexReplace, x.encode("unicode_escape"))
except UnicodeEncodeError:
ret = x.encode("idna")
except Exception as e: # pylint: disable=broad-except
log.error(
"Couldn't encode %s using either 'unicode_escape' or 'idna' codecs", x
)
raise CommandExecutionError(e)
log.debug("AWS-encoded result for %s: %s", x, ret)
return ret.decode("utf-8")
def _aws_encode_changebatch(o):
"""
helper method to process a change batch & encode the bits which need encoding.
"""
change_idx = 0
while change_idx < len(o["Changes"]):
o["Changes"][change_idx]["ResourceRecordSet"]["Name"] = _aws_encode(
o["Changes"][change_idx]["ResourceRecordSet"]["Name"]
)
if "ResourceRecords" in o["Changes"][change_idx]["ResourceRecordSet"]:
rr_idx = 0
while rr_idx < len(
o["Changes"][change_idx]["ResourceRecordSet"]["ResourceRecords"]
):
o["Changes"][change_idx]["ResourceRecordSet"]["ResourceRecords"][
rr_idx
]["Value"] = _aws_encode(
o["Changes"][change_idx]["ResourceRecordSet"]["ResourceRecords"][
rr_idx
]["Value"]
)
rr_idx += 1
if "AliasTarget" in o["Changes"][change_idx]["ResourceRecordSet"]:
o["Changes"][change_idx]["ResourceRecordSet"]["AliasTarget"]["DNSName"] = (
_aws_encode(
o["Changes"][change_idx]["ResourceRecordSet"]["AliasTarget"][
"DNSName"
]
)
)
change_idx += 1
return o
def _aws_decode(x):
"""
An implementation of the decoding required to support AWS's domain name
rules defined here__:
.. __: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html
The important part is this:
If the domain name includes any characters other than a to z, 0 to 9, - (hyphen),
or _ (underscore), Route 53 API actions return the characters as escape codes.
This is true whether you specify the characters as characters or as escape
codes when you create the entity.
The Route 53 console displays the characters as characters, not as escape codes."
For a list of ASCII characters the corresponding octal codes, do an internet search on "ascii table".
We look for the existence of any escape codes which give us a clue that
we're received an escaped unicode string; or we assume it's idna encoded
and then decode as necessary.
"""
if "\\" in x:
return x.decode("unicode_escape")
if isinstance(x, bytes):
return x.decode("idna")
return x
def _hexReplace(x):
"""
Converts a hex code to a base 16 int then the octal of it, minus the leading
zero.
This is necessary because x.encode('unicode_escape') automatically assumes
you want a hex string, which AWS will accept but doesn't result in what
you really want unless it's an octal escape sequence
"""
c = int(x.group(1), 16)
return "\\" + str(oct(c))[1:]
def get_resource_records(
HostedZoneId=None,
Name=None,
StartRecordName=None,
StartRecordType=None,
PrivateZone=None,
region=None,
key=None,
keyid=None,
profile=None,
):
"""
Get all resource records from a given zone matching the provided StartRecordName (if given) or all
records in the zone (if not), optionally filtered by a specific StartRecordType. This will return
any and all RRs matching, regardless of their special AWS flavors (weighted, geolocation, alias,
etc.) so your code should be prepared for potentially large numbers of records back from this
function - for example, if you've created a complex geolocation mapping with lots of entries all
over the world providing the same server name to many different regional clients.
If you want EXACTLY ONE record to operate on, you'll need to implement any logic required to
pick the specific RR you care about from those returned.
Note that if you pass in Name without providing a value for PrivateZone (either True or
False), CommandExecutionError can be raised in the case of both public and private zones
matching the domain. XXX FIXME DOCU
CLI Example:
.. code-block:: bash
salt myminion boto3_route53.get_records test.example.org example.org A
"""
if not _exactly_one((HostedZoneId, Name)):
raise SaltInvocationError(
"Exactly one of either HostedZoneId or Name must be provided."
)
if Name:
args = {
"Name": Name,
"region": region,
"key": key,
"keyid": keyid,
"profile": profile,
}
args.update({"PrivateZone": PrivateZone}) if PrivateZone is not None else None
zone = find_hosted_zone(**args)
if not zone:
log.error("Couldn't resolve domain name %s to a hosted zone ID.", Name)
return []
HostedZoneId = zone[0]["HostedZone"]["Id"]
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
ret = []
next_rr_name = StartRecordName
next_rr_type = StartRecordType
next_rr_id = None
done = False
while True:
if done:
return ret
args = {"HostedZoneId": HostedZoneId}
(
args.update({"StartRecordName": _aws_encode(next_rr_name)})
if next_rr_name
else None
)
# Grrr, can't specify type unless name is set... We'll do this via filtering later instead
(
args.update({"StartRecordType": next_rr_type})
if next_rr_name and next_rr_type
else None
)
args.update({"StartRecordIdentifier": next_rr_id}) if next_rr_id else None
try:
r = conn.list_resource_record_sets(**args)
rrs = r["ResourceRecordSets"]
next_rr_name = r.get("NextRecordName")
next_rr_type = r.get("NextRecordType")
next_rr_id = r.get("NextRecordIdentifier")
for rr in rrs:
rr["Name"] = _aws_decode(rr["Name"])
# now iterate over the ResourceRecords and replace any encoded
# value strings with the decoded versions
if "ResourceRecords" in rr:
x = 0
while x < len(rr["ResourceRecords"]):
if "Value" in rr["ResourceRecords"][x]:
rr["ResourceRecords"][x]["Value"] = _aws_decode(
rr["ResourceRecords"][x]["Value"]
)
x += 1
# or if we are an AliasTarget then decode the DNSName
if "AliasTarget" in rr:
rr["AliasTarget"]["DNSName"] = _aws_decode(
rr["AliasTarget"]["DNSName"]
)
if StartRecordName and rr["Name"] != StartRecordName:
done = True
break
if StartRecordType and rr["Type"] != StartRecordType:
if StartRecordName:
done = True
break
else:
# We're filtering by type alone, and there might be more later, so...
continue
ret += [rr]
if not next_rr_name:
done = True
except ClientError as e:
# Try forever on a simple thing like this...
if e.response.get("Error", {}).get("Code") == "Throttling":
log.debug("Throttled by AWS API.")
time.sleep(3)
continue
raise
def change_resource_record_sets(
HostedZoneId=None,
Name=None,
PrivateZone=None,
ChangeBatch=None,
region=None,
key=None,
keyid=None,
profile=None,
):
"""
See the `AWS Route53 API docs`__ as well as the `Boto3 documentation`__ for all the details...
.. __: https://docs.aws.amazon.com/Route53/latest/APIReference/API_ChangeResourceRecordSets.html
.. __: http://boto3.readthedocs.io/en/latest/reference/services/route53.html#Route53.Client.change_resource_record_sets
The syntax for a ChangeBatch parameter is as follows, but note that the permutations of allowed
parameters and combinations thereof are quite varied, so perusal of the above linked docs is
highly recommended for any non-trival configurations.
.. code-block:: text
{
"Comment": "string",
"Changes": [
{
"Action": "CREATE"|"DELETE"|"UPSERT",
"ResourceRecordSet": {
"Name": "string",
"Type": "SOA"|"A"|"TXT"|"NS"|"CNAME"|"MX"|"NAPTR"|"PTR"|"SRV"|"SPF"|"AAAA",
"SetIdentifier": "string",
"Weight": 123,
"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",
"GeoLocation": {
"ContinentCode": "string",
"CountryCode": "string",
"SubdivisionCode": "string"
},
"Failover": "PRIMARY"|"SECONDARY",
"TTL": 123,
"ResourceRecords": [
{
"Value": "string"
},
],
"AliasTarget": {
"HostedZoneId": "string",
"DNSName": "string",
"EvaluateTargetHealth": True|False
},
"HealthCheckId": "string",
"TrafficPolicyInstanceId": "string"
}
},
]
}
CLI Example:
.. code-block:: bash
foo='{
"Name": "my-cname.example.org.",
"TTL": 600,
"Type": "CNAME",
"ResourceRecords": [
{
"Value": "my-host.example.org"
}
]
}'
foo=`echo $foo` # Remove newlines
salt myminion boto3_route53.change_resource_record_sets DomainName=example.org. \
keyid=A1234567890ABCDEF123 key=xblahblahblah \
ChangeBatch="{'Changes': [{'Action': 'UPSERT', 'ResourceRecordSet': $foo}]}"
"""
if not _exactly_one((HostedZoneId, Name)):
raise SaltInvocationError(
"Exactly one of either HostZoneId or Name must be provided."
)
if Name:
args = {
"Name": Name,
"region": region,
"key": key,
"keyid": keyid,
"profile": profile,
}
args.update({"PrivateZone": PrivateZone}) if PrivateZone is not None else None
zone = find_hosted_zone(**args)
if not zone:
log.error("Couldn't resolve domain name %s to a hosted zone ID.", Name)
return []
HostedZoneId = zone[0]["HostedZone"]["Id"]
args = {
"HostedZoneId": HostedZoneId,
"ChangeBatch": _aws_encode_changebatch(ChangeBatch),
}
conn = _get_conn(region=region, key=key, keyid=keyid, profile=profile)
tries = 20 # A bit more headroom
while tries:
try:
r = conn.change_resource_record_sets(**args)
return _wait_for_sync(
r["ChangeInfo"]["Id"], conn, 30
) # And a little extra time here
except ClientError as e:
if tries and e.response.get("Error", {}).get("Code") == "Throttling":
log.debug("Throttled by AWS API.")
time.sleep(3)
tries -= 1
continue
log.error(
"Failed to apply requested changes to the hosted zone %s: %s",
(Name or HostedZoneId),
str(e),
)
raise e
return False
Zerion Mini Shell 1.0