# # # from __future__ import absolute_import, division, print_function, \ unicode_literals from boto3 import client from botocore.config import Config from collections import defaultdict from ipaddress import AddressValueError, ip_address from pycountry_convert import country_alpha2_to_continent_code from uuid import uuid4 import logging import re from six import text_type from ..equality import EqualityTupleMixin from ..record import Record, Update from ..record.geo import GeoCodes 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(EqualityTupleMixin): @classmethod def _new_dynamic(cls, provider, record, hosted_zone_id, creating): # Creates the RRSets that correspond to the given dynamic record ret = set() # HostedZoneId wants just the last bit, but the place we're getting # this from looks like /hostedzone/Z424CArX3BB224 hosted_zone_id = hosted_zone_id.split('/', 2)[-1] # Create the default pool which comes from the base `values` of the # record object. Its only used if all other values fail their # healthchecks, which hopefully never happens. fqdn = record.fqdn ret.add(_Route53Record(provider, record, creating, '_octodns-default-pool.{}'.format(fqdn))) # Pools for pool_name, pool in record.dynamic.pools.items(): # Create the primary, this will be the rrset that geo targeted # rrsets will point to when they want to use a pool of values. It's # a primary and observes target health so if all the values for # this pool go red, we'll use the fallback/SECONDARY just below ret.add(_Route53DynamicPool(provider, hosted_zone_id, record, pool_name, creating)) # Create the fallback for this pool fallback = pool.data.get('fallback', False) if fallback: # We have an explicitly configured fallback, another pool to # use if all our values go red. This RRSet configures that pool # as the next best option ret.add(_Route53DynamicPool(provider, hosted_zone_id, record, pool_name, creating, target_name=fallback)) else: # We fallback on the default, no explicit fallback so if all of # this pool's values go red we'll fallback to the base # (non-health-checked) default pool of values ret.add(_Route53DynamicPool(provider, hosted_zone_id, record, pool_name, creating, target_name='default')) # Create the values for this pool. These are health checked and in # general each unique value will have an associated healthcheck. # The PRIMARY pool up above will point to these RRSets which will # be served out according to their weights for i, value in enumerate(pool.data['values']): weight = value['weight'] value = value['value'] ret.add(_Route53DynamicValue(provider, record, pool_name, value, weight, i, creating)) # Rules for i, rule in enumerate(record.dynamic.rules): pool_name = rule.data['pool'] geos = rule.data.get('geos', []) if geos: for geo in geos: # Create a RRSet for each geo in each rule that uses the # desired target pool ret.add(_Route53DynamicRule(provider, hosted_zone_id, record, pool_name, i, creating, geo=geo)) else: # There's no geo's for this rule so it's the catchall that will # just point things that don't match any geo rules to the # specified pool ret.add(_Route53DynamicRule(provider, hosted_zone_id, record, pool_name, i, creating)) return ret @classmethod def _new_geo(cls, provider, record, creating): # Creates the RRSets that correspond to the given geo record ret = set() ret.add(_Route53GeoDefault(provider, record, creating)) for ident, geo in record.geo.items(): ret.add(_Route53GeoRecord(provider, record, ident, geo, creating)) return ret @classmethod def new(cls, provider, record, hosted_zone_id, creating): # Creates the RRSets that correspond to the given record if getattr(record, 'dynamic', False): ret = cls._new_dynamic(provider, record, hosted_zone_id, creating) return ret elif getattr(record, 'geo', False): return cls._new_geo(provider, record, creating) # Its a simple record that translates into a single RRSet return set((_Route53Record(provider, record, creating),)) def __init__(self, provider, record, creating, fqdn_override=None): self.fqdn = fqdn_override or 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, existing_rrsets): 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 ordering methods that consider # _Route53Records equivalent if they have the same class, fqdn, and _type. # Values are ignored. This is useful when computing diffs/changes. def __hash__(self): 'sub-classes should never use this method' return '{}:{}'.format(self.fqdn, self._type).__hash__() def _equality_tuple(self): '''Sub-classes should call up to this and return its value and add any additional fields they need to hav considered.''' return (self.__class__.__name__, self.fqdn, self._type) def __repr__(self): return '_Route53Record<{} {} {} {}>'.format(self.fqdn, self._type, self.ttl, self.values) def _value_convert_value(self, value, record): return value _value_convert_A = _value_convert_value _value_convert_AAAA = _value_convert_value _value_convert_NS = _value_convert_value _value_convert_CNAME = _value_convert_value _value_convert_PTR = _value_convert_value 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 _value_convert_CAA(self, value, record): return '{} {} "{}"'.format(value.flags, value.tag, value.value) def _values_for_CAA(self, record): return [self._value_convert_CAA(v, record) 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 _value_convert_MX(self, value, record): return '{} {}'.format(value.preference, value.exchange) def _values_for_MX(self, record): return [self._value_convert_MX(v, record) for v in record.values] def _value_convert_NAPTR(self, value, record): return '{} {} "{}" "{}" "{}" {}' \ .format(value.order, value.preference, value.flags if value.flags else '', value.service if value.service else '', value.regexp if value.regexp else '', value.replacement) def _values_for_NAPTR(self, record): return [self._value_convert_NAPTR(v, record) for v in record.values] def _value_convert_quoted(self, value, record): return record.chunked_value(value) _value_convert_SPF = _value_convert_quoted _value_convert_TXT = _value_convert_quoted def _values_for_quoted(self, record): return record.chunked_values _values_for_SPF = _values_for_quoted _values_for_TXT = _values_for_quoted def _value_for_SRV(self, value, record): return '{} {} {} {}'.format(value.priority, value.weight, value.port, value.target) def _values_for_SRV(self, record): return [self._value_for_SRV(v, record) for v in record.values] class _Route53DynamicPool(_Route53Record): def __init__(self, provider, hosted_zone_id, record, pool_name, creating, target_name=None): fqdn_override = '_octodns-{}-pool.{}'.format(pool_name, record.fqdn) super(_Route53DynamicPool, self) \ .__init__(provider, record, creating, fqdn_override=fqdn_override) self.hosted_zone_id = hosted_zone_id self.pool_name = pool_name self.target_name = target_name if target_name: # We're pointing down the chain self.target_dns_name = '_octodns-{}-pool.{}'.format(target_name, record.fqdn) else: # We're a paimary, point at our values self.target_dns_name = '_octodns-{}-value.{}'.format(pool_name, record.fqdn) @property def mode(self): return 'Secondary' if self.target_name else 'Primary' @property def identifer(self): if self.target_name: return '{}-{}-{}'.format(self.pool_name, self.mode, self.target_name) return '{}-{}'.format(self.pool_name, self.mode) def mod(self, action, existing_rrsets): return { 'Action': action, 'ResourceRecordSet': { 'AliasTarget': { 'DNSName': self.target_dns_name, 'EvaluateTargetHealth': True, 'HostedZoneId': self.hosted_zone_id, }, 'Failover': 'SECONDARY' if self.target_name else 'PRIMARY', 'Name': self.fqdn, 'SetIdentifier': self.identifer, 'Type': self._type, } } def __hash__(self): return '{}:{}:{}'.format(self.fqdn, self._type, self.identifer).__hash__() def __repr__(self): return '_Route53DynamicPool<{} {} {} {}>' \ .format(self.fqdn, self._type, self.mode, self.target_dns_name) class _Route53DynamicRule(_Route53Record): def __init__(self, provider, hosted_zone_id, record, pool_name, index, creating, geo=None): super(_Route53DynamicRule, self).__init__(provider, record, creating) self.hosted_zone_id = hosted_zone_id self.geo = geo self.pool_name = pool_name self.index = index self.target_dns_name = '_octodns-{}-pool.{}'.format(pool_name, record.fqdn) @property def identifer(self): return '{}-{}-{}'.format(self.index, self.pool_name, self.geo) def mod(self, action, existing_rrsets): rrset = { 'AliasTarget': { 'DNSName': self.target_dns_name, 'EvaluateTargetHealth': True, 'HostedZoneId': self.hosted_zone_id, }, 'GeoLocation': { 'CountryCode': '*' }, 'Name': self.fqdn, 'SetIdentifier': self.identifer, 'Type': self._type, } if self.geo: geo = GeoCodes.parse(self.geo) if geo['province_code']: rrset['GeoLocation'] = { 'CountryCode': geo['country_code'], 'SubdivisionCode': geo['province_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.identifer).__hash__() def __repr__(self): return '_Route53DynamicRule<{} {} {} {} {}>' \ .format(self.fqdn, self._type, self.index, self.geo, self.target_dns_name) class _Route53DynamicValue(_Route53Record): def __init__(self, provider, record, pool_name, value, weight, index, creating): fqdn_override = '_octodns-{}-value.{}'.format(pool_name, record.fqdn) super(_Route53DynamicValue, self).__init__(provider, record, creating, fqdn_override=fqdn_override) self.pool_name = pool_name self.index = index value_convert = getattr(self, '_value_convert_{}'.format(record._type)) self.value = value_convert(value, record) self.weight = weight self.health_check_id = provider.get_health_check_id(record, self.value, creating) @property def identifer(self): return '{}-{:03d}'.format(self.pool_name, self.index) def mod(self, action, existing_rrsets): if action == 'DELETE': # When deleting records try and find the original rrset so that # we're 100% sure to have the complete & accurate data (this mostly # ensures we have the right health check id when there's multiple # potential matches) for existing in existing_rrsets: if self.fqdn == existing.get('Name') and \ self.identifer == existing.get('SetIdentifier', None): return { 'Action': action, 'ResourceRecordSet': existing, } return { 'Action': action, 'ResourceRecordSet': { 'HealthCheckId': self.health_check_id, 'Name': self.fqdn, 'ResourceRecords': [{'Value': self.value}], 'SetIdentifier': self.identifer, 'TTL': self.ttl, 'Type': self._type, 'Weight': self.weight, } } def __hash__(self): return '{}:{}:{}'.format(self.fqdn, self._type, self.identifer).__hash__() def __repr__(self): return '_Route53DynamicValue<{} {} {} {}>' \ .format(self.fqdn, self._type, self.identifer, self.value) class _Route53GeoDefault(_Route53Record): def mod(self, action, existing_rrsets): 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 value = geo.values[0] self.health_check_id = provider.get_health_check_id(record, value, creating) def mod(self, action, existing_rrsets): geo = self.geo set_identifier = geo.code fqdn = self.fqdn if action == 'DELETE': # When deleting records try and find the original rrset so that # we're 100% sure to have the complete & accurate data (this mostly # ensures we have the right health check id when there's multiple # potential matches) for existing in existing_rrsets: if fqdn == existing.get('Name') and \ set_identifier == existing.get('SetIdentifier', None): return { 'Action': action, 'ResourceRecordSet': existing, } rrset = { 'Name': self.fqdn, 'GeoLocation': { 'CountryCode': '*' }, 'ResourceRecords': [{'Value': v} for v in geo.values], 'SetIdentifier': set_identifier, '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 _equality_tuple(self): return super(_Route53GeoRecord, self)._equality_tuple() + \ (self.geo.code,) def __repr__(self): return '_Route53GeoRecord<{} {} {} {} {}>'.format(self.fqdn, self._type, self.ttl, self.geo.code, self.values) def _mod_keyer(mod): rrset = mod['ResourceRecordSet'] # Route53 requires that changes are ordered such that a target of an # AliasTarget is created or upserted prior to the record that targets it. # This is complicated by "UPSERT" appearing to be implemented as "DELETE" # before all changes, followed by a "CREATE", internally in the AWS API. # Because of this, we order changes as follows: # - Delete any records that we wish to delete that are GEOS # (because they are never targeted by anything) # - Delete any records that we wish to delete that are SECONDARY # (because they are no longer targeted by GEOS) # - Delete any records that we wish to delete that are PRIMARY # (because they are no longer targeted by SECONDARY) # - Delete any records that we wish to delete that are VALUES # (because they are no longer targeted by PRIMARY) # - CREATE/UPSERT any records that are VALUES # (because they don't depend on other records) # - CREATE/UPSERT any records that are PRIMARY # (because they always point to VALUES which now exist) # - CREATE/UPSERT any records that are SECONDARY # (because they now have PRIMARY records to target) # - CREATE/UPSERT any records that are GEOS # (because they now have all their PRIMARY pools to target) # - :tada: # # In theory we could also do this based on actual target reference # checking, but that's more complex. Since our rules have a known # dependency order, we just rely on that. # Get the unique ID from the name/id to get a consistent ordering. if rrset.get('GeoLocation', False): unique_id = rrset['SetIdentifier'] else: if 'SetIdentifier' in rrset: unique_id = '{}-{}'.format(rrset['Name'], rrset['SetIdentifier']) else: unique_id = rrset['Name'] # Prioritise within the action_priority, ensuring targets come first. if rrset.get('GeoLocation', False): # Geos reference pools, so they come last. record_priority = 3 elif rrset.get('AliasTarget', False): # We use an alias if rrset.get('Failover', False) == 'SECONDARY': # We're a secondary, which reference the primary (failover, P1). record_priority = 2 else: # We're a primary, we reference values (P0). record_priority = 1 else: # We're just a plain value, has no dependencies so first. record_priority = 0 if mod['Action'] == 'DELETE': # Delete things first, so we can never trounce our own additions action_priority = 0 # Delete in the reverse order of priority, e.g. start with the deepest # reference and work back to the values, rather than starting at the # values (still ref'd). record_priority = -record_priority else: # For CREATE and UPSERT, Route53 seems to treat them the same, so # interleave these, keeping the reference order described above. action_priority = 1 return (action_priority, record_priority, unique_id) def _parse_pool_name(n): # Parse the pool name out of _octodns--pool... return n.split('.', 1)[0][9:-5] class Route53Provider(BaseProvider): ''' AWS Route53 Provider route53: class: octodns.provider.route53.Route53Provider # The AWS access key id access_key_id: # The AWS secret access key secret_access_key: # The AWS session token (optional) # Only needed if using temporary security credentials session_token: Alternatively, you may leave out access_key_id, secret_access_key and session_token. This will result in boto3 deciding authentication dynamically. In general the account used will need full permissions on Route53. ''' SUPPORTS_GEO = True SUPPORTS_DYNAMIC = 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 = '0001' def __init__(self, id, access_key_id=None, secret_access_key=None, max_changes=1000, client_max_attempts=None, session_token=None, delegation_set_id=None, *args, **kwargs): self.max_changes = max_changes self.delegation_set_id = delegation_set_id _msg = 'access_key_id={}, secret_access_key=***, ' \ 'session_token=***'.format(access_key_id) use_fallback_auth = access_key_id is None and \ secret_access_key is None and session_token is None if use_fallback_auth: _msg = 'auth=fallback' self.log = logging.getLogger('Route53Provider[{}]'.format(id)) self.log.debug('__init__: id=%s, %s', id, _msg) 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}) if use_fallback_auth: self._conn = client('route53', config=config) else: self._conn = client('route53', aws_access_key_id=access_key_id, aws_secret_access_key=secret_access_key, aws_session_token=session_token, 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 del_set = self.delegation_set_id self.log.debug('_get_zone_id: no matching zone, creating, ' 'ref=%s', ref) if del_set: resp = self._conn.create_hosted_zone(Name=name, CallerReference=ref, DelegationSetId=del_set) else: 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 = country_alpha2_to_continent_code(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'(? 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, lenient=lenient) self.log.info('populate: found %s records, exists=%s', len(zone.records) - before, exists) return exists def _gen_mods(self, action, records, existing_rrsets): ''' Turns `_Route53*`s in to `change_resource_record_sets` `Changes` ''' return [r.mod(action, existing_rrsets) 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 _healthcheck_measure_latency(self, record): return record._octodns.get('route53', {}) \ .get('healthcheck', {}) \ .get('measure_latency', True) def _health_check_equivalent(self, host, path, protocol, port, measure_latency, health_check, value=None): config = health_check['HealthCheckConfig'] # So interestingly Route53 normalizes IPAddress which will cause us to # fail to find see things as equivalent. To work around this we'll # ip_address's returned object for equivalence # E.g 2001:4860:4860::8842 -> 2001:4860:4860:0:0:0:0:8842 if value: value = ip_address(text_type(value)) config_ip_address = ip_address(text_type(config['IPAddress'])) else: # No value so give this a None to match value's config_ip_address = None return host == config['FullyQualifiedDomainName'] and \ path == config['ResourcePath'] and protocol == config['Type'] \ and port == config['Port'] and \ measure_latency == config['MeasureLatency'] and \ value == config_ip_address def get_health_check_id(self, record, value, 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 self.log.debug('get_health_check_id: fqdn=%s, type=%s, value=%s', fqdn, record._type, value) try: ip_address(text_type(value)) # We're working with an IP, host is the Host header healthcheck_host = record.healthcheck_host except (AddressValueError, ValueError): # This isn't an IP, host is the value, value should be None healthcheck_host = value value = None healthcheck_path = record.healthcheck_path healthcheck_protocol = record.healthcheck_protocol healthcheck_port = record.healthcheck_port healthcheck_latency = self._healthcheck_measure_latency(record) # we're looking for a healthcheck with the current version & our record # type, we'll ignore anything else expected_ref = '{}:{}:{}:'.format(self.HEALTH_CHECK_VERSION, record._type, record.fqdn) for id, health_check in self.health_checks.items(): if not health_check['CallerReference'].startswith(expected_ref): # not match, ignore continue if self._health_check_equivalent(healthcheck_host, healthcheck_path, healthcheck_protocol, healthcheck_port, healthcheck_latency, health_check, value=value): # this is the health check we're looking for self.log.debug('get_health_check_id: found match id=%s', id) return id if not create: # no existing matches and not allowed to create, return none self.log.debug('get_health_check_id: no matches, no create') return # no existing matches, we need to create a new health check config = { 'EnableSNI': healthcheck_protocol == 'HTTPS', 'FailureThreshold': 6, 'FullyQualifiedDomainName': healthcheck_host, 'MeasureLatency': healthcheck_latency, 'Port': healthcheck_port, 'RequestInterval': 10, 'ResourcePath': healthcheck_path, 'Type': healthcheck_protocol, } if value: config['IPAddress'] = value ref = '{}:{}:{}:{}'.format(self.HEALTH_CHECK_VERSION, record._type, record.fqdn, uuid4().hex[:12]) resp = self._conn.create_health_check(CallerReference=ref, HealthCheckConfig=config) health_check = resp['HealthCheck'] id = health_check['Id'] # Set a Name for the benefit of the UI name = '{}:{} - {}'.format(record.fqdn, record._type, value or healthcheck_host) self._conn.change_tags_for_resource(ResourceType='healthcheck', ResourceId=id, AddTags=[{ 'Key': 'Name', 'Value': name, }]) # Manually add it to our cache health_check['Tags'] = { 'Name': name } # 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, ' 'path=%s, protocol=%s, port=%d, measure_latency=%r, ' 'value=%s', id, healthcheck_host, healthcheck_path, healthcheck_protocol, healthcheck_port, healthcheck_latency, value) return id def _gc_health_checks(self, record, new): if record._type not in ('A', 'AAAA'): return 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 expected_re = re.compile(r'^\d\d\d\d:{}:{}:' .format(record._type, record.fqdn)) # UNITL 1.0: we'll clean out the previous version of Route53 health # checks as best as we can. expected_legacy_host = record.fqdn[:-1] expected_legacy = '0000:{}:'.format(record._type) for id, health_check in self.health_checks.items(): ref = health_check['CallerReference'] if expected_re.match(ref) and id not in in_use: # this is a health check for this record, 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) elif ref.startswith(expected_legacy): config = health_check['HealthCheckConfig'] if expected_legacy_host == config['FullyQualifiedDomainName']: self.log.info('_gc_health_checks: deleting legacy id=%s', id) self._conn.delete_health_check(HealthCheckId=id) def _gen_records(self, record, zone_id, creating=False): ''' Turns an octodns.Record into one or more `_Route53*`s ''' return _Route53Record.new(self, record, zone_id, creating) def _mod_Create(self, change, zone_id, existing_rrsets): # New is the stuff that needs to be created new_records = self._gen_records(change.new, zone_id, 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, existing_rrsets) def _mod_Update(self, change, zone_id, existing_rrsets): # See comments in _Route53Record for how the set math is made to do our # bidding here. existing_records = self._gen_records(change.existing, zone_id, creating=False) new_records = self._gen_records(change.new, zone_id, 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, existing_rrsets) + \ self._gen_mods('CREATE', creates, existing_rrsets) + \ self._gen_mods('UPSERT', upserts, existing_rrsets) def _mod_Delete(self, change, zone_id, existing_rrsets): # Existing is the thing that needs to be deleted existing_records = self._gen_records(change.existing, zone_id, 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, existing_rrsets) def _extra_changes_update_needed(self, record, rrset): healthcheck_host = record.healthcheck_host healthcheck_path = record.healthcheck_path healthcheck_protocol = record.healthcheck_protocol healthcheck_port = record.healthcheck_port healthcheck_latency = self._healthcheck_measure_latency(record) try: health_check_id = rrset['HealthCheckId'] health_check = self.health_checks[health_check_id] caller_ref = health_check['CallerReference'] if caller_ref.startswith(self.HEALTH_CHECK_VERSION): if self._health_check_equivalent(healthcheck_host, healthcheck_path, healthcheck_protocol, healthcheck_port, healthcheck_latency, health_check): # it has the right health check return False except (IndexError, 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.info('_extra_changes_update_needed: health-check caused ' 'update of %s:%s', record.fqdn, record._type) return True def _extra_changes_geo_needs_update(self, zone_id, record): # 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 or other mismatch self.log.debug('_extra_changes_geo_needs_update: 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'] and record._type == rrset['Type'] and \ rrset.get('GeoLocation', {}).get('CountryCode', False) != '*' \ and self._extra_changes_update_needed(record, rrset): # no good, doesn't have the right health check, needs an update self.log.info('_extra_changes_geo_needs_update: health-check ' 'caused update of %s:%s', record.fqdn, record._type) return True return False def _extra_changes_dynamic_needs_update(self, zone_id, record): # OK this is a record we don't have change for that does have dynamic # information. We need to look and see if it needs to be updated b/c of # a health check version bump or other mismatch self.log.debug('_extra_changes_dynamic_needs_update: inspecting=%s, ' '%s', record.fqdn, record._type) fqdn = record.fqdn _type = record._type # loop through all the r53 rrsets for rrset in self._load_records(zone_id): name = rrset['Name'] # Break off the first piece of the name, it'll let us figure out if # this is an rrset we're interested in. maybe_meta, rest = name.split('.', 1) if not maybe_meta.startswith('_octodns-') or \ not maybe_meta.endswith('-value') or \ '-default-' in name: # We're only interested in non-default dynamic value records, # as that's where healthchecks live continue if rest != fqdn or _type != rrset['Type']: # rrset isn't for the current record continue if self._extra_changes_update_needed(record, rrset): # no good, doesn't have the right health check, needs an update self.log.info('_extra_changes_dynamic_needs_update: ' 'health-check caused update of %s:%s', record.fqdn, record._type) return True return False def _extra_changes(self, desired, changes, **kwargs): self.log.debug('_extra_changes: desired=%s', desired.name) zone_id = self._get_zone_id(desired.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 desired records extras = [] for record in desired.records: if record in changed: # already have a change for it, skipping continue if getattr(record, 'geo', False): if self._extra_changes_geo_needs_update(zone_id, record): extras.append(Update(record, record)) elif getattr(record, 'dynamic', False): if self._extra_changes_dynamic_needs_update(zone_id, record): extras.append(Update(record, record)) return extras 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) existing_rrsets = self._load_records(zone_id) for c in changes: # Generate the mods for this change mod_type = getattr(self, '_mod_{}'.format(c.__class__.__name__)) mods = mod_type(c, zone_id, existing_rrsets) # Order our mods to make sure targets exist before alises point to # them and we CRUD in the desired order mods.sort(key=_mod_keyer) mods_rs_count = sum( [len(m['ResourceRecordSet'].get('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 leftovers 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'])