From f8240f0cbdc491bd90737b8866b2e390a13223f3 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Mon, 3 Jan 2022 09:24:50 -0800 Subject: [PATCH] extract Route53Provider and AwsAcmMangingProcessor into their own module --- CHANGELOG.md | 6 + octodns/processor/awsacm.py | 48 +- octodns/provider/route53.py | 1551 +---------- tests/test_octodns_processor_awsacm.py | 62 +- tests/test_octodns_provider_route53.py | 3528 +----------------------- 5 files changed, 41 insertions(+), 5154 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 004936c..0c9b7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ #### Noteworthy changes +* Provider extraction has begun, see + https://github.com/octodns/octodns/issues/622 & + https://github.com/octodns/octodns/pull/822 for more information. Providers + that have been extracted in this release include: + * [Route53Provider](https://github.com/octodns/octodns-route53/) also + AwsAcmMangingProcessor * NS1 provider has received improvements to the dynamic record implementation. As a result, if octoDNS is downgraded from this version, any dynamic records created or updated using this version will show an update. diff --git a/octodns/processor/awsacm.py b/octodns/processor/awsacm.py index d036aba..49c1c06 100644 --- a/octodns/processor/awsacm.py +++ b/octodns/processor/awsacm.py @@ -7,38 +7,16 @@ from __future__ import absolute_import, division, print_function, \ from logging import getLogger -from .base import BaseProcessor - - -class AwsAcmMangingProcessor(BaseProcessor): - ''' - processors: - awsacm: - class: octodns.processor.acme.AwsAcmMangingProcessor - - ... - - zones: - something.com.: - ... - processors: - - awsacm - ... - ''' - - log = getLogger('AwsAcmMangingProcessor') - - def _ignore_awsacm_cnames(self, zone): - for r in zone.records: - if r._type == 'CNAME' and \ - r.name.startswith('_') \ - and r.value.endswith('.acm-validations.aws.'): - self.log.info('_process: ignoring %s', r.fqdn) - zone.remove_record(r) - return zone - - def process_source_zone(self, desired, *args, **kwargs): - return self._ignore_awsacm_cnames(desired) - - def process_target_zone(self, existing, *args, **kwargs): - return self._ignore_awsacm_cnames(existing) +logger = getLogger('Route53') +try: + logger.warn('octodns_route53 shimmed. Update your processor class to ' + 'octodns_route53.processor.AwsAcmMangingProcessor. ' + 'Shim will be removed in 1.0') + from octodns_route53.processor import AwsAcmMangingProcessor + AwsAcmMangingProcessor # pragma: no cover +except ModuleNotFoundError: + logger.exception('AwsAcmMangingProcessor has been moved into a seperate ' + 'module, octodns_route53 is now required. Processor ' + 'class should be updated to ' + 'octodns_route53.processor.AwsAcmMangingProcessor') + raise diff --git a/octodns/provider/route53.py b/octodns/provider/route53.py index 3359f3e..daef8ea 100644 --- a/octodns/provider/route53.py +++ b/octodns/provider/route53.py @@ -5,1540 +5,17 @@ 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 ..equality import EqualityTupleMixin -from ..record import Record, Update -from ..record.geo import GeoCodes -from . import ProviderException -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, - f'_octodns-default-pool.{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'] - status = value['status'] - value = value['value'] - ret.add(_Route53DynamicValue(provider, record, pool_name, - value, weight, status, 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, f'_values_for_{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 f'{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<{self.fqdn} {self._type} {self.ttl} ' \ - f'{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 f'{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 f'{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): - flags = value.flags if value.flags else '' - service = value.service if value.service else '' - regexp = value.regexp if value.regexp else '' - return f'{value.order} {value.preference} "{flags}" "{service}" ' \ - f'"{regexp}" {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 f'{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 = f'_octodns-{pool_name}-pool.{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 = f'_octodns-{target_name}-pool.{record.fqdn}' - else: - # We're a paimary, point at our values - self.target_dns_name = f'_octodns-{pool_name}-value.{record.fqdn}' - - @property - def mode(self): - return 'Secondary' if self.target_name else 'Primary' - - @property - def identifer(self): - if self.target_name: - return f'{self.pool_name}-{self.mode}-{self.target_name}' - return f'{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 f'{self.fqdn}:{self._type}:{self.identifer}'.__hash__() - - def __repr__(self): - return f'_Route53DynamicPool<{self.fqdn} {self._type} {self.mode} ' \ - f'{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 = f'_octodns-{pool_name}-pool.{record.fqdn}' - - @property - def identifer(self): - return f'{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 f'{self.fqdn}:{self._type}:{self.identifer}'.__hash__() - - def __repr__(self): - return f'_Route53DynamicRule<{self.fqdn} {self._type} {self.index} ' \ - f'{self.geo} {self.target_dns_name}>' - - -class _Route53DynamicValue(_Route53Record): - - def __init__(self, provider, record, pool_name, value, weight, status, - index, creating): - fqdn_override = f'_octodns-{pool_name}-value.{record.fqdn}' - super(_Route53DynamicValue, self).__init__(provider, record, creating, - fqdn_override=fqdn_override) - - self.pool_name = pool_name - self.status = status - self.index = index - value_convert = getattr(self, f'_value_convert_{record._type}') - self.value = value_convert(value, record) - self.weight = weight - - self.health_check_id = provider.get_health_check_id(record, self.value, - self.status, - creating) - - @property - def identifer(self): - return f'{self.pool_name}-{self.index:03d}' - - 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, - } - - ret = { - 'Action': action, - 'ResourceRecordSet': { - 'Name': self.fqdn, - 'ResourceRecords': [{'Value': self.value}], - 'SetIdentifier': self.identifer, - 'TTL': self.ttl, - 'Type': self._type, - 'Weight': self.weight, - } - } - - if self.health_check_id: - ret['ResourceRecordSet']['HealthCheckId'] = self.health_check_id - - return ret - - def __hash__(self): - return f'{self.fqdn}:{self._type}:{self.identifer}'.__hash__() - - def __repr__(self): - return f'_Route53DynamicValue<{self.fqdn} {self._type} ' \ - f'{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 f'{self.fqdn}:{self._type}:default'.__hash__() - - def __repr__(self): - return f'_Route53GeoDefault<{self.fqdn} {self._type} {self.ttl} ' \ - f'{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, - 'obey', 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 f'{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 f'_Route53GeoRecord<{self.fqdn} {self._type} {self.ttl} ' \ - f'{self.geo.code} {self.values}>' - - -class Route53ProviderException(ProviderException): - pass - - -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 = f'{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_POOL_VALUE_STATUS = 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, - get_zones_by_name=False, *args, **kwargs): - self.max_changes = max_changes - self.delegation_set_id = delegation_set_id - self.get_zones_by_name = get_zones_by_name - _msg = f'access_key_id={access_key_id}, secret_access_key=***, ' \ - 'session_token=***' - 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(f'Route53Provider[{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 - - def _get_zone_id_by_name(self, name): - # attempt to get zone by name - # limited to one as this should be unique - id = None - resp = self._conn.list_hosted_zones_by_name( - DNSName=name, MaxItems="1" - ) - if len(resp['HostedZones']) != 0: - # if there is a response that starts with the name - if resp['HostedZones'][0]['Name'].startswith(name): - id = resp['HostedZones'][0]['Id'] - self.log.debug('get_zones_by_name: id=%s', id) - return id - - def update_r53_zones(self, name): - if self._r53_zones is None: - if self.get_zones_by_name: - id = self._get_zone_id_by_name(name) - zones = {} - zones[name] = id - self._r53_zones = zones - else: - 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 - else: - if name not in self._r53_zones and self.get_zones_by_name: - id = self._get_zone_id_by_name(name) - self._r53_zones[name] = id - - def _get_zone_id(self, name, create=False): - self.log.debug('_get_zone_id: name=%s', name) - self.update_r53_zones(name) - id = None - if name in self._r53_zones: - id = self._r53_zones[name] - self.log.debug('_get_zone_id: id=%s', id) - if create and not id: - 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 - - 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 f'{cn}-{cc}-{loc["SubdivisionCode"]}' - except KeyError: - return f'{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 _healthcheck_request_interval(self, record): - interval = record._octodns.get('route53', {}) \ - .get('healthcheck', {}) \ - .get('request_interval', 10) - if (interval in [10, 30]): - return interval - else: - raise Route53ProviderException( - 'route53.healthcheck.request_interval ' - 'parameter must be either 10 or 30.') - - def _health_check_equivalent(self, host, path, protocol, port, - measure_latency, request_interval, - health_check, value=None, disabled=None, - inverted=None): - config = health_check['HealthCheckConfig'] - - # So interestingly Route53 normalizes IPv6 addresses to a funky, but - # valid, form which will cause us to fail to find see things as - # equivalent. To work around this we'll ip_address's returned objects - # for equivalence. - # E.g 2001:4860:4860:0:0:0:0:8842 -> 2001:4860:4860::8842 - if value: - value = ip_address(str(value)) - config_ip_address = ip_address(str(config['IPAddress'])) - else: - # No value so give this a None to match value's - config_ip_address = None - - fully_qualified_domain_name = config.get('FullyQualifiedDomainName', - None) - resource_path = config.get('ResourcePath', None) - return host == fully_qualified_domain_name and \ - path == resource_path and protocol == config['Type'] and \ - port == config['Port'] and \ - measure_latency == config['MeasureLatency'] and \ - request_interval == config['RequestInterval'] and \ - (disabled is None or disabled == config['Disabled']) and \ - (inverted is None or inverted == config['Inverted']) and \ - value == config_ip_address - - def get_health_check_id(self, record, value, status, 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, ' - 'status=%s', fqdn, record._type, value, status) - - if status == 'up': - # status up means no health check - self.log.debug('get_health_check_id: status up, no health check') - return None - - try: - ip_address(str(value)) - # We're working with an IP, host is the Host header - healthcheck_host = record.healthcheck_host(value=value) - 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) - healthcheck_interval = self._healthcheck_request_interval(record) - if status == 'down': - healthcheck_disabled = True - healthcheck_inverted = True - else: # obey - healthcheck_disabled = False - healthcheck_inverted = False - - # we're looking for a healthcheck with the current version & our record - # type, we'll ignore anything else - expected_ref = \ - f'{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, - healthcheck_interval, - health_check, - value=value, - disabled=healthcheck_disabled, - inverted=healthcheck_inverted): - # 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 = { - 'Disabled': healthcheck_disabled, - 'Inverted': healthcheck_inverted, - 'EnableSNI': healthcheck_protocol == 'HTTPS', - 'FailureThreshold': 6, - 'MeasureLatency': healthcheck_latency, - 'Port': healthcheck_port, - 'RequestInterval': healthcheck_interval, - 'Type': healthcheck_protocol, - } - if healthcheck_protocol != 'TCP': - config['FullyQualifiedDomainName'] = healthcheck_host - config['ResourcePath'] = healthcheck_path - if value: - config['IPAddress'] = value - - ref = f'{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 - value_or_host = value or healthcheck_host - name = f'{record.fqdn}:{record._type} - {value_or_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, ' - 'request_interval=%d, value=%s', - id, healthcheck_host, healthcheck_path, - healthcheck_protocol, healthcheck_port, - healthcheck_latency, healthcheck_interval, 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(fr'^\d\d\d\d:{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 = f'0000:{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, statuses={}): - value = rrset['ResourceRecords'][0]['Value'] - if record._type == 'CNAME': - # For CNAME, healthcheck host by default points to the CNAME value - healthcheck_host = value - else: - 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) - healthcheck_interval = self._healthcheck_request_interval(record) - - status = statuses.get(value, 'obey') - if status == 'up': - if 'HealthCheckId' in rrset: - self.log.info('_extra_changes_update_needed: health-check ' - 'found for status="up", causing update of %s:%s', - record.fqdn, record._type) - return True - else: - # No health check needed - return False - - 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, - healthcheck_interval, - 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 - - # map values to statuses - statuses = {} - for pool in record.dynamic.pools.values(): - for value in pool.data['values']: - statuses[value['value']] = value.get('status', 'obey') - - # 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, statuses): - # 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 - klass = c.__class__.__name__ - mod_type = getattr(self, f'_mod_{klass}') - 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(f'Too many modifications: {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): - # Ensure this batch is ordered (deletes before creates etc.) - batch.sort(key=_mod_keyer) - uuid = uuid4().hex - batch = { - 'Comment': f'Change: {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']) +from logging import getLogger + +logger = getLogger('Route53') +try: + logger.warn('octodns_route53 shimmed. Update your provider class to ' + 'octodns_route53.Route53Provider. ' + 'Shim will be removed in 1.0') + from octodns_route53 import Route53Provider + Route53Provider # pragma: no cover +except ModuleNotFoundError: + logger.exception('Route53Provider has been moved into a seperate module, ' + 'octodns_route53 is now required. Provider class should ' + 'be updated to octodns_route53.Route53Provider') + raise diff --git a/tests/test_octodns_processor_awsacm.py b/tests/test_octodns_processor_awsacm.py index e184755..3a99447 100644 --- a/tests/test_octodns_processor_awsacm.py +++ b/tests/test_octodns_processor_awsacm.py @@ -7,64 +7,10 @@ from __future__ import absolute_import, division, print_function, \ from unittest import TestCase -from octodns.processor.awsacm import AwsAcmMangingProcessor -from octodns.record import Record -from octodns.zone import Zone - -zone = Zone('unit.tests.', []) -records = { - 'root': Record.new(zone, '_deadbeef', { - 'ttl': 30, - 'type': 'CNAME', - 'value': '_0123456789abcdef.acm-validations.aws.', - }), - 'sub': Record.new(zone, '_deadbeef.sub', { - 'ttl': 30, - 'type': 'CNAME', - 'value': '_0123456789abcdef.acm-validations.aws.', - }), - 'not-cname': Record.new(zone, '_deadbeef.not-cname', { - 'ttl': 30, - 'type': 'AAAA', - 'value': '::1', - }), - 'not-acm': Record.new(zone, '_not-acm', { - 'ttl': 30, - 'type': 'CNAME', - 'value': 'localhost.unit.tests.', - }), -} - class TestAwsAcmMangingProcessor(TestCase): - def test_process_zones(self): - acm = AwsAcmMangingProcessor('acm') - - source = Zone(zone.name, []) - # Unrelated stuff that should be untouched - source.add_record(records['not-cname']) - source.add_record(records['not-acm']) - # ACM records that should be ignored - source.add_record(records['root']) - source.add_record(records['sub']) - - got = acm.process_source_zone(source) - self.assertEqual([ - '_deadbeef.not-cname', - '_not-acm', - ], sorted([r.name for r in got.records])) - - existing = Zone(zone.name, []) - # Unrelated stuff that should be untouched - existing.add_record(records['not-cname']) - existing.add_record(records['not-acm']) - # Stuff that will be ignored - existing.add_record(records['root']) - existing.add_record(records['sub']) - - got = acm.process_target_zone(existing) - self.assertEqual([ - '_deadbeef.not-cname', - '_not-acm' - ], sorted([r.name for r in got.records])) + def test_missing(self): + with self.assertRaises(ModuleNotFoundError): + from octodns.processor.awsacm import AwsAcmMangingProcessor + AwsAcmMangingProcessor diff --git a/tests/test_octodns_provider_route53.py b/tests/test_octodns_provider_route53.py index 680c9ce..23e5748 100644 --- a/tests/test_octodns_provider_route53.py +++ b/tests/test_octodns_provider_route53.py @@ -5,3532 +5,12 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from botocore.exceptions import ClientError -from botocore.stub import ANY, Stubber from unittest import TestCase -from mock import patch - -from octodns.record import Create, Delete, Record, Update -from octodns.provider.route53 import Route53Provider, _Route53DynamicValue, \ - _Route53GeoDefault, _Route53GeoRecord, Route53ProviderException, \ - _Route53Record, _mod_keyer, _octal_replace -from octodns.zone import Zone - -from helpers import GeoProvider - - -class DummyR53Record(object): - - def __init__(self, health_check_id): - self.health_check_id = health_check_id - - -class TestOctalReplace(TestCase): - - def test_basic(self): - for expected, s in ( - ('', ''), - ('abc', 'abc'), - ('123', '123'), - ('abc123', 'abc123'), - ('*', '\\052'), - ('abc*', 'abc\\052'), - ('*abc', '\\052abc'), - ('123*', '123\\052'), - ('*123', '\\052123'), - ('**', '\\052\\052'), - ): - self.assertEquals(expected, _octal_replace(s)) - - -dynamic_rrsets = [{ - 'Name': '_octodns-default-pool.unit.tests.', - 'ResourceRecords': [{'Value': '1.1.2.1'}, - {'Value': '1.1.2.2'}], - 'TTL': 60, - 'Type': 'A', -}, { - 'HealthCheckId': '76', - 'Name': '_octodns-ap-southeast-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.4.1.1'}], - 'SetIdentifier': 'ap-southeast-1-000', - 'TTL': 60, - 'Type': 'A', - 'Weight': 2 -}, { - 'Name': '_octodns-ap-southeast-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.4.1.2'}], - 'SetIdentifier': 'ap-southeast-1-001', - 'TTL': 60, - 'Type': 'A', - 'Weight': 2 -}, { - 'HealthCheckId': 'ab', - 'Name': '_octodns-eu-central-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.3.1.1'}], - 'SetIdentifier': 'eu-central-1-000', - 'TTL': 60, - 'Type': 'A', - 'Weight': 1 -}, { - 'HealthCheckId': '1e', - 'Name': '_octodns-eu-central-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.3.1.2'}], - 'SetIdentifier': 'eu-central-1-001', - 'TTL': 60, - 'Type': 'A', - 'Weight': 1 -}, { - 'HealthCheckId': '2a', - 'Name': '_octodns-us-east-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.5.1.1'}], - 'SetIdentifier': 'us-east-1-000', - 'TTL': 60, - 'Type': 'A', - 'Weight': 1 -}, { - 'HealthCheckId': '61', - 'Name': '_octodns-us-east-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.5.1.2'}], - 'SetIdentifier': 'us-east-1-001', - 'TTL': 60, - 'Type': 'A', - 'Weight': 1, -}, { - 'AliasTarget': {'DNSName': '_octodns-default-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2'}, - 'Failover': 'SECONDARY', - 'Name': '_octodns-us-east-1-pool.unit.tests.', - 'SetIdentifier': 'us-east-1-Secondary-default', - 'Type': 'A' -}, { - 'AliasTarget': { - 'DNSName': '_octodns-us-east-1-value.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2' - }, - 'Failover': 'PRIMARY', - 'Name': '_octodns-us-east-1-pool.unit.tests.', - 'SetIdentifier': 'us-east-1-Primary', - 'Type': 'A', -}, { - 'AliasTarget': {'DNSName': '_octodns-us-east-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2'}, - 'Failover': 'SECONDARY', - 'Name': '_octodns-eu-central-1-pool.unit.tests.', - 'SetIdentifier': 'eu-central-1-Secondary-default', - 'Type': 'A' -}, { - 'AliasTarget': { - 'DNSName': '_octodns-eu-central-1-value.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2' - }, - 'Failover': 'PRIMARY', - 'Name': '_octodns-eu-central-1-pool.unit.tests.', - 'SetIdentifier': 'eu-central-1-Primary', - 'Type': 'A', -}, { - 'AliasTarget': {'DNSName': '_octodns-us-east-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2'}, - 'Failover': 'SECONDARY', - 'Name': '_octodns-ap-southeast-1-pool.unit.tests.', - 'SetIdentifier': 'ap-southeast-1-Secondary-default', - 'Type': 'A' -}, { - 'AliasTarget': { - 'DNSName': '_octodns-ap-southeast-1-value.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2' - }, - 'Failover': 'PRIMARY', - 'Name': '_octodns-ap-southeast-1-pool.unit.tests.', - 'SetIdentifier': 'ap-southeast-1-Primary', - 'Type': 'A', -}, { - 'AliasTarget': {'DNSName': '_octodns-ap-southeast-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2'}, - 'GeoLocation': {'CountryCode': 'JP'}, - 'Name': 'unit.tests.', - 'SetIdentifier': '1-ap-southeast-1-AS-JP', - 'Type': 'A', -}, { - 'AliasTarget': {'DNSName': '_octodns-ap-southeast-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2'}, - 'GeoLocation': {'CountryCode': 'CN'}, - 'Name': 'unit.tests.', - 'SetIdentifier': '1-ap-southeast-1-AS-CN', - 'Type': 'A', -}, { - 'AliasTarget': {'DNSName': '_octodns-eu-central-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2'}, - 'GeoLocation': {'ContinentCode': 'NA-US-FL'}, - 'Name': 'unit.tests.', - 'SetIdentifier': '2-eu-central-1-NA-US-FL', - 'Type': 'A', -}, { - 'AliasTarget': {'DNSName': '_octodns-eu-central-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2'}, - 'GeoLocation': {'ContinentCode': 'EU'}, - 'Name': 'unit.tests.', - 'SetIdentifier': '2-eu-central-1-EU', - 'Type': 'A', -}, { - 'AliasTarget': {'DNSName': '_octodns-us-east-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'Z2'}, - 'GeoLocation': {'CountryCode': '*'}, - 'Name': 'unit.tests.', - 'SetIdentifier': '3-us-east-1-None', - 'Type': 'A', -}] -dynamic_health_checks = { - '76': { - 'HealthCheckConfig': { - 'Disabled': False, - 'Inverted': False, - } - }, - 'ab': { - 'HealthCheckConfig': { - 'Disabled': True, - 'Inverted': True, - } - }, -} - -dynamic_record_data = { - 'dynamic': { - 'pools': { - 'ap-southeast-1': { - 'fallback': 'us-east-1', - 'values': [{ - 'weight': 2, 'value': '1.4.1.1', 'status': 'obey', - }, { - 'weight': 2, 'value': '1.4.1.2', 'status': 'up', - }] - }, - 'eu-central-1': { - 'fallback': 'us-east-1', - 'values': [{ - 'weight': 1, 'value': '1.3.1.1', 'status': 'down', - }, { - 'weight': 1, 'value': '1.3.1.2', 'status': 'up', - }], - }, - 'us-east-1': { - 'values': [{ - 'weight': 1, 'value': '1.5.1.1', 'status': 'up', - }, { - 'weight': 1, 'value': '1.5.1.2', 'status': 'up', - }], - } - }, - 'rules': [{ - 'geos': ['AS-CN', 'AS-JP'], - 'pool': 'ap-southeast-1', - }, { - 'geos': ['EU', 'NA-US-FL'], - 'pool': 'eu-central-1', - }, { - 'pool': 'us-east-1', - }], - }, - 'ttl': 60, - 'type': 'A', - 'values': [ - '1.1.2.1', - '1.1.2.2', - ], -} class TestRoute53Provider(TestCase): - expected = Zone('unit.tests.', []) - for name, data in ( - ('simple', - {'ttl': 60, 'type': 'A', 'values': ['1.2.3.4', '2.2.3.4']}), - ('', - {'ttl': 61, 'type': 'A', 'values': ['2.2.3.4', '3.2.3.4'], - 'geo': { - 'AF': ['4.2.3.4'], - 'NA-US': ['5.2.3.4', '6.2.3.4'], - 'NA-US-CA': ['7.2.3.4']}}), - ('cname', {'ttl': 62, 'type': 'CNAME', 'value': 'unit.tests.'}), - ('txt', {'ttl': 63, 'type': 'TXT', 'values': ['Hello World!', - 'Goodbye World?']}), - ('', {'ttl': 64, 'type': 'MX', - 'values': [{ - 'preference': 10, - 'exchange': 'smtp-1.unit.tests.', - }, { - 'preference': 20, - 'exchange': 'smtp-2.unit.tests.', - }]}), - ('naptr', {'ttl': 65, 'type': 'NAPTR', - 'value': { - 'order': 10, - 'preference': 20, - 'flags': 'U', - 'service': 'SIP+D2U', - 'regexp': '!^.*$!sip:info@bar.example.com!', - 'replacement': '.', - }}), - ('_srv._tcp', {'ttl': 66, 'type': 'SRV', 'value': { - 'priority': 10, - 'weight': 20, - 'port': 30, - 'target': 'cname.unit.tests.' - }}), - ('', - {'ttl': 67, 'type': 'NS', 'values': ['8.2.3.4.', '9.2.3.4.']}), - ('sub', - {'ttl': 68, 'type': 'NS', 'values': ['5.2.3.4.', '6.2.3.4.']}), - ('', - {'ttl': 69, 'type': 'CAA', 'value': { - 'flags': 0, - 'tag': 'issue', - 'value': 'ca.unit.tests' - }}), - ): - record = Record.new(expected, name, data) - expected.add_record(record) - - caller_ref = f'{Route53Provider.HEALTH_CHECK_VERSION}:A:unit.tests.:1324' - - health_checks = [{ - 'Id': '42', - 'CallerReference': caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '4.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }, { - 'Id': 'ignored-also', - 'CallerReference': 'something-else', - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '5.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 42, - }, { - 'Id': '43', - 'CallerReference': caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '5.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }, { - 'Id': '44', - 'CallerReference': caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '7.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }, { - 'Id': '45', - # won't match anything based on type - 'CallerReference': caller_ref.replace(':A:', ':AAAA:'), - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '7.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }] - - def _get_stubbed_provider(self): - provider = Route53Provider('test', 'abc', '123') - - # Use the stubber - stubber = Stubber(provider._conn) - stubber.activate() - - return (provider, stubber) - - def _get_stubbed_delegation_set_provider(self): - provider = Route53Provider('test', 'abc', '123', - delegation_set_id="ABCDEFG123456") - - # Use the stubber - stubber = Stubber(provider._conn) - stubber.activate() - - return (provider, stubber) - - def _get_stubbed_fallback_auth_provider(self): - provider = Route53Provider('test') - - # Use the stubber - stubber = Stubber(provider._conn) - stubber.activate() - - return (provider, stubber) - - # with fallback boto makes an unstubbed call to the 169. metadata api, this - # stubs that bit out - @patch('botocore.credentials.CredentialResolver.load_credentials') - def test_process_desired_zone(self, fetch_metadata_token_mock): - provider, stubber = self._get_stubbed_fallback_auth_provider() - fetch_metadata_token_mock.side_effect = [None] - - # No records, essentially a no-op - desired = Zone('unit.tests.', []) - got = provider._process_desired_zone(desired) - self.assertEquals(desired.records, got.records) - - # Record without any geos - desired = Zone('unit.tests.', []) - record = Record.new(desired, 'a', { - 'ttl': 30, - 'type': 'A', - 'value': '1.2.3.4', - 'dynamic': { - 'pools': { - 'one': { - 'values': [{ - 'value': '2.2.3.4', - }], - }, - }, - 'rules': [{ - 'pool': 'one', - }], - }, - }) - desired.add_record(record) - got = provider._process_desired_zone(desired) - self.assertEquals(desired.records, got.records) - self.assertEquals(1, len(list(got.records)[0].dynamic.rules)) - self.assertFalse('geos' in list(got.records)[0].dynamic.rules[0].data) - - # Record where all geos are supported - desired = Zone('unit.tests.', []) - record = Record.new(desired, 'a', { - 'ttl': 30, - 'type': 'A', - 'value': '1.2.3.4', - 'dynamic': { - 'pools': { - 'one': { - 'values': [{ - 'value': '1.2.3.4', - }], - }, - 'two': { - 'values': [{ - 'value': '2.2.3.4', - }], - }, - }, - 'rules': [{ - 'geos': ['EU', 'NA-US-OR'], - 'pool': 'two', - }, { - 'pool': 'one', - }], - }, - }) - desired.add_record(record) - got = provider._process_desired_zone(desired) - self.assertEquals(2, len(list(got.records)[0].dynamic.rules)) - self.assertEquals(['EU', 'NA-US-OR'], - list(got.records)[0].dynamic.rules[0].data['geos']) - self.assertFalse('geos' in list(got.records)[0].dynamic.rules[1].data) - - # Record with NA-CA-* only rule which is removed - desired = Zone('unit.tests.', []) - record = Record.new(desired, 'a', { - 'ttl': 30, - 'type': 'A', - 'value': '1.2.3.4', - 'dynamic': { - 'pools': { - 'one': { - 'values': [{ - 'value': '1.2.3.4', - }], - }, - 'two': { - 'values': [{ - 'value': '2.2.3.4', - }], - }, - }, - 'rules': [{ - 'geos': ['NA-CA-BC'], - 'pool': 'two', - }, { - 'pool': 'one', - }], - }, - }) - desired.add_record(record) - got = provider._process_desired_zone(desired) - self.assertEquals(1, len(list(got.records)[0].dynamic.rules)) - self.assertFalse('geos' in list(got.records)[0].dynamic.rules[0].data) - - # Record with NA-CA-* rule combined with other geos, filtered - desired = Zone('unit.tests.', []) - record = Record.new(desired, 'a', { - 'ttl': 30, - 'type': 'A', - 'value': '1.2.3.4', - 'dynamic': { - 'pools': { - 'one': { - 'values': [{ - 'value': '1.2.3.4', - }], - }, - 'two': { - 'values': [{ - 'value': '2.2.3.4', - }], - }, - }, - 'rules': [{ - 'geos': ['EU', 'NA-CA-NB', 'NA-US-OR'], - 'pool': 'two', - }, { - 'pool': 'one', - }], - }, - }) - desired.add_record(record) - got = provider._process_desired_zone(desired) - self.assertEquals(2, len(list(got.records)[0].dynamic.rules)) - self.assertEquals(['EU', 'NA-US-OR'], - list(got.records)[0].dynamic.rules[0].data['geos']) - self.assertFalse('geos' in list(got.records)[0].dynamic.rules[1].data) - - # with fallback boto makes an unstubbed call to the 169. metadata api, this - # stubs that bit out - @patch('botocore.credentials.CredentialResolver.load_credentials') - def test_populate_with_fallback(self, fetch_metadata_token_mock): - provider, stubber = self._get_stubbed_fallback_auth_provider() - fetch_metadata_token_mock.side_effect = [None] - - got = Zone('unit.tests.', []) - with self.assertRaises(ClientError): - stubber.add_client_error('list_hosted_zones') - provider.populate(got) - - def test_populate(self): - provider, stubber = self._get_stubbed_provider() - - got = Zone('unit.tests.', []) - with self.assertRaises(ClientError): - stubber.add_client_error('list_hosted_zones') - provider.populate(got) - - with self.assertRaises(ClientError): - list_hosted_zones_resp = { - 'HostedZones': [{ - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, - {}) - stubber.add_client_error('list_resource_record_sets', - expected_params={'HostedZoneId': u'z42'}) - provider.populate(got) - stubber.assert_no_pending_responses() - - # list_hosted_zones has been cached from now on so we don't have to - # worry about stubbing it - - list_resource_record_sets_resp_p1 = { - 'ResourceRecordSets': [{ - 'Name': 'simple.unit.tests.', - 'Type': 'A', - 'ResourceRecords': [{ - 'Value': '1.2.3.4', - }, { - 'Value': '2.2.3.4', - }], - 'TTL': 60, - }, { - 'Name': 'unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'CountryCode': '*', - }, - 'ResourceRecords': [{ - 'Value': '2.2.3.4', - }, { - 'Value': '3.2.3.4', - }], - 'TTL': 61, - }, { - 'Name': 'unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'ContinentCode': 'AF', - }, - 'ResourceRecords': [{ - 'Value': '4.2.3.4', - }], - 'TTL': 61, - }, { - 'Name': 'unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'CountryCode': 'US', - }, - 'ResourceRecords': [{ - 'Value': '5.2.3.4', - }, { - 'Value': '6.2.3.4', - }], - 'TTL': 61, - }, { - 'Name': 'unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'CountryCode': 'US', - 'SubdivisionCode': 'CA', - }, - 'ResourceRecords': [{ - 'Value': '7.2.3.4', - }], - 'TTL': 61, - }], - 'IsTruncated': True, - 'NextRecordName': 'next_name', - 'NextRecordType': 'next_type', - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp_p1, - {'HostedZoneId': 'z42'}) - - list_resource_record_sets_resp_p2 = { - 'ResourceRecordSets': [{ - 'Name': 'cname.unit.tests.', - 'Type': 'CNAME', - 'ResourceRecords': [{ - 'Value': 'unit.tests.', - }], - 'TTL': 62, - }, { - 'Name': 'txt.unit.tests.', - 'Type': 'TXT', - 'ResourceRecords': [{ - 'Value': '"Hello World!"', - }, { - 'Value': '"Goodbye World?"', - }], - 'TTL': 63, - }, { - 'Name': 'unit.tests.', - 'Type': 'MX', - 'ResourceRecords': [{ - 'Value': '10 smtp-1.unit.tests.', - }, { - 'Value': '20 smtp-2.unit.tests.', - }], - 'TTL': 64, - }, { - 'Name': 'naptr.unit.tests.', - 'Type': 'NAPTR', - 'ResourceRecords': [{ - 'Value': '10 20 "U" "SIP+D2U" ' - '"!^.*$!sip:info@bar.example.com!" .', - }], - 'TTL': 65, - }, { - 'Name': '_srv._tcp.unit.tests.', - 'Type': 'SRV', - 'ResourceRecords': [{ - 'Value': '10 20 30 cname.unit.tests.', - }], - 'TTL': 66, - }, { - 'Name': 'unit.tests.', - 'Type': 'NS', - 'ResourceRecords': [{ - 'Value': 'ns1.unit.tests.', - }], - 'TTL': 67, - }, { - 'Name': 'sub.unit.tests.', - 'Type': 'NS', - 'GeoLocation': { - 'ContinentCode': 'AF', - }, - 'ResourceRecords': [{ - 'Value': '5.2.3.4.', - }, { - 'Value': '6.2.3.4.', - }], - 'TTL': 68, - }, { - 'Name': 'soa.unit.tests.', - 'Type': 'SOA', - 'ResourceRecords': [{ - 'Value': 'ns1.unit.tests.', - }], - 'TTL': 69, - }, { - 'Name': 'unit.tests.', - 'Type': 'CAA', - 'ResourceRecords': [{ - 'Value': '0 issue "ca.unit.tests"', - }], - 'TTL': 69, - }, { - 'AliasTarget': { - 'HostedZoneId': 'Z119WBBTVP5WFX', - 'EvaluateTargetHealth': False, - 'DNSName': 'unit.tests.' - }, - 'Type': 'A', - 'Name': 'alias.unit.tests.' - }], - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp_p2, - {'HostedZoneId': 'z42', - 'StartRecordName': 'next_name', - 'StartRecordType': 'next_type'}) - - # Load everything - provider.populate(got) - # Make sure we got what we expected - changes = self.expected.changes(got, GeoProvider()) - self.assertEquals(0, len(changes)) - stubber.assert_no_pending_responses() - - # Populate a zone that doesn't exist - nonexistent = Zone('does.not.exist.', []) - provider.populate(nonexistent) - self.assertEquals(set(), nonexistent.records) - - def test_sync(self): - provider, stubber = self._get_stubbed_provider() - - list_hosted_zones_resp = { - 'HostedZones': [{ - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, - {}) - list_resource_record_sets_resp = { - 'ResourceRecordSets': [], - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp, - {'HostedZoneId': 'z42'}) - - plan = provider.plan(self.expected) - self.assertEquals(9, len(plan.changes)) - self.assertTrue(plan.exists) - for change in plan.changes: - self.assertIsInstance(change, Create) - stubber.assert_no_pending_responses() - - stubber.add_response('list_health_checks', - { - 'HealthChecks': self.health_checks, - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - stubber.add_response('change_resource_record_sets', - {'ChangeInfo': { - 'Id': 'id', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - }}, {'HostedZoneId': 'z42', 'ChangeBatch': ANY}) - - self.assertEquals(9, provider.apply(plan)) - stubber.assert_no_pending_responses() - - # Delete by monkey patching in a populate that includes an extra record - def add_extra_populate(existing, target, lenient): - for record in self.expected.records: - existing.add_record(record) - record = Record.new(existing, 'extra', - {'ttl': 99, 'type': 'A', - 'values': ['9.9.9.9']}) - existing.add_record(record) - - provider.populate = add_extra_populate - change_resource_record_sets_params = { - 'ChangeBatch': { - 'Changes': [{ - 'Action': 'DELETE', 'ResourceRecordSet': { - 'Name': 'extra.unit.tests.', - 'ResourceRecords': [{'Value': u'9.9.9.9'}], - 'TTL': 99, - 'Type': 'A' - }}], - u'Comment': ANY - }, - 'HostedZoneId': u'z42' - } - stubber.add_response('change_resource_record_sets', - {'ChangeInfo': { - 'Id': 'id', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - }}, change_resource_record_sets_params) - plan = provider.plan(self.expected) - self.assertEquals(1, len(plan.changes)) - self.assertIsInstance(plan.changes[0], Delete) - self.assertEquals(1, provider.apply(plan)) - stubber.assert_no_pending_responses() - - # Update by monkey patching in a populate that modifies the A record - # with geos - def mod_geo_populate(existing, target, lenient): - for record in self.expected.records: - if record._type != 'A' or not record.geo: - existing.add_record(record) - record = Record.new(existing, '', { - 'ttl': 61, - 'type': 'A', - 'values': ['8.2.3.4', '3.2.3.4'], - 'geo': { - 'AF': ['4.2.3.4'], - 'NA-US': ['5.2.3.4', '6.2.3.4'], - 'NA-US-KY': ['7.2.3.4'] - } - }) - existing.add_record(record) - - provider.populate = mod_geo_populate - change_resource_record_sets_params = { - 'ChangeBatch': { - 'Changes': [{ - 'Action': 'DELETE', - 'ResourceRecordSet': { - 'GeoLocation': {'CountryCode': 'US', - 'SubdivisionCode': 'KY'}, - 'HealthCheckId': u'44', - 'Name': 'unit.tests.', - 'ResourceRecords': [{'Value': '7.2.3.4'}], - 'SetIdentifier': 'NA-US-KY', - 'TTL': 61, - 'Type': 'A' - } - }, { - 'Action': 'UPSERT', - 'ResourceRecordSet': { - 'GeoLocation': {'ContinentCode': 'AF'}, - 'Name': 'unit.tests.', - 'HealthCheckId': u'42', - 'ResourceRecords': [{'Value': '4.2.3.4'}], - 'SetIdentifier': 'AF', - 'TTL': 61, - 'Type': 'A' - } - }, { - 'Action': 'UPSERT', - 'ResourceRecordSet': { - 'GeoLocation': {'CountryCode': 'US'}, - 'HealthCheckId': u'43', - 'Name': 'unit.tests.', - 'ResourceRecords': [{'Value': '5.2.3.4'}, - {'Value': '6.2.3.4'}], - 'SetIdentifier': 'NA-US', - 'TTL': 61, - 'Type': 'A' - } - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'GeoLocation': {'CountryCode': 'US', - 'SubdivisionCode': 'CA'}, - 'HealthCheckId': u'44', - 'Name': 'unit.tests.', - 'ResourceRecords': [{'Value': '7.2.3.4'}], - 'SetIdentifier': 'NA-US-CA', - 'TTL': 61, - 'Type': 'A' - } - }, { - 'Action': 'UPSERT', - 'ResourceRecordSet': { - 'GeoLocation': {'CountryCode': '*'}, - 'Name': 'unit.tests.', - 'ResourceRecords': [{'Value': '2.2.3.4'}, - {'Value': '3.2.3.4'}], - 'SetIdentifier': 'default', - 'TTL': 61, - 'Type': 'A' - } - }], - 'Comment': ANY - }, - 'HostedZoneId': 'z42' - } - stubber.add_response('change_resource_record_sets', - {'ChangeInfo': { - 'Id': 'id', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - }}, change_resource_record_sets_params) - plan = provider.plan(self.expected) - self.assertEquals(1, len(plan.changes)) - self.assertIsInstance(plan.changes[0], Update) - self.assertEquals(1, provider.apply(plan)) - stubber.assert_no_pending_responses() - - # Update converting to non-geo by monkey patching in a populate that - # modifies the A record with geos - def mod_add_geo_populate(existing, target, lenient): - for record in self.expected.records: - if record._type != 'A' or record.geo: - existing.add_record(record) - record = Record.new(existing, 'simple', { - 'ttl': 61, - 'type': 'A', - 'values': ['1.2.3.4', '2.2.3.4'], - 'geo': { - 'OC': ['3.2.3.4', '4.2.3.4'], - } - }) - existing.add_record(record) - - provider.populate = mod_add_geo_populate - change_resource_record_sets_params = { - 'ChangeBatch': { - 'Changes': [{ - 'Action': 'DELETE', - 'ResourceRecordSet': { - 'GeoLocation': {'ContinentCode': 'OC'}, - 'Name': 'simple.unit.tests.', - 'ResourceRecords': [{'Value': '3.2.3.4'}, - {'Value': '4.2.3.4'}], - 'SetIdentifier': 'OC', - 'TTL': 61, - 'Type': 'A'} - }, { - 'Action': 'DELETE', - 'ResourceRecordSet': { - 'GeoLocation': {'CountryCode': '*'}, - 'Name': 'simple.unit.tests.', - 'ResourceRecords': [{'Value': '1.2.3.4'}, - {'Value': '2.2.3.4'}], - 'SetIdentifier': 'default', - 'TTL': 61, - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'Name': 'simple.unit.tests.', - 'ResourceRecords': [{'Value': '1.2.3.4'}, - {'Value': '2.2.3.4'}], - 'TTL': 60, - 'Type': 'A'} - }], - 'Comment': ANY - }, - 'HostedZoneId': 'z42' - } - stubber.add_response('change_resource_record_sets', - {'ChangeInfo': { - 'Id': 'id', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - }}, change_resource_record_sets_params) - plan = provider.plan(self.expected) - self.assertEquals(1, len(plan.changes)) - self.assertIsInstance(plan.changes[0], Update) - self.assertEquals(1, provider.apply(plan)) - stubber.assert_no_pending_responses() - - def test_sync_create(self): - provider, stubber = self._get_stubbed_provider() - - got = Zone('unit.tests.', []) - - list_hosted_zones_resp = { - 'HostedZones': [], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, - {}) - - plan = provider.plan(self.expected) - self.assertEquals(9, len(plan.changes)) - self.assertFalse(plan.exists) - for change in plan.changes: - self.assertIsInstance(change, Create) - stubber.assert_no_pending_responses() - - create_hosted_zone_resp = { - 'HostedZone': { - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }, - 'ChangeInfo': { - 'Id': 'a12', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - 'Comment': 'hrm', - }, - 'DelegationSet': { - 'Id': 'b23', - 'CallerReference': 'blip', - 'NameServers': [ - 'n12.unit.tests.', - ], - }, - 'Location': 'us-east-1', - } - stubber.add_response('create_hosted_zone', - create_hosted_zone_resp, { - 'Name': got.name, - 'CallerReference': ANY, - }) - - list_resource_record_sets_resp = { - 'ResourceRecordSets': [{ - 'Name': 'a.unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'ContinentCode': 'NA', - }, - 'ResourceRecords': [{ - 'Value': '2.2.3.4', - }], - 'TTL': 61, - }], - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp, - {'HostedZoneId': 'z42'}) - - stubber.add_response('list_health_checks', - { - 'HealthChecks': self.health_checks, - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - - stubber.add_response('change_resource_record_sets', - {'ChangeInfo': { - 'Id': 'id', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - }}, {'HostedZoneId': 'z42', 'ChangeBatch': ANY}) - - self.assertEquals(9, provider.apply(plan)) - stubber.assert_no_pending_responses() - - def test_sync_create_with_delegation_set(self): - provider, stubber = self._get_stubbed_delegation_set_provider() - - got = Zone('unit.tests.', []) - - list_hosted_zones_resp = { - 'HostedZones': [], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, - {}) - - plan = provider.plan(self.expected) - self.assertEquals(9, len(plan.changes)) - self.assertFalse(plan.exists) - for change in plan.changes: - self.assertIsInstance(change, Create) - stubber.assert_no_pending_responses() - - create_hosted_zone_resp = { - 'HostedZone': { - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }, - 'ChangeInfo': { - 'Id': 'a12', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - 'Comment': 'hrm', - }, - 'DelegationSet': { - 'Id': 'b23', - 'CallerReference': 'blip', - 'NameServers': [ - 'n12.unit.tests.', - ], - }, - 'Location': 'us-east-1', - } - stubber.add_response('create_hosted_zone', - create_hosted_zone_resp, { - 'Name': got.name, - 'CallerReference': ANY, - 'DelegationSetId': 'ABCDEFG123456' - }) - - list_resource_record_sets_resp = { - 'ResourceRecordSets': [{ - 'Name': 'a.unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'ContinentCode': 'NA', - }, - 'ResourceRecords': [{ - 'Value': '2.2.3.4', - }], - 'TTL': 61, - }], - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp, - {'HostedZoneId': 'z42'}) - - stubber.add_response('list_health_checks', - { - 'HealthChecks': self.health_checks, - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - - stubber.add_response('change_resource_record_sets', - {'ChangeInfo': { - 'Id': 'id', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - }}, {'HostedZoneId': 'z42', 'ChangeBatch': ANY}) - - self.assertEquals(9, provider.apply(plan)) - stubber.assert_no_pending_responses() - - def test_health_checks_pagination(self): - provider, stubber = self._get_stubbed_provider() - - health_checks_p1 = [{ - 'Id': '42', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '4.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }, { - 'Id': '43', - 'CallerReference': 'abc123', - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '9.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }] - stubber.add_response('list_health_checks', - { - 'HealthChecks': health_checks_p1, - 'IsTruncated': True, - 'MaxItems': '2', - 'Marker': '', - 'NextMarker': 'moar', - }) - - health_checks_p2 = [{ - 'Id': '44', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '8.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }] - stubber.add_response('list_health_checks', - { - 'HealthChecks': health_checks_p2, - 'IsTruncated': False, - 'MaxItems': '2', - 'Marker': 'moar', - }, {'Marker': 'moar'}) - - health_checks = provider.health_checks - self.assertEquals({ - '42': health_checks_p1[0], - '44': health_checks_p2[0], - }, health_checks) - stubber.assert_no_pending_responses() - - # get without create - record = Record.new(self.expected, '', { - 'ttl': 61, - 'type': 'A', - 'values': ['2.2.3.4', '3.2.3.4'], - 'geo': { - 'AF': ['4.2.3.4'], - } - }) - value = record.geo['AF'].values[0] - id = provider.get_health_check_id(record, value, 'obey', True) - self.assertEquals('42', id) - - def test_health_check_status_support(self): - provider, stubber = self._get_stubbed_provider() - - health_checks = [{ - 'Id': '42', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '1.1.1.1', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }, { - 'Id': '43', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': { - 'Disabled': True, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '2.2.2.2', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }, { - 'Id': '44', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': { - 'Disabled': True, - 'EnableSNI': True, - 'Inverted': True, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '3.3.3.3', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }] - stubber.add_response('list_health_checks', - { - 'HealthChecks': health_checks, - 'IsTruncated': False, - 'MaxItems': '20', - 'Marker': '', - }) - - health_checks = provider.health_checks - - # get without create - record = Record.new(self.expected, '', { - 'ttl': 61, - 'type': 'A', - 'value': '5.5.5.5', - 'dynamic': { - 'pools': { - 'main': { - 'values': [{ - 'value': '6.6.6.6', - }] - } - }, - 'rules': [{ - 'pool': 'main', - }] - } - }) - self.assertEquals('42', - provider.get_health_check_id(record, '1.1.1.1', - 'obey', False)) - self.assertEquals(None, - provider.get_health_check_id(record, '2.2.2.2', - 'up', False)) - self.assertEquals('44', - provider.get_health_check_id(record, '3.3.3.3', - 'down', False)) - - # If we're not allowed to create we won't find a health check for - # 1.1.1.1 with status up or down - self.assertFalse(provider.get_health_check_id(record, '1.1.1.1', - 'up', False)) - self.assertFalse(provider.get_health_check_id(record, '1.1.1.1', - 'down', False)) - - def test_health_check_create(self): - provider, stubber = self._get_stubbed_provider() - - # No match based on type - caller_ref = f'{Route53Provider.HEALTH_CHECK_VERSION}:AAAA:foo1234' - health_checks = [{ - 'Id': '42', - # No match based on version - 'CallerReference': '9999:A:foo1234', - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '4.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }, { - 'Id': '43', - 'CallerReference': caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '4.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }] - stubber.add_response('list_health_checks', { - 'HealthChecks': health_checks, - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - - health_check_config = { - 'Disabled': False, - 'EnableSNI': False, - 'Inverted': False, - 'FailureThreshold': 6, - 'FullyQualifiedDomainName': 'foo.bar.com', - 'IPAddress': '4.2.3.4', - 'MeasureLatency': True, - 'Port': 8080, - 'RequestInterval': 10, - 'ResourcePath': '/_status', - 'Type': 'HTTP' - } - stubber.add_response('create_health_check', { - 'HealthCheck': { - 'Id': '42', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': health_check_config, - 'HealthCheckVersion': 1, - }, - 'Location': 'http://url', - }, { - 'CallerReference': ANY, - 'HealthCheckConfig': health_check_config, - }) - stubber.add_response('change_tags_for_resource', {}) - - health_check_config = { - 'Disabled': False, - 'EnableSNI': False, - 'Inverted': False, - 'FailureThreshold': 6, - 'FullyQualifiedDomainName': '4.2.3.4', - 'IPAddress': '4.2.3.4', - 'MeasureLatency': True, - 'Port': 8080, - 'RequestInterval': 10, - 'ResourcePath': '/_status', - 'Type': 'HTTP' - } - stubber.add_response('create_health_check', { - 'HealthCheck': { - 'Id': '43', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': health_check_config, - 'HealthCheckVersion': 1, - }, - 'Location': 'http://url', - }, { - 'CallerReference': ANY, - 'HealthCheckConfig': health_check_config, - }) - stubber.add_response('change_tags_for_resource', {}) - - record = Record.new(self.expected, '', { - 'ttl': 61, - 'type': 'A', - 'values': ['2.2.3.4', '3.2.3.4'], - 'geo': { - 'AF': ['4.2.3.4'], - }, - 'octodns': { - 'healthcheck': { - 'host': 'foo.bar.com', - 'path': '/_status', - 'port': 8080, - 'protocol': 'HTTP', - }, - } - }) - - # if not allowed to create returns none - value = record.geo['AF'].values[0] - id = provider.get_health_check_id(record, value, 'obey', False) - self.assertFalse(id) - - # when allowed to create we do - id = provider.get_health_check_id(record, value, 'obey', True) - self.assertEquals('42', id) - - # when allowed to create and when host is None - record._octodns['healthcheck']['host'] = None - id = provider.get_health_check_id(record, value, 'obey', True) - self.assertEquals('43', id) - stubber.assert_no_pending_responses() - - # A CNAME style healthcheck, without a value - - health_check_config = { - 'Disabled': False, - 'EnableSNI': False, - 'Inverted': False, - 'FailureThreshold': 6, - 'FullyQualifiedDomainName': 'target-1.unit.tests.', - 'MeasureLatency': True, - 'Port': 8080, - 'RequestInterval': 10, - 'ResourcePath': '/_status', - 'Type': 'HTTP' - } - stubber.add_response('create_health_check', { - 'HealthCheck': { - 'Id': '42', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': health_check_config, - 'HealthCheckVersion': 1, - }, - 'Location': 'http://url', - }, { - 'CallerReference': ANY, - 'HealthCheckConfig': health_check_config, - }) - stubber.add_response('change_tags_for_resource', {}) - - id = provider.get_health_check_id(record, 'target-1.unit.tests.', - 'obey', True) - self.assertEquals('42', id) - stubber.assert_no_pending_responses() - - # TCP health check - - health_check_config = { - 'Disabled': False, - 'EnableSNI': False, - 'Inverted': False, - 'FailureThreshold': 6, - 'MeasureLatency': True, - 'Port': 8080, - 'RequestInterval': 10, - 'Type': 'TCP' - } - stubber.add_response('create_health_check', { - 'HealthCheck': { - 'Id': '42', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': health_check_config, - 'HealthCheckVersion': 1, - }, - 'Location': 'http://url', - }, { - 'CallerReference': ANY, - 'HealthCheckConfig': health_check_config, - }) - stubber.add_response('change_tags_for_resource', {}) - - record._octodns['healthcheck']['protocol'] = 'TCP' - id = provider.get_health_check_id(record, 'target-1.unit.tests.', - 'obey', True) - self.assertEquals('42', id) - stubber.assert_no_pending_responses() - - def test_health_check_provider_options(self): - provider, stubber = self._get_stubbed_provider() - record = Record.new(self.expected, 'a', { - 'ttl': 61, - 'type': 'A', - 'value': '1.2.3.4', - 'octodns': { - 'healthcheck': { - }, - 'route53': { - 'healthcheck': { - 'measure_latency': True, - 'request_interval': 10, - } - } - } - }) - latency = provider._healthcheck_measure_latency(record) - interval = provider._healthcheck_request_interval(record) - self.assertTrue(latency) - self.assertEquals(10, interval) - - record_default = Record.new(self.expected, 'a', { - 'ttl': 61, - 'type': 'A', - 'value': '1.2.3.4', - }) - latency = provider._healthcheck_measure_latency(record_default) - interval = provider._healthcheck_request_interval(record_default) - self.assertTrue(latency) - self.assertEquals(10, interval) - - record = Record.new(self.expected, 'a', { - 'ttl': 61, - 'type': 'A', - 'value': '1.2.3.4', - 'octodns': { - 'healthcheck': { - }, - 'route53': { - 'healthcheck': { - 'measure_latency': False, - 'request_interval': 30, - } - } - } - }) - latency = provider._healthcheck_measure_latency(record) - interval = provider._healthcheck_request_interval(record) - self.assertFalse(latency) - self.assertEquals(30, interval) - - record_invalid = Record.new(self.expected, 'a', { - 'ttl': 61, - 'type': 'A', - 'value': '1.2.3.4', - 'octodns': { - 'healthcheck': { - }, - 'route53': { - 'healthcheck': { - 'request_interval': 20, - } - } - } - }) - with self.assertRaises(Route53ProviderException): - interval = provider._healthcheck_request_interval(record_invalid) - - def test_create_health_checks_provider_options(self): - provider, stubber = self._get_stubbed_provider() - - health_check_config = { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'FailureThreshold': 6, - 'FullyQualifiedDomainName': 'a.unit.tests', - 'IPAddress': '1.2.3.4', - 'MeasureLatency': False, - 'Port': 443, - 'RequestInterval': 30, - 'ResourcePath': '/_dns', - 'Type': 'HTTPS' - } - - stubber.add_response('list_health_checks', { - 'HealthChecks': [], - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - - stubber.add_response('create_health_check', { - 'HealthCheck': { - 'Id': '42', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': health_check_config, - 'HealthCheckVersion': 1, - }, - 'Location': 'http://url', - }, { - 'CallerReference': ANY, - 'HealthCheckConfig': health_check_config, - }) - stubber.add_response('change_tags_for_resource', {}) - stubber.add_response('change_tags_for_resource', {}) - - record = Record.new(self.expected, 'a', { - 'ttl': 61, - 'type': 'A', - 'value': '2.2.3.4', - 'geo': { - 'AF': ['1.2.3.4'], - }, - 'octodns': { - 'healthcheck': { - }, - 'route53': { - 'healthcheck': { - 'measure_latency': False, - 'request_interval': 30 - } - } - } - }) - - value = record.geo['AF'].values[0] - id = provider.get_health_check_id(record, value, 'obey', True) - ml = provider.health_checks[id]['HealthCheckConfig']['MeasureLatency'] - ri = provider.health_checks[id]['HealthCheckConfig']['RequestInterval'] - self.assertFalse(ml) - self.assertEquals(30, ri) - - def test_health_check_gc(self): - provider, stubber = self._get_stubbed_provider() - - stubber.add_response('list_health_checks', { - 'HealthChecks': self.health_checks, - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - - record = Record.new(self.expected, '', { - 'ttl': 61, - 'type': 'A', - 'values': ['2.2.3.4', '3.2.3.4'], - 'geo': { - 'AF': ['4.2.3.4'], - 'NA-US': ['5.2.3.4', '6.2.3.4'], - # removed one geo - } - }) - - # gc no longer in_use records (directly) - stubber.add_response('delete_health_check', {}, { - 'HealthCheckId': '44', - }) - provider._gc_health_checks(record, [ - DummyR53Record('42'), - DummyR53Record('43'), - ]) - stubber.assert_no_pending_responses() - - # gc through _mod_Create - stubber.add_response('delete_health_check', {}, { - 'HealthCheckId': '44', - }) - change = Create(record) - provider._mod_Create(change, 'z43', []) - stubber.assert_no_pending_responses() - - # gc through _mod_Update - stubber.add_response('delete_health_check', {}, { - 'HealthCheckId': '44', - }) - # first record is ignored for our purposes, we have to pass something - change = Update(record, record) - provider._mod_Create(change, 'z43', []) - stubber.assert_no_pending_responses() - - # gc through _mod_Delete, expect 3 to go away, can't check order - # b/c it's not deterministic - stubber.add_response('delete_health_check', {}, { - 'HealthCheckId': ANY, - }) - stubber.add_response('delete_health_check', {}, { - 'HealthCheckId': ANY, - }) - stubber.add_response('delete_health_check', {}, { - 'HealthCheckId': ANY, - }) - change = Delete(record) - provider._mod_Delete(change, 'z43', []) - stubber.assert_no_pending_responses() - - # gc only AAAA, leave the A's alone - stubber.add_response('delete_health_check', {}, { - 'HealthCheckId': '45', - }) - record = Record.new(self.expected, '', { - 'ttl': 61, - 'type': 'AAAA', - 'value': '2001:0db8:3c4d:0015:0000:0000:1a2f:1a4b' - }) - provider._gc_health_checks(record, []) - stubber.assert_no_pending_responses() - - def test_legacy_health_check_gc(self): - provider, stubber = self._get_stubbed_provider() - - old_caller_ref = '0000:A:3333' - health_checks = [{ - 'Id': '42', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '4.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }, { - 'Id': '43', - 'CallerReference': old_caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '4.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }, { - 'Id': '44', - 'CallerReference': old_caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'other.unit.tests', - 'IPAddress': '4.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }] - - stubber.add_response('list_health_checks', { - 'HealthChecks': health_checks, - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - - # No changes to the record itself - record = Record.new(self.expected, '', { - 'ttl': 61, - 'type': 'A', - 'values': ['2.2.3.4', '3.2.3.4'], - 'geo': { - 'AF': ['4.2.3.4'], - 'NA-US': ['5.2.3.4', '6.2.3.4'], - 'NA-US-CA': ['7.2.3.4'] - } - }) - - # Expect to delete the legacy hc for our record, but not touch the new - # one or the other legacy record - stubber.add_response('delete_health_check', {}, { - 'HealthCheckId': '43', - }) - - provider._gc_health_checks(record, [ - DummyR53Record('42'), - ]) - - def test_no_extra_changes(self): - provider, stubber = self._get_stubbed_provider() - - list_hosted_zones_resp = { - 'HostedZones': [{ - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, {}) - - # empty is empty - desired = Zone('unit.tests.', []) - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals([], extra) - stubber.assert_no_pending_responses() - - # single record w/o geo is empty - desired = Zone('unit.tests.', []) - record = Record.new(desired, 'a', { - 'ttl': 30, - 'type': 'A', - 'value': '1.2.3.4', - }) - desired.add_record(record) - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals([], extra) - stubber.assert_no_pending_responses() - - # short-circuit for unknown zone - other = Zone('other.tests.', []) - extra = provider._extra_changes(desired=other, changes=[]) - self.assertEquals([], extra) - stubber.assert_no_pending_responses() - - def test_no_changes_with_get_zones_by_name(self): - provider = Route53Provider( - 'test', 'abc', '123', get_zones_by_name=True) - - # Use the stubber - stubber = Stubber(provider._conn) - stubber.activate() - - list_hosted_zones_by_name_resp_1 = { - 'HostedZones': [{ - 'Id': 'z42', - 'Name': 'unit.tests.', - 'CallerReference': 'abc', - 'Config': { - 'Comment': 'string', - 'PrivateZone': False - }, - 'ResourceRecordSetCount': 123, - }, ], - 'DNSName': 'unit.tests.', - 'HostedZoneId': 'z42', - 'IsTruncated': False, - 'MaxItems': 'string' - } - - list_hosted_zones_by_name_resp_2 = { - 'HostedZones': [{ - 'Id': 'z43', - 'Name': 'unit2.tests.', - 'CallerReference': 'abc', - 'Config': { - 'Comment': 'string', - 'PrivateZone': False - }, - 'ResourceRecordSetCount': 123, - }, ], - 'DNSName': 'unit2.tests.', - 'HostedZoneId': 'z43', - 'IsTruncated': False, - 'MaxItems': 'string' - } - - stubber.add_response( - 'list_hosted_zones_by_name', - list_hosted_zones_by_name_resp_1, - {'DNSName': 'unit.tests.', 'MaxItems': '1'} - ) - - # empty is empty - desired = Zone('unit.tests.', []) - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals([], extra) - stubber.assert_no_pending_responses() - - stubber.add_response( - 'list_hosted_zones_by_name', - list_hosted_zones_by_name_resp_2, - {'DNSName': 'unit2.tests.', 'MaxItems': '1'} - ) - - # empty is empty - desired = Zone('unit2.tests.', []) - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals([], extra) - stubber.assert_no_pending_responses() - - def test_zone_not_found_get_zones_by_name(self): - provider = Route53Provider( - 'test', 'abc', '123', get_zones_by_name=True) - - # Use the stubber - stubber = Stubber(provider._conn) - stubber.activate() - - list_hosted_zones_by_name_resp = { - 'HostedZones': [{ - 'Id': 'z43', - 'Name': 'bad.tests.', - 'CallerReference': 'abc', - 'Config': { - 'Comment': 'string', - 'PrivateZone': False - }, - 'ResourceRecordSetCount': 123, - }, ], - 'DNSName': 'unit.tests.', - 'HostedZoneId': 'z42', - 'IsTruncated': False, - 'MaxItems': 'string' - } - - stubber.add_response( - 'list_hosted_zones_by_name', - list_hosted_zones_by_name_resp, - {'DNSName': 'unit.tests.', 'MaxItems': '1'} - ) - - # empty is empty - desired = Zone('unit.tests.', []) - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals([], extra) - stubber.assert_no_pending_responses() - - def test_plan_apply_with_get_zones_by_name_zone_not_exists(self): - provider = Route53Provider( - 'test', 'abc', '123', get_zones_by_name=True) - - # Use the stubber - stubber = Stubber(provider._conn) - stubber.activate() - - # this is an empty response - # zone name not found - list_hosted_zones_by_name_resp = { - 'HostedZones': [], - 'DNSName': 'unit.tests.', - 'HostedZoneId': 'z42', - 'IsTruncated': False, - 'MaxItems': 'string' - } - - stubber.add_response( - 'list_hosted_zones_by_name', - list_hosted_zones_by_name_resp, - {'DNSName': 'unit.tests.', 'MaxItems': '1'} - ) - - plan = provider.plan(self.expected) - self.assertEquals(9, len(plan.changes)) - - create_hosted_zone_resp = { - 'HostedZone': { - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }, - 'ChangeInfo': { - 'Id': 'a12', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - 'Comment': 'hrm', - }, - 'DelegationSet': { - 'Id': 'b23', - 'CallerReference': 'blip', - 'NameServers': [ - 'n12.unit.tests.', - ], - }, - 'Location': 'us-east-1', - } - stubber.add_response('create_hosted_zone', - create_hosted_zone_resp, { - 'Name': 'unit.tests.', - 'CallerReference': ANY, - }) - - list_resource_record_sets_resp = { - 'ResourceRecordSets': [{ - 'Name': 'a.unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'ContinentCode': 'NA', - }, - 'ResourceRecords': [{ - 'Value': '2.2.3.4', - }], - 'TTL': 61, - }], - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp, - {'HostedZoneId': 'z42'}) - - stubber.add_response('list_health_checks', - { - 'HealthChecks': self.health_checks, - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - - stubber.add_response('change_resource_record_sets', - {'ChangeInfo': { - 'Id': 'id', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - }}, {'HostedZoneId': 'z42', 'ChangeBatch': ANY}) - - self.assertEquals(9, provider.apply(plan)) - stubber.assert_no_pending_responses() - - def test_plan_apply_with_get_zones_by_name_zone_exists(self): - provider = Route53Provider( - 'test', 'abc', '123', get_zones_by_name=True) - - # Use the stubber - stubber = Stubber(provider._conn) - stubber.activate() - - list_hosted_zones_by_name_resp = { - 'HostedZones': [{ - 'Id': 'z42', - 'Name': 'unit.tests.', - 'CallerReference': 'abc', - 'Config': { - 'Comment': 'string', - 'PrivateZone': False - }, - 'ResourceRecordSetCount': 123, - }, ], - 'DNSName': 'unit.tests.', - 'HostedZoneId': 'z42', - 'IsTruncated': False, - 'MaxItems': 'string' - } - - list_resource_record_sets_resp = { - 'ResourceRecordSets': [{ - 'Name': 'a.unit.tests.', - 'Type': 'A', - 'ResourceRecords': [{ - 'Value': '2.2.3.4', - }], - 'TTL': 61, - }], - 'IsTruncated': False, - 'MaxItems': '100', - } - - stubber.add_response( - 'list_hosted_zones_by_name', - list_hosted_zones_by_name_resp, - {'DNSName': 'unit.tests.', 'MaxItems': '1'} - ) - - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp, - {'HostedZoneId': 'z42'}) - - plan = provider.plan(self.expected) - self.assertEquals(10, len(plan.changes)) - - stubber.add_response('list_health_checks', - { - 'HealthChecks': self.health_checks, - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - - stubber.add_response('change_resource_record_sets', - {'ChangeInfo': { - 'Id': 'id', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - }}, {'HostedZoneId': 'z42', 'ChangeBatch': ANY}) - - self.assertEquals(10, provider.apply(plan)) - stubber.assert_no_pending_responses() - - def test_extra_change_no_health_check(self): - provider, stubber = self._get_stubbed_provider() - - list_hosted_zones_resp = { - 'HostedZones': [{ - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, {}) - - # record with geo and no health check returns change - desired = Zone('unit.tests.', []) - record = Record.new(desired, 'a', { - 'ttl': 30, - 'type': 'A', - 'value': '1.2.3.4', - 'geo': { - 'NA': ['2.2.3.4'], - } - }) - desired.add_record(record) - list_resource_record_sets_resp = { - 'ResourceRecordSets': [{ - 'Name': 'a.unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'ContinentCode': 'NA', - }, - 'ResourceRecords': [{ - 'Value': '2.2.3.4', - }], - 'TTL': 61, - }], - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp, - {'HostedZoneId': 'z42'}) - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals(1, len(extra)) - stubber.assert_no_pending_responses() - - def test_extra_change_has_wrong_health_check(self): - provider, stubber = self._get_stubbed_provider() - - list_hosted_zones_resp = { - 'HostedZones': [{ - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, {}) - - # record with geo and no health check returns change - desired = Zone('unit.tests.', []) - record = Record.new(desired, 'a', { - 'ttl': 30, - 'type': 'A', - 'value': '1.2.3.4', - 'geo': { - 'NA': ['2.2.3.4'], - } - }) - desired.add_record(record) - list_resource_record_sets_resp = { - 'ResourceRecordSets': [{ - 'Name': 'a.unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'ContinentCode': 'NA', - }, - 'ResourceRecords': [{ - 'Value': '2.2.3.4', - }], - 'TTL': 61, - 'HealthCheckId': '42', - }], - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp, - {'HostedZoneId': 'z42'}) - stubber.add_response('list_health_checks', { - 'HealthChecks': [{ - 'Id': '42', - 'CallerReference': 'foo', - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'unit.tests', - 'IPAddress': '2.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }], - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals(1, len(extra)) - stubber.assert_no_pending_responses() - - for change in (Create(record), Update(record, record), Delete(record)): - extra = provider._extra_changes(desired=desired, changes=[change]) - self.assertEquals(0, len(extra)) - stubber.assert_no_pending_responses() - - def test_extra_change_has_health_check(self): - provider, stubber = self._get_stubbed_provider() - - list_hosted_zones_resp = { - 'HostedZones': [{ - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, {}) - - # record with geo and no health check returns change - desired = Zone('unit.tests.', []) - record = Record.new(desired, 'a', { - 'ttl': 30, - 'type': 'A', - 'value': '1.2.3.4', - 'geo': { - 'NA': ['2.2.3.4'], - } - }) - desired.add_record(record) - list_resource_record_sets_resp = { - 'ResourceRecordSets': [{ - # other name - 'Name': 'unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'CountryCode': '*', - }, - 'ResourceRecords': [{ - 'Value': '1.2.3.4', - }], - 'TTL': 61, - }, { - # matching name, other type - 'Name': 'a.unit.tests.', - 'Type': 'AAAA', - 'ResourceRecords': [{ - 'Value': '2001:0db8:3c4d:0015:0000:0000:1a2f:1a4b' - }], - 'TTL': 61, - }, { - # default geo - 'Name': 'a.unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'CountryCode': '*', - }, - 'ResourceRecords': [{ - 'Value': '1.2.3.4', - }], - 'TTL': 61, - }, { - # match w/correct geo - 'Name': 'a.unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'ContinentCode': 'NA', - }, - 'ResourceRecords': [{ - 'Value': '2.2.3.4', - }], - 'TTL': 61, - 'HealthCheckId': '42', - }], - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp, - {'HostedZoneId': 'z42'}) - stubber.add_response('list_health_checks', { - 'HealthChecks': [{ - 'Id': '42', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'a.unit.tests', - 'IPAddress': '2.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }], - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals(0, len(extra)) - stubber.assert_no_pending_responses() - - # change b/c of healthcheck path - record._octodns['healthcheck'] = { - 'path': '/_ready' - } - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals(1, len(extra)) - stubber.assert_no_pending_responses() - - def test_extra_change_dynamic_has_health_check(self): - provider, stubber = self._get_stubbed_provider() - - list_hosted_zones_resp = { - 'HostedZones': [{ - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, {}) - - # record with geo and no health check returns change - desired = Zone('unit.tests.', []) - record = Record.new(desired, 'a', { - 'ttl': 30, - 'type': 'A', - 'value': '1.2.3.4', - 'dynamic': { - 'pools': { - 'one': { - 'values': [{ - 'value': '2.2.3.4', - }], - }, - }, - 'rules': [{ - 'pool': 'one', - }], - }, - }) - desired.add_record(record) - list_resource_record_sets_resp = { - 'ResourceRecordSets': [{ - # Not dynamic value and other name - 'Name': 'unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'CountryCode': '*', - }, - 'ResourceRecords': [{ - 'Value': '1.2.3.4', - }], - 'TTL': 61, - # All the non-matches have a different Id so we'll fail if they - # match - 'HealthCheckId': '33', - }, { - # Not dynamic value, matching name, other type - 'Name': 'a.unit.tests.', - 'Type': 'AAAA', - 'ResourceRecords': [{ - 'Value': '2001:0db8:3c4d:0015:0000:0000:1a2f:1a4b' - }], - 'TTL': 61, - 'HealthCheckId': '33', - }, { - # default value pool - 'Name': '_octodns-default-value.a.unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'CountryCode': '*', - }, - 'ResourceRecords': [{ - 'Value': '1.2.3.4', - }], - 'TTL': 61, - 'HealthCheckId': '33', - }, { - # different record - 'Name': '_octodns-two-value.other.unit.tests.', - 'Type': 'A', - 'GeoLocation': { - 'CountryCode': '*', - }, - 'ResourceRecords': [{ - 'Value': '1.2.3.4', - }], - 'TTL': 61, - 'HealthCheckId': '33', - }, { - # same everything, but different type - 'Name': '_octodns-one-value.a.unit.tests.', - 'Type': 'AAAA', - 'ResourceRecords': [{ - 'Value': '2001:0db8:3c4d:0015:0000:0000:1a2f:1a4b' - }], - 'TTL': 61, - 'HealthCheckId': '33', - }, { - # same everything, sub - 'Name': '_octodns-one-value.sub.a.unit.tests.', - 'Type': 'A', - 'ResourceRecords': [{ - 'Value': '1.2.3.4', - }], - 'TTL': 61, - 'HealthCheckId': '33', - }, { - # match - 'Name': '_octodns-one-value.a.unit.tests.', - 'Type': 'A', - 'ResourceRecords': [{ - 'Value': '2.2.3.4', - }], - 'TTL': 61, - 'HealthCheckId': '42', - }], - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp, - {'HostedZoneId': 'z42'}) - stubber.add_response('list_health_checks', { - 'HealthChecks': [{ - 'Id': '42', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'a.unit.tests', - 'IPAddress': '2.2.3.4', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }], - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals(0, len(extra)) - stubber.assert_no_pending_responses() - - # change b/c of healthcheck path - record._octodns['healthcheck'] = { - 'path': '/_ready' - } - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals(1, len(extra)) - stubber.assert_no_pending_responses() - - # change b/c of healthcheck host - record._octodns['healthcheck'] = { - 'host': 'foo.bar.io' - } - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals(1, len(extra)) - stubber.assert_no_pending_responses() - - def test_extra_change_dyamic_status_up(self): - provider, stubber = self._get_stubbed_provider() - - zone = Zone('unit.tests.', []) - record = Record.new(zone, 'a', { - 'ttl': 30, - 'type': 'A', - 'value': '1.1.1.1', - 'dynamic': { - 'pools': { - 'one': { - 'values': [{ - 'status': 'up', - 'value': '1.2.3.4', - }], - }, - }, - 'rules': [{ - 'pool': 'one', - }], - }, - }) - - # status up and no health check so we're good - rrset = { - 'ResourceRecords': [{'Value': '1.2.3.4'}], - } - statuses = {'1.2.3.4': 'up'} - self.assertFalse( - provider._extra_changes_update_needed(record, rrset, statuses) - ) - - # status up and has a health check so update needed - rrset = { - 'ResourceRecords': [{'Value': '1.2.3.4'}], - 'HealthCheckId': 'foo', - } - statuses = {'1.2.3.4': 'up'} - self.assertTrue( - provider._extra_changes_update_needed(record, rrset, statuses) - ) - - def test_extra_change_dynamic_has_health_check_cname(self): - provider, stubber = self._get_stubbed_provider() - - list_hosted_zones_resp = { - 'HostedZones': [{ - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, {}) - - # record with geo and no health check returns change - desired = Zone('unit.tests.', []) - record = Record.new(desired, 'cname', { - 'ttl': 30, - 'type': 'CNAME', - 'value': 'cname.unit.tests.', - 'dynamic': { - 'pools': { - 'one': { - 'values': [{ - 'value': 'one.cname.unit.tests.', - }], - }, - }, - 'rules': [{ - 'pool': 'one', - }], - }, - }) - desired.add_record(record) - list_resource_record_sets_resp = { - 'ResourceRecordSets': [{ - # Not dynamic value and other name - 'Name': 'unit.tests.', - 'Type': 'CNAME', - 'GeoLocation': { - 'CountryCode': '*', - }, - 'ResourceRecords': [{ - 'Value': 'cname.unit.tests.', - }], - 'TTL': 61, - # All the non-matches have a different Id so we'll fail if they - # match - 'HealthCheckId': '33', - }, { - # Not dynamic value, matching name, other type - 'Name': 'cname.unit.tests.', - 'Type': 'AAAA', - 'ResourceRecords': [{ - 'Value': '2001:0db8:3c4d:0015:0000:0000:1a2f:1a4b' - }], - 'TTL': 61, - 'HealthCheckId': '33', - }, { - # default value pool - 'Name': '_octodns-default-value.cname.unit.tests.', - 'Type': 'CNAME', - 'GeoLocation': { - 'CountryCode': '*', - }, - 'ResourceRecords': [{ - 'Value': 'cname.unit.tests.', - }], - 'TTL': 61, - 'HealthCheckId': '33', - }, { - # different record - 'Name': '_octodns-two-value.other.unit.tests.', - 'Type': 'CNAME', - 'GeoLocation': { - 'CountryCode': '*', - }, - 'ResourceRecords': [{ - 'Value': 'cname.unit.tests.', - }], - 'TTL': 61, - 'HealthCheckId': '33', - }, { - # same everything, but different type - 'Name': '_octodns-one-value.cname.unit.tests.', - 'Type': 'AAAA', - 'ResourceRecords': [{ - 'Value': '2001:0db8:3c4d:0015:0000:0000:1a2f:1a4b' - }], - 'TTL': 61, - 'HealthCheckId': '33', - }, { - # same everything, sub - 'Name': '_octodns-one-value.sub.cname.unit.tests.', - 'Type': 'CNAME', - 'ResourceRecords': [{ - 'Value': 'cname.unit.tests.', - }], - 'TTL': 61, - 'HealthCheckId': '33', - }, { - # match - 'Name': '_octodns-one-value.cname.unit.tests.', - 'Type': 'CNAME', - 'ResourceRecords': [{ - 'Value': 'one.cname.unit.tests.', - }], - 'TTL': 61, - 'HealthCheckId': '42', - }], - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_resource_record_sets', - list_resource_record_sets_resp, - {'HostedZoneId': 'z42'}) - - stubber.add_response('list_health_checks', { - 'HealthChecks': [{ - 'Id': '42', - 'CallerReference': self.caller_ref, - 'HealthCheckConfig': { - 'Disabled': False, - 'EnableSNI': True, - 'Inverted': False, - 'Type': 'HTTPS', - 'FullyQualifiedDomainName': 'one.cname.unit.tests.', - 'ResourcePath': '/_dns', - 'Type': 'HTTPS', - 'Port': 443, - 'MeasureLatency': True, - 'RequestInterval': 10, - }, - 'HealthCheckVersion': 2, - }], - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals(0, len(extra)) - stubber.assert_no_pending_responses() - - # change b/c of healthcheck path - record._octodns['healthcheck'] = { - 'path': '/_ready' - } - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals(1, len(extra)) - stubber.assert_no_pending_responses() - - # no change b/c healthcheck host ignored for dynamic cname - record._octodns['healthcheck'] = { - 'host': 'foo.bar.io' - } - extra = provider._extra_changes(desired=desired, changes=[]) - self.assertEquals(0, len(extra)) - stubber.assert_no_pending_responses() - - def _get_test_plan(self, max_changes): - - provider = Route53Provider('test', 'abc', '123', max_changes) - - # Use the stubber - stubber = Stubber(provider._conn) - stubber.activate() - - got = Zone('unit.tests.', []) - - list_hosted_zones_resp = { - 'HostedZones': [], - 'Marker': 'm', - 'IsTruncated': False, - 'MaxItems': '100', - } - stubber.add_response('list_hosted_zones', list_hosted_zones_resp, - {}) - - create_hosted_zone_resp = { - 'HostedZone': { - 'Name': 'unit.tests.', - 'Id': 'z42', - 'CallerReference': 'abc', - }, - 'ChangeInfo': { - 'Id': 'a12', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - 'Comment': 'hrm', - }, - 'DelegationSet': { - 'Id': 'b23', - 'CallerReference': 'blip', - 'NameServers': [ - 'n12.unit.tests.', - ], - }, - 'Location': 'us-east-1', - } - stubber.add_response('create_hosted_zone', - create_hosted_zone_resp, { - 'Name': got.name, - 'CallerReference': ANY, - }) - - stubber.add_response('list_health_checks', - { - 'HealthChecks': self.health_checks, - 'IsTruncated': False, - 'MaxItems': '100', - 'Marker': '', - }) - - stubber.add_response('change_resource_record_sets', - {'ChangeInfo': { - 'Id': 'id', - 'Status': 'PENDING', - 'SubmittedAt': '2017-01-29T01:02:03Z', - }}, {'HostedZoneId': 'z42', 'ChangeBatch': ANY}) - - plan = provider.plan(self.expected) - - return provider, plan - - # _get_test_plan() returns a plan with 11 modifications, 17 RRs - - @patch('octodns.provider.route53.Route53Provider._load_records') - @patch('octodns.provider.route53.Route53Provider._really_apply') - def test_apply_1(self, really_apply_mock, _): - - # 18 RRs with max of 19 should only get applied in one call - provider, plan = self._get_test_plan(19) - provider.apply(plan) - really_apply_mock.assert_called_once() - - @patch('octodns.provider.route53.Route53Provider._load_records') - @patch('octodns.provider.route53.Route53Provider._really_apply') - def test_apply_2(self, really_apply_mock, _): - - # 18 RRs with max of 17 should only get applied in two calls - provider, plan = self._get_test_plan(18) - provider.apply(plan) - self.assertEquals(2, really_apply_mock.call_count) - - @patch('octodns.provider.route53.Route53Provider._load_records') - @patch('octodns.provider.route53.Route53Provider._really_apply') - def test_apply_3(self, really_apply_mock, _): - - # with a max of seven modifications, three calls - provider, plan = self._get_test_plan(7) - provider.apply(plan) - self.assertEquals(3, really_apply_mock.call_count) - - @patch('octodns.provider.route53.Route53Provider._load_records') - @patch('octodns.provider.route53.Route53Provider._really_apply') - def test_apply_4(self, really_apply_mock, _): - - # with a max of 11 modifications, two calls - provider, plan = self._get_test_plan(11) - provider.apply(plan) - self.assertEquals(2, really_apply_mock.call_count) - - @patch('octodns.provider.route53.Route53Provider._load_records') - @patch('octodns.provider.route53.Route53Provider._really_apply') - def test_apply_bad(self, really_apply_mock, _): - - # with a max of 1 modifications, fail - provider, plan = self._get_test_plan(1) - with self.assertRaises(Exception) as ctx: - provider.apply(plan) - self.assertTrue('modifications' in str(ctx.exception)) - - def test_semicolon_fixup(self): - provider = Route53Provider('test', 'abc', '123') - - self.assertEquals({ - 'type': 'TXT', - 'ttl': 30, - 'values': [ - 'abcd\\; ef\\;g', - 'hij\\; klm\\;n', - ], - }, provider._data_for_quoted({ - 'ResourceRecords': [{ - 'Value': '"abcd; ef;g"', - }, { - 'Value': '"hij\\; klm\\;n"', - }], - 'TTL': 30, - 'Type': 'TXT', - })) - - def test_client_max_attempts(self): - provider = Route53Provider('test', 'abc', '123', - client_max_attempts=42) - # NOTE: this will break if boto ever changes the impl details... - self.assertEquals({ - 'mode': 'legacy', - 'total_max_attempts': 43, - }, provider._conn._client_config.retries) - - def test_data_for_dynamic(self): - provider = Route53Provider('test', 'abc', '123') - provider._health_checks = dynamic_health_checks - - data = provider._data_for_dynamic('', 'A', dynamic_rrsets) - self.assertEquals(dynamic_record_data, data) - - @patch('octodns.provider.route53.Route53Provider._get_zone_id') - @patch('octodns.provider.route53.Route53Provider._load_records') - def test_dynamic_populate(self, load_records_mock, get_zone_id_mock): - provider = Route53Provider('test', 'abc', '123') - provider._health_checks = {} - - get_zone_id_mock.side_effect = ['z44'] - load_records_mock.side_effect = [dynamic_rrsets] - - got = Zone('unit.tests.', []) - provider.populate(got) - - self.assertEquals(1, len(got.records)) - record = list(got.records)[0] - self.assertEquals('', record.name) - self.assertEquals('A', record._type) - self.assertEquals([ - '1.1.2.1', - '1.1.2.2', - ], record.values) - self.assertTrue(record.dynamic) - - self.assertEquals({ - 'ap-southeast-1': { - 'fallback': 'us-east-1', - 'values': [{ - 'weight': 2, 'value': '1.4.1.1', 'status': 'up', - }, { - 'weight': 2, 'value': '1.4.1.2', 'status': 'up', - }] - }, - 'eu-central-1': { - 'fallback': 'us-east-1', - 'values': [{ - 'weight': 1, 'value': '1.3.1.1', 'status': 'up', - }, { - 'weight': 1, 'value': '1.3.1.2', 'status': 'up', - }], - }, - 'us-east-1': { - 'fallback': None, - 'values': [{ - 'weight': 1, 'value': '1.5.1.1', 'status': 'up', - }, { - 'weight': 1, 'value': '1.5.1.2', 'status': 'up', - }], - } - }, {k: v.data for k, v in record.dynamic.pools.items()}) - - self.assertEquals([ - { - 'geos': ['AS-CN', 'AS-JP'], - 'pool': 'ap-southeast-1', - }, { - 'geos': ['EU', 'NA-US-FL'], - 'pool': 'eu-central-1', - }, { - 'pool': 'us-east-1', - }], [r.data for r in record.dynamic.rules]) - - -class DummyProvider(object): - - def get_health_check_id(self, *args, **kwargs): - return None - - -class TestRoute53Records(TestCase): - existing = Zone('unit.tests.', []) - record_a = Record.new(existing, '', { - 'geo': { - 'NA-US': ['2.2.2.2', '3.3.3.3'], - 'OC': ['4.4.4.4', '5.5.5.5'] - }, - 'ttl': 99, - 'type': 'A', - 'values': ['9.9.9.9'] - }) - - def test_value_fors(self): - route53_record = _Route53Record(None, self.record_a, False) - - for value in (None, '', 'foo', 'bar', '1.2.3.4'): - converted = route53_record._value_convert_value(value, - self.record_a) - self.assertEquals(value, converted) - - record_txt = Record.new(self.existing, 'txt', { - 'ttl': 98, - 'type': 'TXT', - 'value': 'Not Important', - }) - - # We don't really have to test the details fo chunked_value as that's - # tested elsewhere, we just need to make sure that it's plumbed up and - # working - self.assertEquals('"Not Important"', route53_record - ._value_convert_quoted(record_txt.values[0], - record_txt)) - - def test_route53_record(self): - a = _Route53Record(None, self.record_a, False) - self.assertEquals(a, a) - b = _Route53Record(None, Record.new(self.existing, '', - {'ttl': 32, 'type': 'A', - 'values': ['8.8.8.8', - '1.1.1.1']}), - False) - self.assertEquals(b, b) - c = _Route53Record(None, Record.new(self.existing, 'other', - {'ttl': 99, 'type': 'A', - 'values': ['9.9.9.9']}), - False) - self.assertEquals(c, c) - d = _Route53Record(None, Record.new(self.existing, '', - {'ttl': 42, 'type': 'MX', - 'value': { - 'preference': 10, - 'exchange': 'foo.bar.'}}), - False) - self.assertEquals(d, d) - - # Same fqdn & type is same record - self.assertEquals(a, b) - # Same name & different type is not the same - self.assertNotEquals(a, d) - # Different name & same type is not the same - self.assertNotEquals(a, c) - - # Same everything, different class is not the same - e = _Route53GeoDefault(None, self.record_a, False) - self.assertNotEquals(a, e) - - provider = DummyProvider() - f = _Route53GeoRecord(provider, self.record_a, 'NA-US', - self.record_a.geo['NA-US'], False) - self.assertEquals(f, f) - g = _Route53GeoRecord(provider, self.record_a, 'OC', - self.record_a.geo['OC'], False) - self.assertEquals(g, g) - - # Geo and non-geo are not the same, using Geo as primary to get it's - # __cmp__ - self.assertNotEquals(f, a) - # Same everything, different geo's is not the same - self.assertNotEquals(f, g) - - # Make sure it doesn't blow up - a.__repr__() - e.__repr__() - f.__repr__() - - def test_route53_record_ordering(self): - # Matches - a = _Route53Record(None, self.record_a, False) - b = _Route53Record(None, self.record_a, False) - self.assertTrue(a == b) - self.assertFalse(a != b) - self.assertFalse(a < b) - self.assertTrue(a <= b) - self.assertFalse(a > b) - self.assertTrue(a >= b) - - # Change the fqdn is greater - fqdn = _Route53Record(None, self.record_a, False, - fqdn_override='other') - self.assertFalse(a == fqdn) - self.assertTrue(a != fqdn) - self.assertFalse(a < fqdn) - self.assertFalse(a <= fqdn) - self.assertTrue(a > fqdn) - self.assertTrue(a >= fqdn) - - provider = DummyProvider() - geo_a = _Route53GeoRecord(provider, self.record_a, 'NA-US', - self.record_a.geo['NA-US'], False) - geo_b = _Route53GeoRecord(provider, self.record_a, 'NA-US', - self.record_a.geo['NA-US'], False) - self.assertTrue(geo_a == geo_b) - self.assertFalse(geo_a != geo_b) - self.assertFalse(geo_a < geo_b) - self.assertTrue(geo_a <= geo_b) - self.assertFalse(geo_a > geo_b) - self.assertTrue(geo_a >= geo_b) - - # Other base - geo_fqdn = _Route53GeoRecord(provider, self.record_a, 'NA-US', - self.record_a.geo['NA-US'], False) - geo_fqdn.fqdn = 'other' - self.assertFalse(geo_a == geo_fqdn) - self.assertTrue(geo_a != geo_fqdn) - self.assertFalse(geo_a < geo_fqdn) - self.assertFalse(geo_a <= geo_fqdn) - self.assertTrue(geo_a > geo_fqdn) - self.assertTrue(geo_a >= geo_fqdn) - - # Other class - self.assertFalse(a == geo_a) - self.assertTrue(a != geo_a) - self.assertFalse(a < geo_a) - self.assertFalse(a <= geo_a) - self.assertTrue(a > geo_a) - self.assertTrue(a >= geo_a) - - def test_dynamic_value_delete(self): - provider = DummyProvider() - geo = _Route53DynamicValue(provider, self.record_a, 'iad', '2.2.2.2', - 1, 'obey', 0, False) - - rrset = { - 'HealthCheckId': 'x12346z', - 'Name': '_octodns-iad-value.unit.tests.', - 'ResourceRecords': [{ - 'Value': '2.2.2.2' - }], - 'SetIdentifier': 'iad-000', - 'TTL': 99, - 'Type': 'A', - 'Weight': 1, - } - - candidates = [ - # Empty, will test no SetIdentifier - {}, - # Non-matching - { - 'SetIdentifier': 'not-a-match', - }, - # Same set-id, different name - { - 'Name': 'not-a-match', - 'SetIdentifier': 'x12346z', - }, - rrset, - ] - - # Provide a matching rrset so that we'll just use it for the delete - # rathr than building up an almost identical one, note the way we'll - # know that we got the one we passed in is that it'll have a - # HealthCheckId and one that was created wouldn't since DummyProvider - # stubs out the lookup for them - mod = geo.mod('DELETE', candidates) - self.assertEquals('x12346z', mod['ResourceRecordSet']['HealthCheckId']) - - # If we don't provide the candidate rrsets we get back exactly what we - # put in minus the healthcheck - del rrset['HealthCheckId'] - mod = geo.mod('DELETE', []) - self.assertEquals(rrset, mod['ResourceRecordSet']) - - def test_geo_delete(self): - provider = DummyProvider() - geo = _Route53GeoRecord(provider, self.record_a, 'NA-US', - self.record_a.geo['NA-US'], False) - - rrset = { - 'GeoLocation': { - 'CountryCode': 'US' - }, - 'HealthCheckId': 'x12346z', - 'Name': 'unit.tests.', - 'ResourceRecords': [{ - 'Value': '2.2.2.2' - }, { - 'Value': '3.3.3.3' - }], - 'SetIdentifier': 'NA-US', - 'TTL': 99, - 'Type': 'A' - } - - candidates = [ - # Empty, will test no SetIdentifier - {}, - { - 'SetIdentifier': 'not-a-match', - }, - # Same set-id, different name - { - 'Name': 'not-a-match', - 'SetIdentifier': 'x12346z', - }, - rrset, - ] - - # Provide a matching rrset so that we'll just use it for the delete - # rathr than building up an almost identical one, note the way we'll - # know that we got the one we passed in is that it'll have a - # HealthCheckId and one that was created wouldn't since DummyProvider - # stubs out the lookup for them - mod = geo.mod('DELETE', candidates) - self.assertEquals('x12346z', mod['ResourceRecordSet']['HealthCheckId']) - - # If we don't provide the candidate rrsets we get back exactly what we - # put in minus the healthcheck - del rrset['HealthCheckId'] - mod = geo.mod('DELETE', []) - self.assertEquals(rrset, mod['ResourceRecordSet']) - - def test_new_dynamic(self): - provider = Route53Provider('test', 'abc', '123') - - # Just so boto won't try and make any calls - stubber = Stubber(provider._conn) - stubber.activate() - - # We'll assume we create all healthchecks here, this functionality is - # thoroughly tested elsewhere - provider._health_checks = {} - # When asked for a healthcheck return dummy info - provider.get_health_check_id = lambda r, v, s, c: 'hc42' - - zone = Zone('unit.tests.', []) - record = Record.new(zone, '', dynamic_record_data) - - # Convert a record into _Route53Records - route53_records = _Route53Record.new(provider, record, 'z45', - creating=True) - self.assertEquals(18, len(route53_records)) - - expected_mods = [r.mod('CREATE', []) for r in route53_records] - # Sort so that we get a consistent order and don't rely on set ordering - expected_mods.sort(key=_mod_keyer) - - # Convert the route53_records into mods - self.assertEquals([{ - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'HealthCheckId': 'hc42', - 'Name': '_octodns-ap-southeast-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.4.1.1'}], - 'SetIdentifier': 'ap-southeast-1-000', - 'TTL': 60, - 'Type': 'A', - 'Weight': 2} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'HealthCheckId': 'hc42', - 'Name': '_octodns-ap-southeast-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.4.1.2'}], - 'SetIdentifier': 'ap-southeast-1-001', - 'TTL': 60, - 'Type': 'A', - 'Weight': 2} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'Name': '_octodns-default-pool.unit.tests.', - 'ResourceRecords': [ - {'Value': '1.1.2.1'}, - {'Value': '1.1.2.2'}], - 'TTL': 60, - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'HealthCheckId': 'hc42', - 'Name': '_octodns-eu-central-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.3.1.1'}], - 'SetIdentifier': 'eu-central-1-000', - 'TTL': 60, - 'Type': 'A', - 'Weight': 1} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'HealthCheckId': 'hc42', - 'Name': '_octodns-eu-central-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.3.1.2'}], - 'SetIdentifier': 'eu-central-1-001', - 'TTL': 60, - 'Type': 'A', - 'Weight': 1} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'HealthCheckId': 'hc42', - 'Name': '_octodns-us-east-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.5.1.1'}], - 'SetIdentifier': 'us-east-1-000', - 'TTL': 60, - 'Type': 'A', - 'Weight': 1} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'HealthCheckId': 'hc42', - 'Name': '_octodns-us-east-1-value.unit.tests.', - 'ResourceRecords': [{'Value': '1.5.1.2'}], - 'SetIdentifier': 'us-east-1-001', - 'TTL': 60, - 'Type': 'A', - 'Weight': 1} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-ap-southeast-1-value.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'Failover': 'PRIMARY', - 'Name': '_octodns-ap-southeast-1-pool.unit.tests.', - 'SetIdentifier': 'ap-southeast-1-Primary', - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-eu-central-1-value.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'Failover': 'PRIMARY', - 'Name': '_octodns-eu-central-1-pool.unit.tests.', - 'SetIdentifier': 'eu-central-1-Primary', - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-us-east-1-value.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'Failover': 'PRIMARY', - 'Name': '_octodns-us-east-1-pool.unit.tests.', - 'SetIdentifier': 'us-east-1-Primary', - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-us-east-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'Failover': 'SECONDARY', - 'Name': '_octodns-ap-southeast-1-pool.unit.tests.', - 'SetIdentifier': 'ap-southeast-1-Secondary-us-east-1', - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-us-east-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'Failover': 'SECONDARY', - 'Name': '_octodns-eu-central-1-pool.unit.tests.', - 'SetIdentifier': 'eu-central-1-Secondary-us-east-1', - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-default-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'Failover': 'SECONDARY', - 'Name': '_octodns-us-east-1-pool.unit.tests.', - 'SetIdentifier': 'us-east-1-Secondary-default', - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-ap-southeast-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'GeoLocation': { - 'CountryCode': 'CN'}, - 'Name': 'unit.tests.', - 'SetIdentifier': '0-ap-southeast-1-AS-CN', - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-ap-southeast-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'GeoLocation': { - 'CountryCode': 'JP'}, - 'Name': 'unit.tests.', - 'SetIdentifier': '0-ap-southeast-1-AS-JP', - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-eu-central-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'GeoLocation': { - 'ContinentCode': 'EU'}, - 'Name': 'unit.tests.', - 'SetIdentifier': '1-eu-central-1-EU', - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-eu-central-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'GeoLocation': { - 'CountryCode': 'US', - 'SubdivisionCode': 'FL'}, - 'Name': 'unit.tests.', - 'SetIdentifier': '1-eu-central-1-NA-US-FL', - 'Type': 'A'} - }, { - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'AliasTarget': { - 'DNSName': '_octodns-us-east-1-pool.unit.tests.', - 'EvaluateTargetHealth': True, - 'HostedZoneId': 'z45'}, - 'GeoLocation': { - 'CountryCode': '*'}, - 'Name': 'unit.tests.', - 'SetIdentifier': '2-us-east-1-None', - 'Type': 'A'} - }], expected_mods) - - for route53_record in route53_records: - # Smoke test stringification - route53_record.__repr__() - - -class TestModKeyer(TestCase): - - def test_mod_keyer(self): - - # First "column" is the action priority for C/R/U - - # Deletes come first - self.assertEquals((0, 0, 'something'), _mod_keyer({ - 'Action': 'DELETE', - 'ResourceRecordSet': { - 'Name': 'something', - } - })) - - # Creates come next - self.assertEquals((1, 0, 'another'), _mod_keyer({ - 'Action': 'CREATE', - 'ResourceRecordSet': { - 'Name': 'another', - } - })) - - # Upserts are the same as creates - self.assertEquals((1, 0, 'last'), _mod_keyer({ - 'Action': 'UPSERT', - 'ResourceRecordSet': { - 'Name': 'last', - } - })) - - # Second "column" value records tested above - - # AliasTarget primary second (to value) - self.assertEquals((0, -1, 'thing'), _mod_keyer({ - 'Action': 'DELETE', - 'ResourceRecordSet': { - 'AliasTarget': 'some-target', - 'Failover': 'PRIMARY', - 'Name': 'thing', - } - })) - - self.assertEquals((1, 1, 'thing'), _mod_keyer({ - 'Action': 'UPSERT', - 'ResourceRecordSet': { - 'AliasTarget': 'some-target', - 'Failover': 'PRIMARY', - 'Name': 'thing', - } - })) - - # AliasTarget secondary third - self.assertEquals((0, -2, 'thing'), _mod_keyer({ - 'Action': 'DELETE', - 'ResourceRecordSet': { - 'AliasTarget': 'some-target', - 'Failover': 'SECONDARY', - 'Name': 'thing', - } - })) - - self.assertEquals((1, 2, 'thing'), _mod_keyer({ - 'Action': 'UPSERT', - 'ResourceRecordSet': { - 'AliasTarget': 'some-target', - 'Failover': 'SECONDARY', - 'Name': 'thing', - } - })) - - # GeoLocation fourth - self.assertEquals((0, -3, 'some-id'), _mod_keyer({ - 'Action': 'DELETE', - 'ResourceRecordSet': { - 'GeoLocation': 'some-target', - 'SetIdentifier': 'some-id', - } - })) - - self.assertEquals((1, 3, 'some-id'), _mod_keyer({ - 'Action': 'UPSERT', - 'ResourceRecordSet': { - 'GeoLocation': 'some-target', - 'SetIdentifier': 'some-id', - } - })) - # The third "column" has already been tested above, Name/SetIdentifier + def test_missing(self): + with self.assertRaises(ModuleNotFoundError): + from octodns.provider.route53 import Route53Provider + Route53Provider