1
0
mirror of https://github.com/github/octodns.git synced 2024-05-11 05:55:00 +00:00
Files
github-octodns/octodns/provider/route53.py
Ross McFarland 70120bedc8 Implement "chunked" TXT/SPF value support for long values
This implements it transparently at Record level. Providers that need things to
be chunked (seems to just be Route53 an Dyn) switch to use `chunked_values`, but
everything else can stick with `values`. I've run through each provider I have
access to verifying that things operate as expected/required. OVH and Azure are
untested.
2017-10-05 10:04:29 -07:00

757 lines
28 KiB
Python

#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from boto3 import client
from botocore.config import Config
from collections import defaultdict
from incf.countryutils.transformations import cca_to_ctca2
from uuid import uuid4
import logging
import re
from ..record import Record, Update
from .base import BaseProvider
octal_re = re.compile(r'\\(\d\d\d)')
def _octal_replace(s):
# See http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/
# DomainNameFormat.html
return octal_re.sub(lambda m: chr(int(m.group(1), 8)), s)
class _Route53Record(object):
@classmethod
def new(self, provider, record, creating):
ret = set()
if getattr(record, 'geo', False):
ret.add(_Route53GeoDefault(provider, record, creating))
for ident, geo in record.geo.items():
ret.add(_Route53GeoRecord(provider, record, ident, geo,
creating))
else:
ret.add(_Route53Record(provider, record, creating))
return ret
def __init__(self, provider, record, creating):
self.fqdn = record.fqdn
self._type = record._type
self.ttl = record.ttl
values_for = getattr(self, '_values_for_{}'.format(self._type))
self.values = values_for(record)
def mod(self, action):
return {
'Action': action,
'ResourceRecordSet': {
'Name': self.fqdn,
'ResourceRecords': [{'Value': v} for v in self.values],
'TTL': self.ttl,
'Type': self._type,
}
}
# NOTE: we're using __hash__ and __cmp__ methods that consider
# _Route53Records equivalent if they have the same class, fqdn, and _type.
# Values are ignored. This is usful when computing diffs/changes.
def __hash__(self):
'sub-classes should never use this method'
return '{}:{}'.format(self.fqdn, self._type).__hash__()
def __cmp__(self, other):
'''sub-classes should call up to this and return its value if non-zero.
When it's zero they should compute their own __cmp__'''
if self.__class__ != other.__class__:
return cmp(self.__class__, other.__class__)
elif self.fqdn != other.fqdn:
return cmp(self.fqdn, other.fqdn)
elif self._type != other._type:
return cmp(self._type, other._type)
# We're ignoring ttl, it's not an actual differentiator
return 0
def __repr__(self):
return '_Route53Record<{} {} {} {}>'.format(self.fqdn, self._type,
self.ttl, self.values)
def _values_for_values(self, record):
return record.values
_values_for_A = _values_for_values
_values_for_AAAA = _values_for_values
_values_for_NS = _values_for_values
def _values_for_CAA(self, record):
return ['{} {} "{}"'.format(v.flags, v.tag, v.value)
for v in record.values]
def _values_for_value(self, record):
return [record.value]
_values_for_CNAME = _values_for_value
_values_for_PTR = _values_for_value
def _values_for_MX(self, record):
return ['{} {}'.format(v.preference, v.exchange)
for v in record.values]
def _values_for_NAPTR(self, record):
return ['{} {} "{}" "{}" "{}" {}'
.format(v.order, v.preference,
v.flags if v.flags else '',
v.service if v.service else '',
v.regexp if v.regexp else '',
v.replacement)
for v in record.values]
def _values_for_quoted(self, record):
return record.chunked_values
_values_for_SPF = _values_for_quoted
_values_for_TXT = _values_for_quoted
def _values_for_SRV(self, record):
return ['{} {} {} {}'.format(v.priority, v.weight, v.port,
v.target)
for v in record.values]
class _Route53GeoDefault(_Route53Record):
def mod(self, action):
return {
'Action': action,
'ResourceRecordSet': {
'Name': self.fqdn,
'GeoLocation': {
'CountryCode': '*'
},
'ResourceRecords': [{'Value': v} for v in self.values],
'SetIdentifier': 'default',
'TTL': self.ttl,
'Type': self._type,
}
}
def __hash__(self):
return '{}:{}:default'.format(self.fqdn, self._type).__hash__()
def __repr__(self):
return '_Route53GeoDefault<{} {} {} {}>'.format(self.fqdn, self._type,
self.ttl, self.values)
class _Route53GeoRecord(_Route53Record):
def __init__(self, provider, record, ident, geo, creating):
super(_Route53GeoRecord, self).__init__(provider, record, creating)
self.geo = geo
self.health_check_id = provider.get_health_check_id(record, ident,
geo, creating)
def mod(self, action):
geo = self.geo
rrset = {
'Name': self.fqdn,
'GeoLocation': {
'CountryCode': '*'
},
'ResourceRecords': [{'Value': v} for v in geo.values],
'SetIdentifier': geo.code,
'TTL': self.ttl,
'Type': self._type,
}
if self.health_check_id:
rrset['HealthCheckId'] = self.health_check_id
if geo.subdivision_code:
rrset['GeoLocation'] = {
'CountryCode': geo.country_code,
'SubdivisionCode': geo.subdivision_code
}
elif geo.country_code:
rrset['GeoLocation'] = {
'CountryCode': geo.country_code
}
else:
rrset['GeoLocation'] = {
'ContinentCode': geo.continent_code
}
return {
'Action': action,
'ResourceRecordSet': rrset,
}
def __hash__(self):
return '{}:{}:{}'.format(self.fqdn, self._type,
self.geo.code).__hash__()
def __cmp__(self, other):
ret = super(_Route53GeoRecord, self).__cmp__(other)
if ret != 0:
return ret
return cmp(self.geo.code, other.geo.code)
def __repr__(self):
return '_Route53GeoRecord<{} {} {} {} {}>'.format(self.fqdn,
self._type, self.ttl,
self.geo.code,
self.values)
class Route53Provider(BaseProvider):
'''
AWS Route53 Provider
route53:
class: octodns.provider.route53.Route53Provider
# The AWS access key id (required)
access_key_id:
# The AWS secret access key (required)
secret_access_key:
In general the account used will need full permissions on Route53.
'''
SUPPORTS_GEO = True
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR',
'SPF', 'SRV', 'TXT'))
# This should be bumped when there are underlying changes made to the
# health check config.
HEALTH_CHECK_VERSION = '0000'
def __init__(self, id, access_key_id, secret_access_key, max_changes=1000,
client_max_attempts=None, *args, **kwargs):
self.max_changes = max_changes
self.log = logging.getLogger('Route53Provider[{}]'.format(id))
self.log.debug('__init__: id=%s, access_key_id=%s, '
'secret_access_key=***', id, access_key_id)
super(Route53Provider, self).__init__(id, *args, **kwargs)
config = None
if client_max_attempts is not None:
self.log.info('__init__: setting max_attempts to %d',
client_max_attempts)
config = Config(retries={'max_attempts': client_max_attempts})
self._conn = client('route53', aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key,
config=config)
self._r53_zones = None
self._r53_rrsets = {}
self._health_checks = None
@property
def r53_zones(self):
if self._r53_zones is None:
self.log.debug('r53_zones: loading')
zones = {}
more = True
start = {}
while more:
resp = self._conn.list_hosted_zones(**start)
for z in resp['HostedZones']:
zones[z['Name']] = z['Id']
more = resp['IsTruncated']
start['Marker'] = resp.get('NextMarker', None)
self._r53_zones = zones
return self._r53_zones
def _get_zone_id(self, name, create=False):
self.log.debug('_get_zone_id: name=%s', name)
if name in self.r53_zones:
id = self.r53_zones[name]
self.log.debug('_get_zone_id: id=%s', id)
return id
if create:
ref = uuid4().hex
self.log.debug('_get_zone_id: no matching zone, creating, '
'ref=%s', ref)
resp = self._conn.create_hosted_zone(Name=name,
CallerReference=ref)
self.r53_zones[name] = id = resp['HostedZone']['Id']
return id
return None
def _parse_geo(self, rrset):
try:
loc = rrset['GeoLocation']
except KeyError:
# No geo loc
return
try:
return loc['ContinentCode']
except KeyError:
# Must be country
cc = loc['CountryCode']
if cc == '*':
# This is the default
return
cn = cca_to_ctca2(cc)
try:
return '{}-{}-{}'.format(cn, cc, loc['SubdivisionCode'])
except KeyError:
return '{}-{}'.format(cn, cc)
def _data_for_geo(self, rrset):
ret = {
'type': rrset['Type'],
'values': [v['Value'] for v in rrset['ResourceRecords']],
'ttl': int(rrset['TTL'])
}
geo = self._parse_geo(rrset)
if geo:
ret['geo'] = geo
return ret
_data_for_A = _data_for_geo
_data_for_AAAA = _data_for_geo
def _data_for_CAA(self, rrset):
values = []
for rr in rrset['ResourceRecords']:
flags, tag, value = rr['Value'].split(' ')
values.append({
'flags': flags,
'tag': tag,
'value': value[1:-1],
})
return {
'type': rrset['Type'],
'values': values,
'ttl': int(rrset['TTL'])
}
def _data_for_single(self, rrset):
return {
'type': rrset['Type'],
'value': rrset['ResourceRecords'][0]['Value'],
'ttl': int(rrset['TTL'])
}
_data_for_PTR = _data_for_single
_data_for_CNAME = _data_for_single
_fix_semicolons = re.compile(r'(?<!\\);')
def _data_for_quoted(self, rrset):
return {
'type': rrset['Type'],
'values': [self._fix_semicolons.sub('\;', rr['Value'][1:-1])
for rr in rrset['ResourceRecords']],
'ttl': int(rrset['TTL'])
}
_data_for_TXT = _data_for_quoted
_data_for_SPF = _data_for_quoted
def _data_for_MX(self, rrset):
values = []
for rr in rrset['ResourceRecords']:
preference, exchange = rr['Value'].split(' ')
values.append({
'preference': preference,
'exchange': exchange,
})
return {
'type': rrset['Type'],
'values': values,
'ttl': int(rrset['TTL'])
}
def _data_for_NAPTR(self, rrset):
values = []
for rr in rrset['ResourceRecords']:
order, preference, flags, service, regexp, replacement = \
rr['Value'].split(' ')
flags = flags[1:-1]
service = service[1:-1]
regexp = regexp[1:-1]
values.append({
'order': order,
'preference': preference,
'flags': flags if flags else None,
'service': service if service else None,
'regexp': regexp if regexp else None,
'replacement': replacement if replacement else None,
})
return {
'type': rrset['Type'],
'values': values,
'ttl': int(rrset['TTL'])
}
def _data_for_NS(self, rrset):
return {
'type': rrset['Type'],
'values': [v['Value'] for v in rrset['ResourceRecords']],
'ttl': int(rrset['TTL'])
}
def _data_for_SRV(self, rrset):
values = []
for rr in rrset['ResourceRecords']:
priority, weight, port, target = rr['Value'].split(' ')
values.append({
'priority': priority,
'weight': weight,
'port': port,
'target': target,
})
return {
'type': rrset['Type'],
'values': values,
'ttl': int(rrset['TTL'])
}
def _load_records(self, zone_id):
if zone_id not in self._r53_rrsets:
self.log.debug('_load_records: zone_id=%s loading', zone_id)
rrsets = []
more = True
start = {}
while more:
resp = \
self._conn.list_resource_record_sets(HostedZoneId=zone_id,
**start)
rrsets += resp['ResourceRecordSets']
more = resp['IsTruncated']
if more:
start = {
'StartRecordName': resp['NextRecordName'],
'StartRecordType': resp['NextRecordType'],
}
try:
start['StartRecordIdentifier'] = \
resp['NextRecordIdentifier']
except KeyError:
pass
self._r53_rrsets[zone_id] = rrsets
return self._r53_rrsets[zone_id]
def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient)
before = len(zone.records)
zone_id = self._get_zone_id(zone.name)
if zone_id:
records = defaultdict(lambda: defaultdict(list))
for rrset in self._load_records(zone_id):
record_name = zone.hostname_from_fqdn(rrset['Name'])
record_name = _octal_replace(record_name)
record_type = rrset['Type']
if record_type == 'SOA':
continue
data = getattr(self, '_data_for_{}'.format(record_type))(rrset)
records[record_name][record_type].append(data)
for name, types in records.items():
for _type, data in types.items():
if len(data) > 1:
# Multiple data indicates a record with GeoDNS, convert
# them data into the format we need
geo = {}
for d in data:
try:
geo[d['geo']] = d['values']
except KeyError:
primary = d
data = primary
data['geo'] = geo
else:
data = data[0]
record = Record.new(zone, name, data, source=self,
lenient=lenient)
zone.add_record(record)
self.log.info('populate: found %s records',
len(zone.records) - before)
def _gen_mods(self, action, records):
'''
Turns `_Route53*`s in to `change_resource_record_sets` `Changes`
'''
return [r.mod(action) for r in records]
@property
def health_checks(self):
if self._health_checks is None:
# need to do the first load
self.log.debug('health_checks: loading')
checks = {}
more = True
start = {}
while more:
resp = self._conn.list_health_checks(**start)
for health_check in resp['HealthChecks']:
# our format for CallerReference is dddd:hex-uuid
ref = health_check.get('CallerReference', 'xxxxx')
if len(ref) > 4 and ref[4] != ':':
# ignore anything else
continue
checks[health_check['Id']] = health_check
more = resp['IsTruncated']
start['Marker'] = resp.get('NextMarker', None)
self._health_checks = checks
# We've got a cached version use it
return self._health_checks
def get_health_check_id(self, record, ident, geo, create):
# fqdn & the first value are special, we use them to match up health
# checks to their records. Route53 health checks check a single ip and
# we're going to assume that ips are interchangeable to avoid
# health-checking each one independently
fqdn = record.fqdn
first_value = geo.values[0]
self.log.debug('get_health_check_id: fqdn=%s, type=%s, geo=%s, '
'first_value=%s', fqdn, record._type, ident,
first_value)
# health check host can't end with a .
host = fqdn[:-1]
# we're looking for a healthcheck with the current version & our record
# type, we'll ignore anything else
expected_version_and_type = '{}:{}:'.format(self.HEALTH_CHECK_VERSION,
record._type)
for id, health_check in self.health_checks.items():
if not health_check['CallerReference'] \
.startswith(expected_version_and_type):
# not a version & type match, ignore
continue
config = health_check['HealthCheckConfig']
if host == config['FullyQualifiedDomainName'] and \
first_value == config['IPAddress']:
# this is the health check we're looking for
return id
if not create:
# no existing matches and not allowed to create, return none
return
# no existing matches, we need to create a new health check
config = {
'EnableSNI': True,
'FailureThreshold': 6,
'FullyQualifiedDomainName': host,
'IPAddress': first_value,
'MeasureLatency': True,
'Port': 443,
'RequestInterval': 10,
'ResourcePath': '/_dns',
'Type': 'HTTPS',
}
ref = '{}:{}:{}'.format(self.HEALTH_CHECK_VERSION, record._type,
uuid4().hex[:16])
resp = self._conn.create_health_check(CallerReference=ref,
HealthCheckConfig=config)
health_check = resp['HealthCheck']
id = health_check['Id']
# store the new health check so that we'll be able to find it in the
# future
self._health_checks[id] = health_check
self.log.info('get_health_check_id: created id=%s, host=%s, '
'first_value=%s', id, host, first_value)
return id
def _gc_health_checks(self, record, new):
self.log.debug('_gc_health_checks: record=%s', record)
# Find the health checks we're using for the new route53 records
in_use = set()
for r in new:
hc_id = getattr(r, 'health_check_id', False)
if hc_id:
in_use.add(hc_id)
self.log.debug('_gc_health_checks: in_use=%s', in_use)
# Now we need to run through ALL the health checks looking for those
# that apply to this record, deleting any that do and are no longer in
# use
host = record.fqdn[:-1]
for id, health_check in self.health_checks.items():
config = health_check['HealthCheckConfig']
_type = health_check['CallerReference'].split(':', 2)[1]
# if host and the pulled out type match it applies
if host == config['FullyQualifiedDomainName'] and \
_type == record._type and id not in in_use:
# this is a health check for our fqdn & type but not one we're
# planning to use going forward
self.log.info('_gc_health_checks: deleting id=%s', id)
self._conn.delete_health_check(HealthCheckId=id)
def _gen_records(self, record, creating=False):
'''
Turns an octodns.Record into one or more `_Route53*`s
'''
return _Route53Record.new(self, record, creating)
def _mod_Create(self, change):
# New is the stuff that needs to be created
new_records = self._gen_records(change.new, creating=True)
# Now is a good time to clear out any unused health checks since we
# know what we'll be using going forward
self._gc_health_checks(change.new, new_records)
return self._gen_mods('CREATE', new_records)
def _mod_Update(self, change):
# See comments in _Route53Record for how the set math is made to do our
# bidding here.
existing_records = self._gen_records(change.existing, creating=False)
new_records = self._gen_records(change.new, creating=True)
# Now is a good time to clear out any unused health checks since we
# know what we'll be using going forward
self._gc_health_checks(change.new, new_records)
# Things in existing, but not new are deletes
deletes = existing_records - new_records
# Things in new, but not existing are the creates
creates = new_records - existing_records
# Things in both need updating, we could optimize this and filter out
# things that haven't actually changed, but that's for another day.
# We can't use set math here b/c we won't be able to control which of
# the two objects will be in the result and we need to ensure it's the
# new one.
upserts = set()
for new_record in new_records:
if new_record in existing_records:
upserts.add(new_record)
return self._gen_mods('DELETE', deletes) + \
self._gen_mods('CREATE', creates) + \
self._gen_mods('UPSERT', upserts)
def _mod_Delete(self, change):
# Existing is the thing that needs to be deleted
existing_records = self._gen_records(change.existing, creating=False)
# Now is a good time to clear out all the health checks since we know
# we're done with them
self._gc_health_checks(change.existing, [])
return self._gen_mods('DELETE', existing_records)
def _extra_changes(self, existing, changes):
self.log.debug('_extra_changes: existing=%s', existing.name)
zone_id = self._get_zone_id(existing.name)
if not zone_id:
# zone doesn't exist so no extras to worry about
return []
# we'll skip extra checking for anything we're already going to change
changed = set([c.record for c in changes])
# ok, now it's time for the reason we're here, we need to go over all
# the existing records
extra = []
for record in existing.records:
if record in changed:
# already have a change for it, skipping
continue
if not getattr(record, 'geo', False):
# record doesn't support geo, we don't need to inspect it
continue
# OK this is a record we don't have change for that does have geo
# information. We need to look and see if it needs to be updated
# b/c of a health check version bump
self.log.debug('_extra_changes: inspecting=%s, %s', record.fqdn,
record._type)
fqdn = record.fqdn
# loop through all the r53 rrsets
for rrset in self._load_records(zone_id):
if fqdn != rrset['Name'] or record._type != rrset['Type']:
# not a name and type match
continue
if rrset.get('GeoLocation', {}) \
.get('CountryCode', False) == '*':
# it's a default record
continue
# we expect a healtcheck now
try:
health_check_id = rrset['HealthCheckId']
caller_ref = \
self.health_checks[health_check_id]['CallerReference']
if caller_ref.startswith(self.HEALTH_CHECK_VERSION):
# it has the right health check
continue
except KeyError:
# no health check id or one that isn't the right version
pass
# no good, doesn't have the right health check, needs an update
self.log.debug('_extra_changes: health-check caused '
'update')
extra.append(Update(record, record))
# We don't need to process this record any longer
break
return extra
def _apply(self, plan):
desired = plan.desired
changes = plan.changes
self.log.info('_apply: zone=%s, len(changes)=%d', desired.name,
len(changes))
batch = []
batch_rs_count = 0
zone_id = self._get_zone_id(desired.name, True)
for c in changes:
mods = getattr(self, '_mod_{}'.format(c.__class__.__name__))(c)
mods_rs_count = sum(
[len(m['ResourceRecordSet']['ResourceRecords']) for m in mods]
)
if mods_rs_count > self.max_changes:
# a single mod resulted in too many ResourceRecords changes
raise Exception('Too many modifications: {}'
.format(mods_rs_count))
# r53 limits changesets to 1000 entries
if (batch_rs_count + mods_rs_count) < self.max_changes:
# append to the batch
batch += mods
batch_rs_count += mods_rs_count
else:
self.log.info('_apply: sending change request for batch of '
'%d mods, %d ResourceRecords', len(batch),
batch_rs_count)
# send the batch
self._really_apply(batch, zone_id)
# start a new batch with the lefovers
batch = mods
batch_rs_count = mods_rs_count
# the way the above process works there will always be something left
# over in batch to process. In the case that we submit a batch up there
# it was always the case that there was something pushing us over
# max_changes and thus left over to submit.
self.log.info('_apply: sending change request for batch of %d mods,'
' %d ResourceRecords', len(batch),
batch_rs_count)
self._really_apply(batch, zone_id)
def _really_apply(self, batch, zone_id):
uuid = uuid4().hex
batch = {
'Comment': 'Change: {}'.format(uuid),
'Changes': batch,
}
self.log.debug('_really_apply: sending change request, comment=%s',
batch['Comment'])
resp = self._conn.change_resource_record_sets(
HostedZoneId=zone_id, ChangeBatch=batch)
self.log.debug('_really_apply: change info=%s', resp['ChangeInfo'])