diff --git a/octodns/provider/ns1.py b/octodns/provider/ns1.py index da2d64a..445e612 100644 --- a/octodns/provider/ns1.py +++ b/octodns/provider/ns1.py @@ -70,13 +70,54 @@ class Ns1Provider(BaseProvider): class: octodns.provider.ns1.Ns1Provider api_key: env/NS1_API_KEY ''' - SUPPORTS_GEO = True - SUPPORTS_DYNAMIC = False + SUPPORTS_GEO = False + SUPPORTS_DYNAMIC = True SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', 'SPF', 'SRV', 'TXT')) ZONE_NOT_FOUND_MESSAGE = 'server error: zone not found' + _DYNAMIC_FILTERS = [{ + 'config': {}, + 'filter': 'up' + }, { + 'config': {}, + 'filter': u'geotarget_regional' + }, { + 'config': {}, + 'filter': u'select_first_region' + }, { + 'config': { + 'eliminate': u'1' + }, + 'filter': 'priority' + }, { + 'config': {}, + 'filter': u'weighted_shuffle' + }, { + 'config': { + 'N': u'1' + }, + 'filter': u'select_first_n' + }] + _REGION_TO_CONTINENT = { + 'AFRICA': 'AF', + 'ASIAPAC': 'AS', + 'EUROPE': 'EU', + 'SOUTH-AMERICA': 'SA', + 'US-CENTRAL': 'NA', + 'US-EAST': 'NA', + 'US-WEST': 'NA', + } + _CONTINENT_TO_REGIONS = { + 'AF': ('AFRICA',), + 'AS': ('ASIAPAC',), + 'EU': ('EUROPE',), + 'SA': ('SOUTH-AMERICA',), + # TODO: what about CA, MX, and all the other NA countries? + 'NA': ('US-CENTRAL', 'US-EAST', 'US-WEST'), + } + def __init__(self, id, api_key, retry_count=4, *args, **kwargs): self.log = getLogger('Ns1Provider[{}]'.format(id)) self.log.debug('__init__: id=%s, api_key=***, retry_count=%d', id, @@ -282,9 +323,124 @@ class Ns1Provider(BaseProvider): len(zone.records) - before, exists) return exists + def _encode_notes(self, data): + return ' '.join(['{}:{}'.format(k, v) + for k, v in sorted(data.items())]) + def _params_for_A(self, record): - params = {'answers': record.values, 'ttl': record.ttl} - if hasattr(record, 'geo'): + params = {'ttl': record.ttl} + + if hasattr(record, 'dynamic'): + + pools = record.dynamic.pools + + # Convert rules to regions + regions = {} + for i, rule in enumerate(record.dynamic.rules): + pool_name = rule.data['pool'] + + notes = { + 'rule-order': i, + } + + fallback = pools[pool_name].data.get('fallback', None) + if fallback: + notes['fallback'] = fallback + + country = set() + georegion = set() + us_state = set() + + for geo in rule.data.get('geos', []): + n = len(geo) + if n == 8: + # US state + us_state.add(geo[-2:]) + elif n == 5: + # Country + country.add(geo[-2:]) + else: + # Continent + georegion.update(self._CONTINENT_TO_REGIONS[geo]) + + meta = { + 'note': self._encode_notes(notes), + } + if georegion: + meta['georegion'] = sorted(georegion) + if country: + meta['country'] = sorted(country) + if us_state: + meta['us_state'] = sorted(us_state) + + regions[pool_name] = { + 'meta': meta, + } + + # Build a list of primary values for each pool + pool_answers = defaultdict(list) + for pool_name, pool in sorted(pools.items()): + for value in pool.data['values']: + pool_answers[pool_name].append({ + 'answer': [value['value']], + 'weight': value['weight'], + }) + + default_answers = [{ + 'answer': [v], + 'weight': 1, + } for v in record.values] + + # Build our list of answers + answers = [] + for pool_name in sorted(pools.keys()): + priority = 1 + + # Dynamic/health checked + current_pool_name = pool_name + while current_pool_name: + pool = pools[current_pool_name] + for answer in pool_answers[current_pool_name]: + answer = { + 'answer': answer['answer'], + 'meta': { + 'priority': priority, + 'note': self._encode_notes({ + 'from': current_pool_name, + }), + 'weight': answer['weight'], + }, + 'region': pool_name, # the one we're answering + } + answers.append(answer) + + current_pool_name = pool.data.get('fallback', None) + priority += 1 + + # Static/default + for answer in default_answers: + answer = { + 'answer': answer['answer'], + 'meta': { + 'priority': priority, + 'note': self._encode_notes({ + 'from': '--default--', + }), + 'weight': 1, + }, + 'region': pool_name, # the one we're answering + } + answers.append(answer) + + params.update({ + 'answers': answers, + 'filters': self._DYNAMIC_FILTERS, + 'regions': regions, + }) + + return params + + elif hasattr(record, 'geo'): # purposefully set non-geo answers to have an empty meta, # so that we know we did this on purpose if/when troubleshooting params['answers'] = [{"answer": [x], "meta": {}} @@ -315,6 +471,9 @@ class Ns1Provider(BaseProvider): {"filter": "select_first_n", "config": {"N": 1}} ) + else: + params['answers'] = record.values + self.log.debug("params for A: %s", params) return params