diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c02e43..2922a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ https://github.com/octodns/octodns/pull/822 for more information. Providers that have been extracted in this release include: * [PowerDnsProvider](https://github.com/octodns/octodns-powerdns/) + * [ConstellixProvider](https://github.com/octodns/octodns-constellix/) * 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/README.md b/README.md index b23fa98..4a977cd 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ The table below lists the providers octoDNS supports. We're currently in the pro | [AzureProvider](/octodns/provider/azuredns.py) | | azure-identity, azure-mgmt-dns, azure-mgmt-trafficmanager | A, AAAA, CAA, CNAME, MX, NS, PTR, SRV, TXT | Alpha (A, AAAA, CNAME) | | | [Akamai](/octodns/provider/edgedns.py) | | edgegrid-python | A, AAAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT | No | | | [CloudflareProvider](/octodns/provider/cloudflare.py) | | | A, AAAA, ALIAS, CAA, CNAME, LOC, MX, NS, PTR, SPF, SRV, TXT, URLFWD | No | CAA tags restricted | -| [ConstellixProvider](/octodns/provider/constellix.py) | | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | Yes | CAA tags restricted | +| [ConstellixProvider](https://github.com/octodns/octodns-constellix/) | [octodns_constellix](https://github.com/octodns/octodns-constellix/) | | | | | | [DigitalOceanProvider](/octodns/provider/digitalocean.py) | | | A, AAAA, CAA, CNAME, MX, NS, TXT, SRV | No | CAA tags restricted | | [DnsMadeEasyProvider](/octodns/provider/dnsmadeeasy.py) | | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted | | [DnsimpleProvider](/octodns/provider/dnsimple.py) | | | All | No | CAA tags restricted | diff --git a/octodns/provider/constellix.py b/octodns/provider/constellix.py index 50e56ec..d39b581 100644 --- a/octodns/provider/constellix.py +++ b/octodns/provider/constellix.py @@ -5,1113 +5,19 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -from collections import defaultdict -from requests import Session -from base64 import b64encode, standard_b64encode -from pycountry_convert import country_alpha2_to_continent_code -import hashlib -import hmac -import logging -import time - -from ..record import Record -from . import ProviderException -from .base import BaseProvider - - -class ConstellixClientException(ProviderException): - pass - - -class ConstellixClientBadRequest(ConstellixClientException): - - def __init__(self, resp): - errors = '\n - '.join(resp.json()['errors']) - super(ConstellixClientBadRequest, self).__init__(f'\n - {errors}') - - -class ConstellixClientUnauthorized(ConstellixClientException): - - def __init__(self): - super(ConstellixClientUnauthorized, self).__init__('Unauthorized') - - -class ConstellixClientNotFound(ConstellixClientException): - - def __init__(self): - super(ConstellixClientNotFound, self).__init__('Not Found') - - -class ConstellixClient(object): - BASE = 'https://api.dns.constellix.com/v1' - - def __init__(self, api_key, secret_key, ratelimit_delay=0.0): - self.api_key = api_key - self.secret_key = secret_key - self.ratelimit_delay = ratelimit_delay - self._sess = Session() - self._sess.headers.update({'x-cnsdns-apiKey': self.api_key}) - self._domains = None - self._pools = {'A': None, 'AAAA': None, 'CNAME': None} - self._geofilters = None - - def _current_time(self): - return str(int(time.time() * 1000)) - - def _hmac_hash(self, now): - return hmac.new(self.secret_key.encode('utf-8'), now.encode('utf-8'), - digestmod=hashlib.sha1).digest() - - def _request(self, method, path, params=None, data=None): - now = self._current_time() - hmac_hash = self._hmac_hash(now) - - headers = { - 'x-cnsdns-hmac': b64encode(hmac_hash), - 'x-cnsdns-requestDate': now - } - - url = f'{self.BASE}{path}' - resp = self._sess.request(method, url, headers=headers, - params=params, json=data) - if resp.status_code == 400: - raise ConstellixClientBadRequest(resp) - if resp.status_code == 401: - raise ConstellixClientUnauthorized() - if resp.status_code == 404: - raise ConstellixClientNotFound() - resp.raise_for_status() - time.sleep(self.ratelimit_delay) - return resp - - @property - def domains(self): - if self._domains is None: - zones = [] - - resp = self._request('GET', '/domains').json() - zones += resp - - self._domains = {f'{z["name"]}.': z['id'] for z in zones} - - return self._domains - - def domain(self, name): - zone_id = self.domains.get(name, False) - if not zone_id: - raise ConstellixClientNotFound() - path = f'/domains/{zone_id}' - return self._request('GET', path).json() - - def domain_create(self, name): - resp = self._request('POST', '/domains', data={'names': [name]}) - # Add newly created zone to domain cache - self._domains[f'{name}.'] = resp.json()[0]['id'] - - def domain_enable_geoip(self, domain_name): - domain = self.domain(domain_name) - if domain['hasGeoIP'] is False: - domain_id = self.domains[domain_name] - self._request( - 'PUT', - f'/domains/{domain_id}', - data={'hasGeoIP': True} - ) - - def _absolutize_value(self, value, zone_name): - if value == '': - value = zone_name - elif not value.endswith('.'): - value = f'{value}.{zone_name}' - - return value - - def records(self, zone_name): - zone_id = self.domains.get(zone_name, False) - if not zone_id: - raise ConstellixClientNotFound() - path = f'/domains/{zone_id}/records' - - resp = self._request('GET', path).json() - for record in resp: - # change ANAME records to ALIAS - if record['type'] == 'ANAME': - record['type'] = 'ALIAS' - - # change relative values to absolute - value = record['value'] - if record['type'] in ['ALIAS', 'CNAME', 'MX', 'NS', 'SRV']: - if isinstance(value, str): - record['value'] = self._absolutize_value(value, - zone_name) - if isinstance(value, list): - for v in value: - v['value'] = self._absolutize_value(v['value'], - zone_name) - - return resp - - def record_create(self, zone_name, record_type, params): - # change ALIAS records to ANAME - if record_type == 'ALIAS': - record_type = 'ANAME' - - zone_id = self.domains.get(zone_name, False) - path = f'/domains/{zone_id}/records/{record_type}' - - self._request('POST', path, data=params) - - def record_delete(self, zone_name, record_type, record_id): - # change ALIAS records to ANAME - if record_type == 'ALIAS': - record_type = 'ANAME' - - zone_id = self.domains.get(zone_name, False) - path = f'/domains/{zone_id}/records/{record_type}/{record_id}' - self._request('DELETE', path) - - def pools(self, pool_type): - if self._pools[pool_type] is None: - self._pools[pool_type] = {} - path = f'/pools/{pool_type}' - response = self._request('GET', path).json() - for pool in response: - self._pools[pool_type][pool['id']] = pool - return self._pools[pool_type].values() - - def pool(self, pool_type, pool_name): - pools = self.pools(pool_type) - for pool in pools: - if pool['name'] == pool_name and pool['type'] == pool_type: - return pool - return None - - def pool_by_id(self, pool_type, pool_id): - pools = self.pools(pool_type) - for pool in pools: - if pool['id'] == pool_id: - return pool - return None - - def pool_create(self, data): - path = f'/pools/{data.get("type")}' - # This returns a list of items, we want the first one - response = self._request('POST', path, data=data).json() - - # Update our cache - self._pools[data.get('type')][response[0]['id']] = response[0] - return response[0] - - def pool_update(self, pool_id, data): - path = f'/pools/{data.get("type")}/{pool_id}' - try: - self._request('PUT', path, data=data).json() - - except ConstellixClientBadRequest as e: - message = str(e) - if not message or "no changes to save" not in message: - raise e - return data - - def pool_delete(self, pool_type, pool_id): - path = f'/pools/{pool_type}/{pool_id}' - self._request('DELETE', path) - - # Update our cache - if self._pools[pool_type] is not None: - self._pools[pool_type].pop(pool_id, None) - - def geofilters(self): - if self._geofilters is None: - self._geofilters = {} - path = '/geoFilters' - response = self._request('GET', path).json() - for geofilter in response: - self._geofilters[geofilter['id']] = geofilter - return self._geofilters.values() - - def geofilter(self, geofilter_name): - geofilters = self.geofilters() - for geofilter in geofilters: - if geofilter['name'] == geofilter_name: - return geofilter - return None - - def geofilter_by_id(self, geofilter_id): - geofilters = self.geofilters() - for geofilter in geofilters: - if geofilter['id'] == geofilter_id: - return geofilter - return None - - def geofilter_create(self, data): - path = '/geoFilters' - response = self._request('POST', path, data=data).json() - - # Update our cache - self._geofilters[response[0]['id']] = response[0] - return response[0] - - def geofilter_update(self, geofilter_id, data): - path = f'/geoFilters/{geofilter_id}' - try: - self._request('PUT', path, data=data).json() - - except ConstellixClientBadRequest as e: - message = str(e) - if not message or "no changes to save" not in message: - raise e - return data - - def geofilter_delete(self, geofilter_id): - path = f'/geoFilters/{geofilter_id}' - self._request('DELETE', path) - - # Update our cache - if self._geofilters is not None: - self._geofilters.pop(geofilter_id, None) - - -class SonarClientException(ProviderException): - pass - - -class SonarClientBadRequest(SonarClientException): - - def __init__(self, resp): - errors = resp.text - super(SonarClientBadRequest, self).__init__(f'\n - {errors}') - - -class SonarClientUnauthorized(SonarClientException): - - def __init__(self): - super(SonarClientUnauthorized, self).__init__('Unauthorized') - - -class SonarClientNotFound(SonarClientException): - - def __init__(self): - super(SonarClientNotFound, self).__init__('Not Found') - - -class SonarClient(object): - BASE = 'https://api.sonar.constellix.com/rest/api' - - def __init__(self, log, api_key, secret_key, ratelimit_delay=0.0): - self.log = log - self.api_key = api_key - self.secret_key = secret_key - self.ratelimit_delay = ratelimit_delay - self._sess = Session() - self._sess.headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'octoDNS', - } - self._agents = None - self._checks = {'tcp': None, 'http': None} - - def _current_time_ms(self): - return str(int(time.time() * 1000)) - - def _hmac_hash(self, now): - digester = hmac.new( - bytes(self.secret_key, "UTF-8"), - bytes(now, "UTF-8"), - hashlib.sha1) - signature = digester.digest() - hmac_text = str(standard_b64encode(signature), "UTF-8") - return hmac_text - - def _request(self, method, path, params=None, data=None): - now = self._current_time_ms() - hmac_text = self._hmac_hash(now) - - headers = { - 'x-cns-security-token': "{}:{}:{}".format( - self.api_key, - hmac_text, - now) - } - - url = f'{self.BASE}{path}' - resp = self._sess.request(method, url, headers=headers, - params=params, json=data) - if resp.status_code == 400: - raise SonarClientBadRequest(resp) - if resp.status_code == 401: - raise SonarClientUnauthorized() - if resp.status_code == 404: - raise SonarClientNotFound() - resp.raise_for_status() - - if self.ratelimit_delay >= 1.0: - self.log.info("Waiting for Sonar Rate Limit Delay") - elif self.ratelimit_delay > 0.0: - self.log.debug("Waiting for Sonar Rate Limit Delay") - time.sleep(self.ratelimit_delay) - - return resp - - @property - def agents(self): - if self._agents is None: - agents = [] - - data = self._request('GET', '/system/sites').json() - agents += data - - self._agents = {f'{a["name"]}.': a for a in agents} - - return self._agents - - def agents_for_regions(self, regions): - if regions[0] == "WORLD": - res_agents = [] - for agent in self.agents.values(): - res_agents.append(agent['id']) - return res_agents - - res_agents = [] - for agent in self.agents.values(): - if agent["region"] in regions: - res_agents.append(agent['id']) - return res_agents - - def parse_uri_id(self, url): - r = str(url).rfind("/") - res = str(url)[r + 1:] - return res - - def checks(self, check_type): - if self._checks[check_type] is None: - self._checks[check_type] = {} - path = f'/{check_type}' - data = self._request('GET', path).json() - for check in data: - self._checks[check_type][check['id']] = check - return self._checks[check_type].values() - - def check(self, check_type, check_name): - checks = self.checks(check_type) - for check in checks: - if check['name'] == check_name: - return check - return None - - def check_create(self, check_type, data): - path = f'/{check_type}' - response = self._request('POST', path, data=data) - # Parse check ID from Location response header - id = self.parse_uri_id(response.headers["Location"]) - # Get check details - path = f'/{check_type}/{id}' - data = self._request('GET', path, data=data).json() - - # Update our cache - self._checks[check_type]['id'] = data - return data - - def check_delete(self, check_id): - # first get check type - path = f'/check/type/{check_id}' - data = self._request('GET', path).json() - check_type = data['type'].lower() - - path = f'/{check_type}/{check_id}' - self._request('DELETE', path) - - # Update our cache - self._checks[check_type].pop(check_id, None) - - -class ConstellixProvider(BaseProvider): - ''' - Constellix DNS provider - - constellix: - class: octodns.provider.constellix.ConstellixProvider - # Your Contellix api key (required) - api_key: env/CONSTELLIX_API_KEY - # Your Constellix secret key (required) - secret_key: env/CONSTELLIX_SECRET_KEY - # Amount of time to wait between requests to avoid - # ratelimit (optional) - ratelimit_delay: 0.0 - ''' - SUPPORTS_GEO = False - SUPPORTS_DYNAMIC = True - SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', - 'NS', 'PTR', 'SPF', 'SRV', 'TXT')) - - def __init__(self, id, api_key, secret_key, ratelimit_delay=0.0, - *args, **kwargs): - self.log = logging.getLogger(f'ConstellixProvider[{id}]') - self.log.debug('__init__: id=%s, api_key=***, secret_key=***', id) - super(ConstellixProvider, self).__init__(id, *args, **kwargs) - self._client = ConstellixClient(api_key, secret_key, ratelimit_delay) - self._sonar = SonarClient( - self.log, api_key, secret_key, ratelimit_delay - ) - self._zone_records = {} - - def _data_for_multiple(self, _type, records): - record = records[0] - if record['recordOption'] == 'pools': - return self._data_for_pool(_type, records) - return { - 'ttl': record['ttl'], - 'type': _type, - 'values': record['value'] - } - - def _data_for_pool(self, _type, records): - default_values = [] - fallback_pool_name = None - pools = {} - rules = [] - - for record in records: - # fetch record pool data - pool_id = record['pools'][0] - pool = self._client.pool_by_id(_type, pool_id) - - geofilter_id = 1 - if 'geolocation' in record.keys() \ - and record['geolocation'] is not None: - # fetch record geofilter data - geofilter_id = record['geolocation']['geoipFilter'] - geofilter = self._client.geofilter_by_id(geofilter_id) - - pool_name = pool['name'].split(':')[-1] - - # fetch default values from the World Default pool - if geofilter_id == 1: - fallback_pool_name = pool_name - for value in pool['values']: - default_values.append(value['value']) - - # populate pools - pools[pool_name] = { - 'fallback': None, - 'values': [] - } - for value in pool['values']: - pools[pool_name]['values'].append({ - 'value': value['value'], - 'weight': value['weight'] - }) - - # populate rules - if geofilter_id == 1: - rules.append({'pool': pool_name}) - else: - geos = [] - - if 'geoipContinents' in geofilter.keys(): - for continent_code in geofilter['geoipContinents']: - geos.append(continent_code) - - if 'geoipCountries' in geofilter.keys(): - for country_code in geofilter['geoipCountries']: - continent_code = \ - country_alpha2_to_continent_code(country_code) - geos.append(f'{continent_code}-{country_code}') - - if 'regions' in geofilter.keys(): - for region in geofilter['regions']: - geos.append(f'{region["continentCode"]}-' - f'{region["countryCode"]}-' - f'{region["regionCode"]}') - - rules.append({ - 'pool': pool_name, - 'geos': sorted(geos) - }) - - # set fallback pool - for pool_name in pools: - if pool_name != fallback_pool_name: - pools[pool_name]['fallback'] = fallback_pool_name - - res = { - 'ttl': record['ttl'], - 'type': _type, - 'dynamic': { - 'pools': dict( - sorted(pools.items(), key=lambda t: t[0])), - 'rules': sorted(rules, key=lambda t: t['pool']) - }, - 'values': default_values - } - return res - - _data_for_A = _data_for_multiple - _data_for_AAAA = _data_for_multiple - - def _data_for_CAA(self, _type, records): - values = [] - record = records[0] - for value in record['value']: - values.append({ - 'flags': value['flag'], - 'tag': value['tag'], - 'value': value['data'] - }) - return { - 'ttl': records[0]['ttl'], - 'type': _type, - 'values': values - } - - def _data_for_NS(self, _type, records): - record = records[0] - return { - 'ttl': record['ttl'], - 'type': _type, - 'values': [value['value'] for value in record['value']] - } - - def _data_for_ALIAS(self, _type, records): - record = records[0] - return { - 'ttl': record['ttl'], - 'type': _type, - 'value': record['value'][0]['value'] - } - - _data_for_PTR = _data_for_ALIAS - - def _data_for_TXT(self, _type, records): - values = [value['value'].replace(';', '\\;') - for value in records[0]['value']] - return { - 'ttl': records[0]['ttl'], - 'type': _type, - 'values': values - } - - _data_for_SPF = _data_for_TXT - - def _data_for_MX(self, _type, records): - values = [] - record = records[0] - for value in record['value']: - values.append({ - 'preference': value['level'], - 'exchange': value['value'] - }) - return { - 'ttl': records[0]['ttl'], - 'type': _type, - 'values': values - } - - def _data_for_single(self, _type, records): - record = records[0] - return { - 'ttl': record['ttl'], - 'type': _type, - 'value': record['value'] - } - - _data_for_CNAME = _data_for_single - - def _data_for_SRV(self, _type, records): - values = [] - record = records[0] - for value in record['value']: - values.append({ - 'port': value['port'], - 'priority': value['priority'], - 'target': value['value'], - 'weight': value['weight'] - }) - return { - 'type': _type, - 'ttl': records[0]['ttl'], - 'values': values - } - - def zone_records(self, zone): - if zone.name not in self._zone_records: - try: - self._zone_records[zone.name] = \ - self._client.records(zone.name) - except ConstellixClientNotFound: - return [] - - return self._zone_records[zone.name] - - def populate(self, zone, target=False, lenient=False): - self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, - target, lenient) - - values = defaultdict(lambda: defaultdict(list)) - for record in self.zone_records(zone): - _type = record['type'] - if _type not in self.SUPPORTS: - self.log.warning('populate: skipping unsupported %s record', - _type) - continue - values[record['name']][record['type']].append(record) - - before = len(zone.records) - for name, types in values.items(): - for _type, records in types.items(): - data_for = getattr(self, f'_data_for_{_type}') - record = Record.new(zone, name, data_for(_type, records), - source=self, lenient=lenient) - zone.add_record(record, lenient=lenient) - - exists = zone.name in self._zone_records - self.log.info('populate: found %s records, exists=%s', - len(zone.records) - before, exists) - return exists - - def _healthcheck_config(self, record): - sonar_healthcheck = record._octodns.get('constellix', {}) \ - .get('healthcheck', None) - - if sonar_healthcheck is None: - return None - - healthcheck = {} - healthcheck["sonar_port"] = sonar_healthcheck.get('sonar_port', 80) - healthcheck["sonar_type"] = sonar_healthcheck.get('sonar_type', "TCP") - healthcheck["sonar_regions"] = sonar_healthcheck.get( - 'sonar_regions', - ["WORLD"] - ) - healthcheck["sonar_interval"] = sonar_healthcheck.get( - 'sonar_interval', - "ONEMINUTE" - ) - - return healthcheck - - def _params_for_multiple(self, record): - yield { - 'name': record.name, - 'ttl': record.ttl, - 'roundRobin': [{ - 'value': value - } for value in record.values] - } - - _params_for_A = _params_for_multiple - _params_for_AAAA = _params_for_multiple - - # An A record with this name must exist in this domain for - # this NS record to be valid. Need to handle checking if - # there is an A record before creating NS - _params_for_NS = _params_for_multiple - - def _params_for_single(self, record): - yield { - 'name': record.name, - 'ttl': record.ttl, - 'host': record.value, - } - - _params_for_CNAME = _params_for_single - - def _params_for_ALIAS(self, record): - yield { - 'name': record.name, - 'ttl': record.ttl, - 'roundRobin': [{ - 'value': record.value, - 'disableFlag': False - }] - } - - _params_for_PTR = _params_for_ALIAS - - def _params_for_MX(self, record): - values = [] - for value in record.values: - values.append({ - 'value': value.exchange, - 'level': value.preference - }) - yield { - 'value': value.exchange, - 'name': record.name, - 'ttl': record.ttl, - 'roundRobin': values - } - - def _params_for_SRV(self, record): - values = [] - for value in record.values: - values.append({ - 'value': value.target, - 'priority': value.priority, - 'weight': value.weight, - 'port': value.port - }) - for value in record.values: - yield { - 'name': record.name, - 'ttl': record.ttl, - 'roundRobin': values - } - - def _params_for_TXT(self, record): - # Constellix does not want values escaped - values = [] - for value in record.chunked_values: - values.append({ - 'value': value.replace('\\;', ';') - }) - yield { - 'name': record.name, - 'ttl': record.ttl, - 'roundRobin': values - } - - _params_for_SPF = _params_for_TXT - - def _params_for_CAA(self, record): - values = [] - for value in record.values: - values.append({ - 'tag': value.tag, - 'data': value.value, - 'flag': value.flags, - }) - yield { - 'name': record.name, - 'ttl': record.ttl, - 'roundRobin': values - } - - def _handle_pools(self, record): - healthcheck = self._healthcheck_config(record) - - # If we don't have dynamic, then there's no pools - if not getattr(record, 'dynamic', False): - return [] - - res_pools = [] - - for i, rule in enumerate(record.dynamic.rules): - pool_name = rule.data.get('pool') - pool = record.dynamic.pools.get(pool_name) - values = [ - { - 'value': value['value'], - 'weight': value['weight'], - } for value in pool.data.get('values', []) - ] - - # Make a pool name based on zone, record, type and name - generated_pool_name = \ - f'{record.zone.name}:{record.name}:{record._type}:{pool_name}' - - # Create Sonar checks if needed - if healthcheck is not None: - check_sites = self._sonar.\ - agents_for_regions(healthcheck["sonar_regions"]) - for value in values: - check_obj = self._create_update_check( - pool_type = record._type, - check_name = '{}-{}'.format( - generated_pool_name, - value['value'] - ), - check_type = healthcheck["sonar_type"].lower(), - value = value['value'], - port = healthcheck["sonar_port"], - interval = healthcheck["sonar_interval"], - sites = check_sites - ) - value['checkId'] = check_obj['id'] - value['policy'] = "followsonar" - - # OK, pool is valid, let's create it or update it - self.log.debug("Creating pool %s", generated_pool_name) - pool_obj = self._create_update_pool( - pool_name = generated_pool_name, - pool_type = record._type, - ttl = record.ttl, - values = values - ) - - # Now will crate GeoFilter for the pool - continents = [] - countries = [] - regions = [] - - for geo in rule.data.get('geos', []): - codes = geo.split('-') - n = len(geo) - if n == 2: - continents.append(geo) - elif n == 5: - countries.append(codes[1]) - else: - regions.append({ - 'continentCode': codes[0], - 'countryCode': codes[1], - 'regionCode': codes[2] - }) - - if len(continents) == 0 and \ - len(countries) == 0 and \ - len(regions) == 0: - pool_obj['geofilter'] = 1 - else: - self.log.debug( - "Creating geofilter %s", - generated_pool_name - ) - geofilter_obj = self._create_update_geofilter( - generated_pool_name, - continents, - countries, - regions - ) - pool_obj['geofilter'] = geofilter_obj['id'] - - res_pools.append(pool_obj) - return res_pools - - def _create_update_check( - self, - pool_type, - check_name, - check_type, - value, - port, - interval, - sites): - - check = { - 'name': check_name, - 'host': value, - 'port': port, - 'checkSites': sites, - 'interval': interval - } - if pool_type == "AAAA": - check['ipVersion'] = "IPV6" - else: - check['ipVersion'] = "IPV4" - - if check_type == "http": - check['protocolType'] = "HTTPS" - - existing_check = self._sonar.check(check_type, check_name) - if existing_check: - self._sonar.check_delete(existing_check['id']) - - return self._sonar.check_create(check_type, check) - - def _create_update_pool(self, pool_name, pool_type, ttl, values): - pool = { - 'name': pool_name, - 'type': pool_type, - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': ttl, - 'values': values - } - existing_pool = self._client.pool(pool_type, pool_name) - if not existing_pool: - return self._client.pool_create(pool) - - pool_id = existing_pool['id'] - updated_pool = self._client.pool_update(pool_id, pool) - updated_pool['id'] = pool_id - return updated_pool - - def _create_update_geofilter( - self, - geofilter_name, - continents, - countries, - regions): - geofilter = { - 'filterRulesLimit': 100, - 'name': geofilter_name, - 'geoipContinents': continents, - 'geoipCountries': countries, - 'regions': regions - } - if len(regions) == 0: - geofilter.pop('regions', None) - - existing_geofilter = self._client.geofilter(geofilter_name) - if not existing_geofilter: - return self._client.geofilter_create(geofilter) - - geofilter_id = existing_geofilter['id'] - updated_geofilter = self._client.geofilter_update( - geofilter_id, geofilter) - updated_geofilter['id'] = geofilter_id - return updated_geofilter - - def _apply_Create(self, change, domain_name): - new = change.new - params_for = getattr(self, f'_params_for_{new._type}') - pools = self._handle_pools(new) - - for params in params_for(new): - if len(pools) == 0: - self._client.record_create(new.zone.name, new._type, params) - elif len(pools) == 1: - params['pools'] = [pools[0]['id']] - params['recordOption'] = 'pools' - params.pop('roundRobin', None) - self.log.debug( - "Creating record %s %s", - new.zone.name, - new._type - ) - self._client.record_create( - new.zone.name, - new._type, - params - ) - else: - # To use GeoIPFilter feature we need to enable it for domain - self.log.debug("Enabling domain %s geo support", domain_name) - self._client.domain_enable_geoip(domain_name) - - # First we need to create World Default (1) Record - for pool in pools: - if pool['geofilter'] != 1: - continue - params['pools'] = [pool['id']] - params['recordOption'] = 'pools' - params['geolocation'] = { - 'geoipUserRegion': [pool['geofilter']] - } - params.pop('roundRobin', None) - self.log.debug( - "Creating record %s %s", - new.zone.name, - new._type) - self._client.record_create( - new.zone.name, - new._type, - params - ) - - # Now we can create the rest of records - for pool in pools: - if pool['geofilter'] == 1: - continue - params['pools'] = [pool['id']] - params['recordOption'] = 'pools' - params['geolocation'] = { - 'geoipUserRegion': [pool['geofilter']] - } - params.pop('roundRobin', None) - self.log.debug( - "Creating record %s %s", - new.zone.name, - new._type) - self._client.record_create( - new.zone.name, - new._type, - params) - - def _apply_Update(self, change, domain_name): - self._apply_Delete(change, domain_name) - self._apply_Create(change, domain_name) - - def _apply_Delete(self, change, domain_name): - existing = change.existing - zone = existing.zone - - # if it is dynamic pools record, we need to delete World Default last - world_default_record = None - - for record in self.zone_records(zone): - if existing.name == record['name'] and \ - existing._type == record['type']: - - # handle dynamic record - if record['recordOption'] == 'pools': - if record['geolocation'] is None: - world_default_record = record - else: - if record['geolocation']['geoipFilter'] == 1: - world_default_record = record - else: - # delete record - self.log.debug( - "Deleting record %s %s", - zone.name, - record['type']) - self._client.record_delete( - zone.name, - record['type'], - record['id']) - # delete geofilter - self.log.debug( - "Deleting geofilter %s", - zone.name) - self._client.geofilter_delete( - record['geolocation']['geoipFilter']) - - # delete pool - self.log.debug( - "Deleting pool %s %s", - zone.name, - record['type']) - self._client.pool_delete( - record['type'], - record['pools'][0]) - - # for all the rest records - else: - self._client.record_delete( - zone.name, record['type'], record['id']) - # delete World Default - if world_default_record: - # delete record - self.log.debug( - "Deleting record %s %s", - zone.name, - world_default_record['type'] - ) - self._client.record_delete( - zone.name, - world_default_record['type'], - world_default_record['id'] - ) - # delete pool - self.log.debug( - "Deleting pool %s %s", - zone.name, - world_default_record['type'] - ) - self._client.pool_delete( - world_default_record['type'], - world_default_record['pools'][0] - ) - - def _apply(self, plan): - desired = plan.desired - changes = plan.changes - self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, - len(changes)) - - try: - self._client.domain(desired.name) - except ConstellixClientNotFound: - self.log.debug('_apply: no matching zone, creating domain') - self._client.domain_create(desired.name[:-1]) - - for change in changes: - class_name = change.__class__.__name__ - getattr(self, f'_apply_{class_name}')( - change, - desired.name) - - # Clear out the cache if any - self._zone_records.pop(desired.name, None) +from logging import getLogger + +logger = getLogger('Constellix') +try: + logger.warn('octodns_constellix shimmed. Update your provider class to ' + 'octodns_constellix.ConstellixProvider. ' + 'Shim will be removed in 1.0') + from octodns_constellix import ConstellixProvider, ConstellixBaseProvider + ConstellixProvider # pragma: no cover + ConstellixBaseProvider # pragma: no cover +except ModuleNotFoundError: + logger.exception('ConstellixProvider has been moved into a seperate ' + 'module, octodns_constellix is now required. Provider ' + 'class should be updated to ' + 'octodns_constellix.ConstellixProvider') + raise diff --git a/tests/fixtures/constellix-domains.json b/tests/fixtures/constellix-domains.json deleted file mode 100644 index 4b6392d..0000000 --- a/tests/fixtures/constellix-domains.json +++ /dev/null @@ -1,28 +0,0 @@ -[{ - "id": 123123, - "name": "unit.tests", - "soa": { - "primaryNameserver": "ns11.constellix.com.", - "email": "dns.constellix.com.", - "ttl": 86400, - "serial": 2015010102, - "refresh": 43200, - "retry": 3600, - "expire": 1209600, - "negCache": 180 - }, - "createdTs": "2019-08-07T03:36:02Z", - "modifiedTs": "2019-08-07T03:36:02Z", - "typeId": 1, - "domainTags": [], - "folder": null, - "hasGtdRegions": false, - "hasGeoIP": false, - "nameserverGroup": 1, - "nameservers": ["ns11.constellix.com.", "ns21.constellix.com.", "ns31.constellix.com.", "ns41.constellix.net.", "ns51.constellix.net.", "ns61.constellix.net."], - "note": "", - "version": 0, - "status": "ACTIVE", - "tags": [], - "contactIds": [] -}] diff --git a/tests/fixtures/constellix-geofilters.json b/tests/fixtures/constellix-geofilters.json deleted file mode 100644 index eef17a3..0000000 --- a/tests/fixtures/constellix-geofilters.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - { - "id": 6303, - "name": "some.other", - "filterRulesLimit": 100, - "createdTs": "2021-08-19T14:47:47Z", - "modifiedTs": "2021-08-19T14:47:47Z", - "geoipContinents": ["AS", "OC"], - "geoipCountries": ["ES", "SE", "UA"], - "regions": [ - { - "continentCode": "NA", - "countryCode": "CA", - "regionCode": "NL" - } - ] - }, - { - "id": 5303, - "name": "unit.tests.:www.dynamic:A:one", - "filterRulesLimit": 100, - "createdTs": "2021-08-19T14:47:47Z", - "modifiedTs": "2021-08-19T14:47:47Z", - "geoipContinents": ["AS", "OC"], - "geoipCountries": ["ES", "SE", "UA"], - "regions": [ - { - "continentCode": "NA", - "countryCode": "CA", - "regionCode": "NL" - } - ] - } -] diff --git a/tests/fixtures/constellix-pools.json b/tests/fixtures/constellix-pools.json deleted file mode 100644 index 8d90bd4..0000000 --- a/tests/fixtures/constellix-pools.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:two", - "type": "A", - "numReturn": 1, - "minAvailableFailover": 1, - "createdTs": "2020-09-12T00:44:35Z", - "modifiedTs": "2020-09-12T00:44:35Z", - "appliedDomains": [ - { - "id": 123123, - "name": "unit.tests", - "recordOption": "pools" - } - ], - "appliedTemplates": null, - "unlinkedDomains": [], - "unlinkedTemplates": null, - "itoEnabled": false, - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - }, - { - "value": "1.2.3.5", - "weight": 1 - } - ] - }, - { - "id": 1808522, - "name": "unit.tests.:www.dynamic:A:one", - "type": "A", - "numReturn": 1, - "minAvailableFailover": 1, - "createdTs": "2020-09-12T00:44:35Z", - "modifiedTs": "2020-09-12T00:44:35Z", - "appliedDomains": [ - { - "id": 123123, - "name": "unit.tests", - "recordOption": "pools" - } - ], - "appliedTemplates": null, - "unlinkedDomains": [], - "unlinkedTemplates": null, - "itoEnabled": false, - "values": [ - { - "value": "1.2.3.6", - "weight": 1 - }, - { - "value": "1.2.3.7", - "weight": 1 - } - ] - } -] \ No newline at end of file diff --git a/tests/fixtures/constellix-records.json b/tests/fixtures/constellix-records.json deleted file mode 100644 index c5cdf8e..0000000 --- a/tests/fixtures/constellix-records.json +++ /dev/null @@ -1,696 +0,0 @@ -[{ - "id": 1808529, - "type": "CAA", - "recordType": "caa", - "name": "", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 3600, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565149569216, - "value": [{ - "flag": 0, - "tag": "issue", - "data": "ca.unit.tests", - "caaProviderId": 1, - "disableFlag": false - }], - "roundRobin": [{ - "flag": 0, - "tag": "issue", - "data": "ca.unit.tests", - "caaProviderId": 1, - "disableFlag": false - }] -}, { - "id": 1808516, - "type": "A", - "recordType": "a", - "name": "", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 300, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565149623640, - "value": ["1.2.3.4", "1.2.3.5"], - "roundRobin": [{ - "value": "1.2.3.4", - "disableFlag": false - }, { - "value": "1.2.3.5", - "disableFlag": false - }], - "geolocation": null, - "recordFailover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "failover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "roundRobinFailover": [], - "pools": [], - "poolsDetail": [] -}, { - "id": 1808527, - "type": "SRV", - "recordType": "srv", - "name": "_srv._tcp", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 600, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565149714387, - "value": [{ - "value": "foo-1.unit.tests.", - "priority": 10, - "weight": 20, - "port": 30, - "disableFlag": false - }, { - "value": "foo-2.unit.tests.", - "priority": 12, - "weight": 20, - "port": 30, - "disableFlag": false - }], - "roundRobin": [{ - "value": "foo-1.unit.tests.", - "priority": 10, - "weight": 20, - "port": 30, - "disableFlag": false - }, { - "value": "foo-2.unit.tests.", - "priority": 12, - "weight": 20, - "port": 30, - "disableFlag": false - }] -}, { - "id": 1808527, - "type": "SRV", - "recordType": "srv", - "name": "_imap._tcp", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 600, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565149714387, - "value": [{ - "value": ".", - "priority": 0, - "weight": 0, - "port": 0, - "disableFlag": false - }], - "roundRobin": [{ - "value": ".", - "priority": 0, - "weight": 0, - "port": 0, - "disableFlag": false - }] -}, { - "id": 1808527, - "type": "SRV", - "recordType": "srv", - "name": "_pop3._tcp", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 600, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565149714387, - "value": [{ - "value": ".", - "priority": 0, - "weight": 0, - "port": 0, - "disableFlag": false - }], - "roundRobin": [{ - "value": ".", - "priority": 0, - "weight": 0, - "port": 0, - "disableFlag": false - }] -}, { - "id": 1808515, - "type": "AAAA", - "recordType": "aaaa", - "name": "aaaa", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 600, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565149739464, - "value": ["2601:644:500:e210:62f8:1dff:feb8:947a"], - "roundRobin": [{ - "value": "2601:644:500:e210:62f8:1dff:feb8:947a", - "disableFlag": false - }], - "geolocation": null, - "recordFailover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "failover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "pools": [], - "poolsDetail": [], - "roundRobinFailover": [] -}, { - "id": 1808530, - "type": "ANAME", - "recordType": "aname", - "name": "", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 1800, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565150251379, - "value": [{ - "value": "aname.unit.tests.", - "disableFlag": false - }], - "roundRobin": [{ - "value": "aname.unit.tests.", - "disableFlag": false - }], - "geolocation": null, - "recordFailover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "failover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "pools": [], - "poolsDetail": [] -}, { - "id": 1808521, - "type": "CNAME", - "recordType": "cname", - "name": "cname", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 300, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565152113825, - "value": "", - "roundRobin": [{ - "value": "", - "disableFlag": false - }], - "recordFailover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [{ - "id": null, - "value": "", - "disableFlag": false, - "failedFlag": false, - "status": "N/A", - "sortOrder": 1, - "markedActive": false - }, { - "id": null, - "value": "", - "disableFlag": false, - "failedFlag": false, - "status": "N/A", - "sortOrder": 2, - "markedActive": false - }] - }, - "failover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [{ - "id": null, - "value": "", - "disableFlag": false, - "failedFlag": false, - "status": "N/A", - "sortOrder": 1, - "markedActive": false - }, { - "id": null, - "value": "", - "disableFlag": false, - "failedFlag": false, - "status": "N/A", - "sortOrder": 2, - "markedActive": false - }] - }, - "pools": [], - "poolsDetail": [], - "geolocation": null, - "host": "" -}, { - "id": 1808522, - "type": "CNAME", - "recordType": "cname", - "name": "included", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 3600, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565152119137, - "value": "", - "roundRobin": [{ - "value": "", - "disableFlag": false - }], - "recordFailover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [{ - "id": null, - "value": "", - "disableFlag": false, - "failedFlag": false, - "status": "N/A", - "sortOrder": 1, - "markedActive": false - }, { - "id": null, - "value": "", - "disableFlag": false, - "failedFlag": false, - "status": "N/A", - "sortOrder": 2, - "markedActive": false - }] - }, - "failover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [{ - "id": null, - "value": "", - "disableFlag": false, - "failedFlag": false, - "status": "N/A", - "sortOrder": 1, - "markedActive": false - }, { - "id": null, - "value": "", - "disableFlag": false, - "failedFlag": false, - "status": "N/A", - "sortOrder": 2, - "markedActive": false - }] - }, - "pools": [], - "poolsDetail": [], - "geolocation": null, - "host": "" -}, { - "id": 1808523, - "type": "MX", - "recordType": "mx", - "name": "mx", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 300, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565149879856, - "value": [{ - "value": "smtp-3.unit.tests.", - "level": 30, - "disableFlag": false - }, { - "value": "smtp-2.unit.tests.", - "level": 20, - "disableFlag": false - }, { - "value": "smtp-4.unit.tests.", - "level": 10, - "disableFlag": false - }, { - "value": "smtp-1.unit.tests.", - "level": 40, - "disableFlag": false - }], - "roundRobin": [{ - "value": "smtp-3.unit.tests.", - "level": 30, - "disableFlag": false - }, { - "value": "smtp-2.unit.tests.", - "level": 20, - "disableFlag": false - }, { - "value": "smtp-4.unit.tests.", - "level": 10, - "disableFlag": false - }, { - "value": "smtp-1.unit.tests.", - "level": 40, - "disableFlag": false - }] -}, { - "id": 1808525, - "type": "PTR", - "recordType": "ptr", - "name": "ptr", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 300, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565150115139, - "value": [{ - "value": "foo.bar.com.", - "disableFlag": false - }], - "roundRobin": [{ - "value": "foo.bar.com.", - "disableFlag": false - }] -}, { - "id": 1808526, - "type": "SPF", - "recordType": "spf", - "name": "spf", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 600, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565149916132, - "value": [{ - "value": "\"v=spf1 ip4:192.168.0.1/16-all\"", - "disableFlag": false - }], - "roundRobin": [{ - "value": "\"v=spf1 ip4:192.168.0.1/16-all\"", - "disableFlag": false - }] -}, { - "id": 1808528, - "type": "TXT", - "recordType": "txt", - "name": "txt", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 600, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565149966915, - "value": [{ - "value": "\"Bah bah black sheep\"", - "disableFlag": false - }, { - "value": "\"have you any wool.\"", - "disableFlag": false - }, { - "value": "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"", - "disableFlag": false - }], - "roundRobin": [{ - "value": "\"Bah bah black sheep\"", - "disableFlag": false - }, { - "value": "\"have you any wool.\"", - "disableFlag": false - }, { - "value": "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"", - "disableFlag": false - }] -}, { - "id": 1808524, - "type": "NS", - "recordType": "ns", - "name": "under", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 3600, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565150062850, - "value": [{ - "value": "ns1.unit.tests.", - "disableFlag": false - }, { - "value": "ns2", - "disableFlag": false - }], - "roundRobin": [{ - "value": "ns1.unit.tests.", - "disableFlag": false - }, { - "value": "ns2", - "disableFlag": false - }] -}, { - "id": 1808531, - "type": "HTTPRedirection", - "recordType": "httpredirection", - "name": "unsupported", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 300, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565150348154, - "value": "https://redirect.unit.tests", - "roundRobin": [{ - "value": "https://redirect.unit.tests" - }], - "title": "Unsupported Record", - "keywords": "unsupported", - "description": "unsupported record", - "hardlinkFlag": false, - "redirectTypeId": 1, - "url": "https://redirect.unit.tests" -}, { - "id": 1808519, - "type": "A", - "recordType": "a", - "name": "www", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 300, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565150079027, - "value": ["2.2.3.6"], - "roundRobin": [{ - "value": "2.2.3.6", - "disableFlag": false - }], - "geolocation": null, - "recordFailover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "failover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "roundRobinFailover": [], - "pools": [], - "poolsDetail": [] -}, { - "id": 1808520, - "type": "A", - "recordType": "a", - "name": "www.sub", - "recordOption": "roundRobin", - "noAnswer": false, - "note": "", - "ttl": 300, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565150090588, - "value": ["2.2.3.6"], - "roundRobin": [{ - "value": "2.2.3.6", - "disableFlag": false - }], - "geolocation": null, - "recordFailover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "failover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "roundRobinFailover": [], - "pools": [], - "poolsDetail": [] -}, { - "id": 1808520, - "type": "A", - "recordType": "a", - "name": "www.dynamic", - "recordOption": "pools", - "noAnswer": false, - "note": "", - "ttl": 300, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565150090588, - "value": [], - "roundRobin": [], - "geolocation": { - "geoipFilter": 1 - }, - "recordFailover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "failover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "roundRobinFailover": [], - "pools": [ - 1808521 - ], - "poolsDetail": [{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:two" - }] -}, -{ - "id": 1808521, - "type": "A", - "recordType": "a", - "name": "www.dynamic", - "recordOption": "pools", - "noAnswer": false, - "note": "", - "ttl": 300, - "gtdRegion": 1, - "parentId": 123123, - "parent": "domain", - "source": "Domain", - "modifiedTs": 1565150090588, - "value": [], - "roundRobin": [], - "geolocation": { - "geoipFilter": 5303 - }, - "recordFailover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "failover": { - "disabled": false, - "failoverType": 1, - "failoverTypeStr": "Normal (always lowest level)", - "values": [] - }, - "roundRobinFailover": [], - "pools": [ - 1808522 - ], - "poolsDetail": [{ - "id": 1808522, - "name": "unit.tests.:www.dynamic:A:one" - }] -}] diff --git a/tests/test_octodns_provider_constellix.py b/tests/test_octodns_provider_constellix.py index b35191f..46e87ce 100644 --- a/tests/test_octodns_provider_constellix.py +++ b/tests/test_octodns_provider_constellix.py @@ -2,1895 +2,15 @@ # # - from __future__ import absolute_import, division, print_function, \ unicode_literals -from mock import Mock, PropertyMock, call -from os.path import dirname, join -from requests import HTTPError -from requests_mock import ANY, mock as requests_mock from unittest import TestCase -from octodns.record import Record -from octodns.provider.constellix import \ - ConstellixProvider, ConstellixClientBadRequest -from octodns.provider.yaml import YamlProvider -from octodns.zone import Zone +class TestConstellixShim(TestCase): -class TestConstellixProvider(TestCase): - expected = Zone('unit.tests.', []) - source = YamlProvider('test', join(dirname(__file__), 'config')) - source.populate(expected) - - # Our test suite differs a bit, add our NS and remove the simple one - expected.add_record(Record.new(expected, 'under', { - 'ttl': 3600, - 'type': 'NS', - 'values': [ - 'ns1.unit.tests.', - 'ns2.unit.tests.', - ] - })) - - # Add some ALIAS records - expected.add_record(Record.new(expected, '', { - 'ttl': 1800, - 'type': 'ALIAS', - 'value': 'aname.unit.tests.' - })) - - # Add a dynamic record - expected.add_record(Record.new(expected, 'www.dynamic', { - 'ttl': 300, - 'type': 'A', - 'values': [ - '1.2.3.4', - '1.2.3.5' - ], - 'dynamic': { - 'pools': { - 'two': { - 'values': [{ - 'value': '1.2.3.4', - 'weight': 1 - }, { - 'value': '1.2.3.5', - 'weight': 1 - }], - }, - }, - 'rules': [{ - 'pool': 'two', - }], - }, - })) - - for record in list(expected.records): - if record.name == 'sub' and record._type == 'NS': - expected._remove_record(record) - break - - expected_healthcheck = Zone('unit.tests.', []) - source = YamlProvider('test', join(dirname(__file__), 'config')) - source.populate(expected_healthcheck) - - # Our test suite differs a bit, add our NS and remove the simple one - expected_healthcheck.add_record(Record.new(expected_healthcheck, 'under', { - 'ttl': 3600, - 'type': 'NS', - 'values': [ - 'ns1.unit.tests.', - 'ns2.unit.tests.', - ] - })) - - # Add some ALIAS records - expected_healthcheck.add_record(Record.new(expected_healthcheck, '', { - 'ttl': 1800, - 'type': 'ALIAS', - 'value': 'aname.unit.tests.' - })) - - # Add a dynamic record - expected_healthcheck.add_record( - Record.new(expected_healthcheck, 'www.dynamic', { - 'ttl': 300, - 'type': 'A', - 'values': [ - '1.2.3.4', - '1.2.3.5' - ], - 'dynamic': { - 'pools': { - 'two': { - 'values': [{ - 'value': '1.2.3.4', - 'weight': 1 - }, { - 'value': '1.2.3.5', - 'weight': 1 - }], - }, - }, - 'rules': [{ - 'pool': 'two', - }], - }, - 'octodns': { - 'constellix': { - 'healthcheck': { - 'sonar_port': 80, - 'sonar_regions': [ - 'ASIAPAC', - 'EUROPE' - ], - 'sonar_type': 'TCP' - } - } - } - }) - ) - - for record in list(expected_healthcheck.records): - if record.name == 'sub' and record._type == 'NS': - expected_healthcheck._remove_record(record) - break - - expected_healthcheck_world = Zone('unit.tests.', []) - source = YamlProvider('test', join(dirname(__file__), 'config')) - source.populate(expected_healthcheck_world) - - # Our test suite differs a bit, add our NS and remove the simple one - expected_healthcheck_world.add_record( - Record.new(expected_healthcheck_world, 'under', { - 'ttl': 3600, - 'type': 'NS', - 'values': [ - 'ns1.unit.tests.', - 'ns2.unit.tests.', - ] - }) - ) - - # Add some ALIAS records - expected_healthcheck_world.add_record( - Record.new(expected_healthcheck_world, '', { - 'ttl': 1800, - 'type': 'ALIAS', - 'value': 'aname.unit.tests.' - }) - ) - - # Add a dynamic record - expected_healthcheck_world.add_record( - Record.new(expected_healthcheck_world, 'www.dynamic', { - 'ttl': 300, - 'type': 'AAAA', - 'values': [ - '2601:644:500:e210:62f8:1dff:feb8:947a', - '2601:642:500:e210:62f8:1dff:feb8:947a' - ], - 'dynamic': { - 'pools': { - 'two': { - 'values': [{ - 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', - 'weight': 1 - }, { - 'value': '2601:642:500:e210:62f8:1dff:feb8:947a', - 'weight': 1 - }], - }, - }, - 'rules': [{ - 'pool': 'two', - }], - }, - 'octodns': { - 'constellix': { - 'healthcheck': { - 'sonar_port': 80, - 'sonar_regions': [ - 'WORLD' - ], - 'sonar_type': 'HTTP' - } - } - } - }) - ) - - for record in list(expected_healthcheck_world.records): - if record.name == 'sub' and record._type == 'NS': - expected_healthcheck_world._remove_record(record) - break - - expected_dynamic = Zone('unit.tests.', []) - source = YamlProvider('test', join(dirname(__file__), 'config')) - source.populate(expected_dynamic) - - # Our test suite differs a bit, add our NS and remove the simple one - expected_dynamic.add_record(Record.new(expected_dynamic, 'under', { - 'ttl': 3600, - 'type': 'NS', - 'values': [ - 'ns1.unit.tests.', - 'ns2.unit.tests.', - ] - })) - - # Add some ALIAS records - expected_dynamic.add_record(Record.new(expected_dynamic, '', { - 'ttl': 1800, - 'type': 'ALIAS', - 'value': 'aname.unit.tests.' - })) - - # Add a dynamic record - expected_dynamic.add_record(Record.new(expected_dynamic, 'www.dynamic', { - 'ttl': 300, - 'type': 'A', - 'values': [ - '1.2.3.4', - '1.2.3.5' - ], - 'dynamic': { - 'pools': { - 'one': { - 'fallback': 'two', - 'values': [{ - 'value': '1.2.3.6', - 'weight': 1 - }, { - 'value': '1.2.3.7', - 'weight': 1 - }], - }, - 'two': { - 'values': [{ - 'value': '1.2.3.4', - 'weight': 1 - }, { - 'value': '1.2.3.5', - 'weight': 1 - }], - }, - }, - 'rules': [{ - 'geos': [ - 'AS', - 'EU-ES', - 'EU-UA', - 'EU-SE', - 'NA-CA-NL', - 'OC' - ], - 'pool': 'one' - }, { - 'pool': 'two', - }], - } - })) - - for record in list(expected_dynamic.records): - if record.name == 'sub' and record._type == 'NS': - expected_dynamic._remove_record(record) - break - - def test_populate(self): - provider = ConstellixProvider('test', 'api', 'secret') - - # Bad auth - with requests_mock() as mock: - mock.get(ANY, status_code=401, - text='{"errors": ["Unable to authenticate token"]}') - - with self.assertRaises(Exception) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals('Unauthorized', str(ctx.exception)) - - with requests_mock() as mock: - mock.get(ANY, status_code=401, - text='{"errors": ["Unable to authenticate token"]}') - - with self.assertRaises(Exception) as ctx: - provider._sonar.agents - self.assertEquals('Unauthorized', str(ctx.exception)) - - # Bad request - with requests_mock() as mock: - mock.get(ANY, status_code=400, - text='{"errors": ["\\"unittests\\" is not ' - 'a valid domain name"]}') - - with self.assertRaises(Exception) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals('\n - "unittests" is not a valid domain name', - str(ctx.exception)) - - with requests_mock() as mock: - mock.get(ANY, status_code=400, - text='error text') - - with self.assertRaises(Exception) as ctx: - provider._sonar.agents - self.assertEquals('\n - error text', - str(ctx.exception)) - - # General error - with requests_mock() as mock: - mock.get(ANY, status_code=502, text='Things caught fire') - - with self.assertRaises(HTTPError) as ctx: - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(502, ctx.exception.response.status_code) - - # Non-existent zone doesn't populate anything - with requests_mock() as mock: - mock.get(ANY, status_code=404, - text='') - - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(set(), zone.records) - - with requests_mock() as mock: - mock.get(ANY, status_code=404, text='') - with self.assertRaises(Exception) as ctx: - provider._sonar.agents - self.assertEquals('Not Found', str(ctx.exception)) - - # Sonar Normal response - provider = ConstellixProvider('test', 'api', 'secret') - with requests_mock() as mock: - mock.get(ANY, status_code=200, text='[]') - agents = provider._sonar.agents - self.assertEquals({}, agents) - agents = provider._sonar.agents - - provider = ConstellixProvider('test', 'api', 'secret', 0.01) - with requests_mock() as mock: - mock.get(ANY, status_code=200, text='[]') - agents = provider._sonar.agents - - provider = ConstellixProvider('test', 'api', 'secret', 1.01) - with requests_mock() as mock: - mock.get(ANY, status_code=200, text='[]') - agents = provider._sonar.agents - - provider = ConstellixProvider('test', 'api', 'secret') - # No diffs == no changes - with requests_mock() as mock: - base = 'https://api.dns.constellix.com/v1' - with open('tests/fixtures/constellix-domains.json') as fh: - mock.get(f'{base}/domains', text=fh.read()) - with open('tests/fixtures/constellix-records.json') as fh: - mock.get(f'{base}/domains/123123/records', text=fh.read()) - with open('tests/fixtures/constellix-pools.json') as fh: - mock.get(f'{base}/pools/A', text=fh.read()) - with open('tests/fixtures/constellix-geofilters.json') as fh: - mock.get(f'{base}/geoFilters', text=fh.read()) - - zone = Zone('unit.tests.', []) - provider.populate(zone) - self.assertEquals(17, len(zone.records)) - changes = self.expected_dynamic.changes(zone, provider) - self.assertEquals(0, len(changes)) - - # 2nd populate makes no network calls/all from cache - again = Zone('unit.tests.', []) - provider.populate(again) - self.assertEquals(17, len(again.records)) - - # bust the cache - del provider._zone_records[zone.name] - - def test_apply(self): - provider = ConstellixProvider('test', 'api', 'secret') - - resp = Mock() - resp.json = Mock() - provider._client._request = Mock(return_value=resp) - - # non-existent domain, create everything - resp.json.side_effect = [ - [], # no domains returned during populate - [{ - 'id': 123123, - 'name': 'unit.tests' - }], # domain created in apply - [], # No pools returned during populate - [{ - "id": 1808520, - "name": "unit.tests.:www.dynamic:A:two", - }] # pool created in apply - ] - - plan = provider.plan(self.expected) - - # No root NS, no ignored, no excluded, no unsupported - n = len(self.expected.records) - 8 - self.assertEquals(n, len(plan.changes)) - self.assertEquals(n, provider.apply(plan)) - - provider._client._request.assert_has_calls([ - # get all domains to build the cache - call('GET', '/domains'), - # created the domain - call('POST', '/domains', data={'names': ['unit.tests']}) - ]) - - # Check we tried to get our pool - provider._client._request.assert_has_calls([ - # get all pools to build the cache - call('GET', '/pools/A'), - # created the pool - call('POST', '/pools/A', data={ - 'name': 'unit.tests.:www.dynamic:A:two', - 'type': 'A', - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': 300, - 'values': [{ - "value": "1.2.3.4", - "weight": 1 - }, { - "value": "1.2.3.5", - "weight": 1 - }] - }) - ]) - - # These two checks are broken up so that ordering doesn't break things. - # Python3 doesn't make the calls in a consistent order so different - # things follow the GET / on different runs - provider._client._request.assert_has_calls([ - call('POST', '/domains/123123/records/SRV', data={ - 'roundRobin': [{ - 'priority': 10, - 'weight': 20, - 'value': 'foo-1.unit.tests.', - 'port': 30 - }, { - 'priority': 12, - 'weight': 20, - 'value': 'foo-2.unit.tests.', - 'port': 30 - }], - 'name': '_srv._tcp', - 'ttl': 600, - }), - ]) - - self.assertEquals(22, provider._client._request.call_count) - - provider._client._request.reset_mock() - - provider._client.records = Mock(return_value=[ - { - 'id': 11189897, - 'type': 'A', - 'name': 'www', - 'ttl': 300, - 'recordOption': 'roundRobin', - 'value': [ - '1.2.3.4', - '2.2.3.4', - ] - }, { - 'id': 11189898, - 'type': 'A', - 'name': 'ttl', - 'ttl': 600, - 'recordOption': 'roundRobin', - 'value': [ - '3.2.3.4' - ] - }, { - 'id': 11189899, - 'type': 'ALIAS', - 'name': 'alias', - 'ttl': 600, - 'recordOption': 'roundRobin', - 'value': [{ - 'value': 'aname.unit.tests.' - }] - }, { - "id": 1808520, - "type": "A", - "name": "www.dynamic", - "geolocation": None, - "recordOption": "pools", - "ttl": 300, - "value": [], - "pools": [ - 1808521 - ] - } - ]) - - provider._client.pools = Mock(return_value=[{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:two", - "type": "A", - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - }, - { - "value": "1.2.3.5", - "weight": 1 - } - ] - }]) - - # Domain exists, we don't care about return - resp.json.side_effect = [ - [], # no domains returned during populate - [{ - 'id': 123123, - 'name': 'unit.tests' - }], # domain created in apply - [], # No pools returned during populate - [{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:one" - }] # pool created in apply - ] - - wanted = Zone('unit.tests.', []) - wanted.add_record(Record.new(wanted, 'ttl', { - 'ttl': 300, - 'type': 'A', - 'value': '3.2.3.4' - })) - - wanted.add_record(Record.new(wanted, 'www.dynamic', { - 'ttl': 300, - 'type': 'A', - 'values': [ - '1.2.3.4' - ], - 'dynamic': { - 'pools': { - 'two': { - 'values': [{ - 'value': '1.2.3.4', - 'weight': 1 - }], - }, - }, - 'rules': [{ - 'pool': 'two', - }], - }, - })) - - plan = provider.plan(wanted) - self.assertEquals(4, len(plan.changes)) - self.assertEquals(4, provider.apply(plan)) - - # recreate for update, and deletes for the 2 parts of the other - provider._client._request.assert_has_calls([ - call('POST', '/domains/123123/records/A', data={ - 'roundRobin': [{ - 'value': '3.2.3.4' - }], - 'name': 'ttl', - 'ttl': 300 - }), - call('PUT', '/pools/A/1808521', data={ - 'name': 'unit.tests.:www.dynamic:A:two', - 'type': 'A', - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': 300, - 'values': [{ - "value": "1.2.3.4", - "weight": 1 - }], - 'id': 1808521, - 'geofilter': 1 - }), - call('DELETE', '/domains/123123/records/A/11189897'), - call('DELETE', '/domains/123123/records/A/11189898'), - call('DELETE', '/domains/123123/records/ANAME/11189899'), - ], any_order=True) - - def test_apply_healthcheck(self): - provider = ConstellixProvider('test', 'api', 'secret') - - resp = Mock() - resp.json = Mock() - provider._client._request = Mock(return_value=resp) - - # non-existent domain, create everything - resp.json.side_effect = [ - [], # no domains returned during populate - [{ - 'id': 123123, - 'name': 'unit.tests' - }], # domain created in apply - [], # No pools returned during populate - [{ - "id": 1808520, - "name": "unit.tests.:www.dynamic:A:two", - }] # pool created in apply - ] - - sonar_resp = Mock() - sonar_resp.json = Mock() - type(sonar_resp).headers = PropertyMock(return_value={ - "Location": "http://api.sonar.constellix.com/rest/api/tcp/52906" - }) - sonar_resp.headers = Mock() - provider._sonar._request = Mock(return_value=sonar_resp) - - sonar_resp.json.side_effect = [ - [{ - "id": 1, - "name": "USWAS01", - "label": "Site 1", - "location": "Washington, DC, U.S.A", - "country": "U.S.A", - "region": "ASIAPAC" - }, { - "id": 23, - "name": "CATOR01", - "label": "Site 1", - "location": "Toronto,Canada", - "country": "Canada", - "region": "EUROPE" - }, { - "id": 25, - "name": "CATOR01", - "label": "Site 1", - "location": "Toronto,Canada", - "country": "Canada", - "region": "OCEANIA" - }], # available agents - [{ - "id": 52, - "name": "unit.tests.:www.dynamic:A:two-1.2.3.4" - }], # initial checks - { - "type": 'TCP' - }, # check type - { - "id": 52906, - "name": "unit.tests.:www.dynamic:A:two-1.2.3.4" - }, - { - "id": 52907, - "name": "unit.tests.:www.dynamic:A:two-1.2.3.5" - } - ] - - plan = provider.plan(self.expected_healthcheck) - - # No root NS, no ignored, no excluded, no unsupported - n = len(self.expected_healthcheck.records) - 8 - self.assertEquals(n, len(plan.changes)) - self.assertEquals(n, provider.apply(plan)) - - provider._client._request.assert_has_calls([ - # get all domains to build the cache - call('GET', '/domains'), - # created the domain - call('POST', '/domains', data={'names': ['unit.tests']}) - ]) - - # Check we tried to get our pool - provider._client._request.assert_has_calls([ - # get all pools to build the cache - call('GET', '/pools/A'), - # created the pool - call('POST', '/pools/A', data={ - 'name': 'unit.tests.:www.dynamic:A:two', - 'type': 'A', - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': 300, - 'values': [{ - "value": "1.2.3.4", - "weight": 1, - "checkId": 52906, - "policy": 'followsonar' - }, { - "value": "1.2.3.5", - "weight": 1, - "checkId": 52907, - "policy": 'followsonar' - }] - }) - ]) - - # These two checks are broken up so that ordering doesn't break things. - # Python3 doesn't make the calls in a consistent order so different - # things follow the GET / on different runs - provider._client._request.assert_has_calls([ - call('POST', '/domains/123123/records/SRV', data={ - 'roundRobin': [{ - 'priority': 10, - 'weight': 20, - 'value': 'foo-1.unit.tests.', - 'port': 30 - }, { - 'priority': 12, - 'weight': 20, - 'value': 'foo-2.unit.tests.', - 'port': 30 - }], - 'name': '_srv._tcp', - 'ttl': 600, - }), - ]) - - self.assertEquals(22, provider._client._request.call_count) - - provider._client._request.reset_mock() - - provider._client.records = Mock(return_value=[ - { - 'id': 11189897, - 'type': 'A', - 'name': 'www', - 'ttl': 300, - 'recordOption': 'roundRobin', - 'value': [ - '1.2.3.4', - '2.2.3.4', - ] - }, { - 'id': 11189898, - 'type': 'A', - 'name': 'ttl', - 'ttl': 600, - 'recordOption': 'roundRobin', - 'value': [ - '3.2.3.4' - ] - }, { - 'id': 11189899, - 'type': 'ALIAS', - 'name': 'alias', - 'ttl': 600, - 'recordOption': 'roundRobin', - 'value': [{ - 'value': 'aname.unit.tests.' - }] - }, { - "id": 1808520, - "type": "A", - "name": "www.dynamic", - "geolocation": None, - "recordOption": "pools", - "ttl": 300, - "value": [], - "pools": [ - 1808521 - ] - } - ]) - - provider._client.pools = Mock(return_value=[{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:two", - "type": "A", - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - }, - { - "value": "1.2.3.5", - "weight": 1 - } - ] - }]) - - # Domain exists, we don't care about return - resp.json.side_effect = [ - [], # no domains returned during populate - [{ - 'id': 123123, - 'name': 'unit.tests' - }], # domain created in apply - [], # No pools returned during populate - [{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:one" - }] # pool created in apply - ] - - wanted = Zone('unit.tests.', []) - wanted.add_record(Record.new(wanted, 'ttl', { - 'ttl': 300, - 'type': 'A', - 'value': '3.2.3.4' - })) - - wanted.add_record(Record.new(wanted, 'www.dynamic', { - 'ttl': 300, - 'type': 'A', - 'values': [ - '1.2.3.4' - ], - 'dynamic': { - 'pools': { - 'two': { - 'values': [{ - 'value': '1.2.3.4', - 'weight': 1 - }], - }, - }, - 'rules': [{ - 'pool': 'two', - }], - }, - })) - - plan = provider.plan(wanted) - self.assertEquals(4, len(plan.changes)) - self.assertEquals(4, provider.apply(plan)) - - # recreate for update, and deletes for the 2 parts of the other - provider._client._request.assert_has_calls([ - call('POST', '/domains/123123/records/A', data={ - 'roundRobin': [{ - 'value': '3.2.3.4' - }], - 'name': 'ttl', - 'ttl': 300 - }), - call('PUT', '/pools/A/1808521', data={ - 'name': 'unit.tests.:www.dynamic:A:two', - 'type': 'A', - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': 300, - 'values': [{ - "value": "1.2.3.4", - "weight": 1 - }], - 'id': 1808521, - 'geofilter': 1 - }), - call('DELETE', '/domains/123123/records/A/11189897'), - call('DELETE', '/domains/123123/records/A/11189898'), - call('DELETE', '/domains/123123/records/ANAME/11189899'), - ], any_order=True) - - def test_apply_healthcheck_world(self): - provider = ConstellixProvider('test', 'api', 'secret') - - resp = Mock() - resp.json = Mock() - provider._client._request = Mock(return_value=resp) - - # non-existent domain, create everything - resp.json.side_effect = [ - [], # no domains returned during populate - [{ - 'id': 123123, - 'name': 'unit.tests' - }], # domain created in apply - [], # No pools returned during populate - [{ - "id": 1808520, - "name": "unit.tests.:www.dynamic:A:two", - }] # pool created in apply - ] - - sonar_resp = Mock() - sonar_resp.json = Mock() - type(sonar_resp).headers = PropertyMock(return_value={ - "Location": "http://api.sonar.constellix.com/rest/api/tcp/52906" - }) - sonar_resp.headers = Mock() - provider._sonar._request = Mock(return_value=sonar_resp) - - sonar_resp.json.side_effect = [ - [{ - "id": 1, - "name": "USWAS01", - "label": "Site 1", - "location": "Washington, DC, U.S.A", - "country": "U.S.A", - "region": "ASIAPAC" - }, { - "id": 23, - "name": "CATOR01", - "label": "Site 1", - "location": "Toronto,Canada", - "country": "Canada", - "region": "EUROPE" - }], # available agents - [], # no checks - { - "id": 52906, - "name": "check1" - }, - { - "id": 52907, - "name": "check2" - } - ] - - plan = provider.plan(self.expected_healthcheck_world) - - # No root NS, no ignored, no excluded, no unsupported - n = len(self.expected_healthcheck.records) - 8 - self.assertEquals(n, len(plan.changes)) - self.assertEquals(n, provider.apply(plan)) - - provider._client._request.assert_has_calls([ - # get all domains to build the cache - call('GET', '/domains'), - # created the domain - call('POST', '/domains', data={'names': ['unit.tests']}) - ]) - - # Check we tried to get our pool - provider._client._request.assert_has_calls([ - # get all pools to build the cache - call('GET', '/pools/AAAA'), - # created the pool - call('POST', '/pools/AAAA', data={ - 'name': 'unit.tests.:www.dynamic:AAAA:two', - 'type': 'AAAA', - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': 300, - 'values': [{ - "value": "2601:642:500:e210:62f8:1dff:feb8:947a", - "weight": 1, - "checkId": 52906, - "policy": 'followsonar' - }, { - "value": "2601:644:500:e210:62f8:1dff:feb8:947a", - "weight": 1, - "checkId": 52907, - "policy": 'followsonar' - }] - }) - ]) - - # These two checks are broken up so that ordering doesn't break things. - # Python3 doesn't make the calls in a consistent order so different - # things follow the GET / on different runs - provider._client._request.assert_has_calls([ - call('POST', '/domains/123123/records/SRV', data={ - 'roundRobin': [{ - 'priority': 10, - 'weight': 20, - 'value': 'foo-1.unit.tests.', - 'port': 30 - }, { - 'priority': 12, - 'weight': 20, - 'value': 'foo-2.unit.tests.', - 'port': 30 - }], - 'name': '_srv._tcp', - 'ttl': 600, - }), - ]) - - self.assertEquals(22, provider._client._request.call_count) - - provider._client._request.reset_mock() - - provider._client.records = Mock(return_value=[ - { - 'id': 11189897, - 'type': 'A', - 'name': 'www', - 'ttl': 300, - 'recordOption': 'roundRobin', - 'value': [ - '1.2.3.4', - '2.2.3.4', - ] - }, { - 'id': 11189898, - 'type': 'A', - 'name': 'ttl', - 'ttl': 600, - 'recordOption': 'roundRobin', - 'value': [ - '3.2.3.4' - ] - }, { - 'id': 11189899, - 'type': 'ALIAS', - 'name': 'alias', - 'ttl': 600, - 'recordOption': 'roundRobin', - 'value': [{ - 'value': 'aname.unit.tests.' - }] - }, { - "id": 1808520, - "type": "A", - "name": "www.dynamic", - "geolocation": None, - "recordOption": "pools", - "ttl": 300, - "value": [], - "pools": [ - 1808521 - ] - } - ]) - - provider._client.pools = Mock(return_value=[{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:two", - "type": "A", - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - }, - { - "value": "1.2.3.5", - "weight": 1 - } - ] - }]) - - # Domain exists, we don't care about return - resp.json.side_effect = [ - [], # no domains returned during populate - [{ - 'id': 123123, - 'name': 'unit.tests' - }], # domain created in apply - [], # No pools returned during populate - [{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:one" - }] # pool created in apply - ] - - wanted = Zone('unit.tests.', []) - wanted.add_record(Record.new(wanted, 'ttl', { - 'ttl': 300, - 'type': 'A', - 'value': '3.2.3.4' - })) - - wanted.add_record(Record.new(wanted, 'www.dynamic', { - 'ttl': 300, - 'type': 'A', - 'values': [ - '1.2.3.4' - ], - 'dynamic': { - 'pools': { - 'two': { - 'values': [{ - 'value': '1.2.3.4', - 'weight': 1 - }], - }, - }, - 'rules': [{ - 'pool': 'two', - }], - }, - })) - - plan = provider.plan(wanted) - self.assertEquals(4, len(plan.changes)) - self.assertEquals(4, provider.apply(plan)) - - # recreate for update, and deletes for the 2 parts of the other - provider._client._request.assert_has_calls([ - call('POST', '/domains/123123/records/A', data={ - 'roundRobin': [{ - 'value': '3.2.3.4' - }], - 'name': 'ttl', - 'ttl': 300 - }), - call('PUT', '/pools/A/1808521', data={ - 'name': 'unit.tests.:www.dynamic:A:two', - 'type': 'A', - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': 300, - 'values': [{ - "value": "1.2.3.4", - "weight": 1 - }], - 'id': 1808521, - 'geofilter': 1 - }), - call('DELETE', '/domains/123123/records/A/11189897'), - call('DELETE', '/domains/123123/records/A/11189898'), - call('DELETE', '/domains/123123/records/ANAME/11189899'), - ], any_order=True) - - def test_apply_dynamic(self): - provider = ConstellixProvider('test', 'api', 'secret') - - resp = Mock() - resp.json = Mock() - provider._client._request = Mock(return_value=resp) - - # non-existent domain, create everything - resp.json.side_effect = [ - [], # no domains returned during populate - [{ - 'id': 123123, - 'name': 'unit.tests' - }], # domain created in apply - [], # No pools returned during populate - [{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:one" - }], # pool created in apply - [], # no geofilters returned during populate - [{ - "id": 5303, - "name": "unit.tests.:www.dynamic:A:one", - "filterRulesLimit": 100, - "geoipContinents": ["AS", "OC"], - "geoipCountries": ["ES", "SE", "UA"], - "regions": [ - { - "continentCode": "NA", - "countryCode": "CA", - "regionCode": "NL" - } - ] - }], # geofilters created in applly - [{ - "id": 1808520, - "name": "unit.tests.:www.dynamic:A:two", - }], # pool created in apply - { - 'id': 123123, - 'name': 'unit.tests', - 'hasGeoIP': False - }, # domain listed for enabling geo - [] # enabling geo - ] - - plan = provider.plan(self.expected_dynamic) - - # No root NS, no ignored, no excluded, no unsupported - n = len(self.expected_dynamic.records) - 8 - self.assertEquals(n, len(plan.changes)) - self.assertEquals(n, provider.apply(plan)) - - provider._client._request.assert_has_calls([ - # get all domains to build the cache - call('GET', '/domains'), - # created the domain - call('POST', '/domains', data={'names': ['unit.tests']}) - ]) -# - # Check we tried to get our pool - provider._client._request.assert_has_calls([ - call('GET', '/pools/A'), - call('POST', '/pools/A', data={ - 'name': 'unit.tests.:www.dynamic:A:one', - 'type': 'A', - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': 300, - 'values': [{ - 'value': '1.2.3.6', - 'weight': 1 - }, { - 'value': '1.2.3.7', - 'weight': 1}] - }), - call('GET', '/geoFilters'), - call('POST', '/geoFilters', data={ - 'filterRulesLimit': 100, - 'name': 'unit.tests.:www.dynamic:A:one', - 'geoipContinents': ['AS', 'OC'], - 'geoipCountries': ['ES', 'SE', 'UA'], - 'regions': [{ - 'continentCode': 'NA', - 'countryCode': 'CA', - 'regionCode': 'NL'}] - }), - call('POST', '/pools/A', data={ - 'name': 'unit.tests.:www.dynamic:A:two', - 'type': 'A', - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': 300, - 'values': [{ - 'value': '1.2.3.4', - 'weight': 1 - }, { - 'value': '1.2.3.5', - 'weight': 1}] - }) - ]) - - # These two checks are broken up so that ordering doesn't break things. - # Python3 doesn't make the calls in a consistent order so different - # things follow the GET / on different runs - provider._client._request.assert_has_calls([ - call('POST', '/domains/123123/records/SRV', data={ - 'roundRobin': [{ - 'priority': 10, - 'weight': 20, - 'value': 'foo-1.unit.tests.', - 'port': 30 - }, { - 'priority': 12, - 'weight': 20, - 'value': 'foo-2.unit.tests.', - 'port': 30 - }], - 'name': '_srv._tcp', - 'ttl': 600, - }), - ]) - - self.assertEquals(28, provider._client._request.call_count) - - provider._client._request.reset_mock() - - provider._client.records = Mock(return_value=[ - { - 'id': 11189897, - 'type': 'A', - 'name': 'www', - 'ttl': 300, - 'recordOption': 'roundRobin', - 'value': [ - '1.2.3.4', - '2.2.3.4', - ] - }, { - 'id': 11189898, - 'type': 'A', - 'name': 'ttl', - 'ttl': 600, - 'recordOption': 'roundRobin', - 'value': [ - '3.2.3.4' - ] - }, { - 'id': 11189899, - 'type': 'ALIAS', - 'name': 'alias', - 'ttl': 600, - 'recordOption': 'roundRobin', - 'value': [{ - 'value': 'aname.unit.tests.' - }] - }, { - "id": 1808520, - "type": "A", - "name": "www.dynamic", - "geolocation": { - "geoipFilter": 1 - }, - "recordOption": "pools", - "ttl": 300, - "value": [], - "pools": [ - 1808521 - ] - }, { - "id": 1808521, - "type": "A", - "name": "www.dynamic", - "geolocation": { - "geoipFilter": 5303 - }, - "recordOption": "pools", - "ttl": 300, - "value": [], - "pools": [ - 1808522 - ] - } - ]) - - provider._client.pools = Mock(return_value=[ - { - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:two", - "type": "A", - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - }, - { - "value": "1.2.3.5", - "weight": 1 - } - ] - }, - { - "id": 1808522, - "name": "unit.tests.:www.dynamic:A:one", - "type": "A", - "values": [ - { - "value": "1.2.3.6", - "weight": 1 - }, - { - "value": "1.2.3.7", - "weight": 1 - } - ] - } - ]) - - provider._client.geofilters = Mock(return_value=[ - { - "id": 5303, - "name": "unit.tests.:www.dynamic:A:one", - "filterRulesLimit": 100, - "geoipContinents": ["AS", "OC"], - "geoipCountries": ["ES", "SE", "UA"], - "regions": [ - { - "continentCode": "NA", - "countryCode": "CA", - "regionCode": "NL" - } - ] - } - ]) - - # Domain exists, we don't care about return - resp.json.side_effect = [ - [], - [], - [], - [], - { - 'id': 123123, - 'name': 'unit.tests', - 'hasGeoIP': True - } # domain listed for enabling geo - ] - - wanted = Zone('unit.tests.', []) - wanted.add_record(Record.new(wanted, 'ttl', { - 'ttl': 300, - 'type': 'A', - 'value': '3.2.3.4' - })) - - wanted.add_record(Record.new(wanted, 'www.dynamic', { - 'ttl': 300, - 'type': 'A', - 'values': [ - '1.2.3.4' - ], - 'dynamic': { - 'pools': { - 'one': { - 'fallback': 'two', - 'values': [{ - 'value': '1.2.3.6', - 'weight': 1 - }, { - 'value': '1.2.3.7', - 'weight': 1 - }], - }, - 'two': { - 'values': [{ - 'value': '1.2.3.4', - 'weight': 1 - }], - }, - }, - 'rules': [{ - 'geos': [ - 'AS', - 'EU-ES', - 'EU-UA', - 'EU-SE', - 'NA-CA-NL', - 'OC' - ], - 'pool': 'one' - }, { - 'pool': 'two', - }], - }, - })) - - plan = provider.plan(wanted) - self.assertEquals(4, len(plan.changes)) - self.assertEquals(4, provider.apply(plan)) - - # recreate for update, and deletes for the 2 parts of the other - provider._client._request.assert_has_calls([ - call('POST', '/domains/123123/records/A', data={ - 'roundRobin': [{ - 'value': '3.2.3.4' - }], - 'name': 'ttl', - 'ttl': 300 - }), - - call('DELETE', '/domains/123123/records/A/1808521'), - call('DELETE', '/geoFilters/5303'), - call('DELETE', '/pools/A/1808522'), - call('DELETE', '/domains/123123/records/A/1808520'), - call('DELETE', '/pools/A/1808521'), - call('DELETE', '/domains/123123/records/ANAME/11189899'), - - call('PUT', '/pools/A/1808522', data={ - 'name': 'unit.tests.:www.dynamic:A:one', - 'type': 'A', - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': 300, - 'values': [ - {'value': '1.2.3.6', 'weight': 1}, - {'value': '1.2.3.7', 'weight': 1}], - 'id': 1808522, - 'geofilter': 5303 - }), - - call('PUT', '/geoFilters/5303', data={ - 'filterRulesLimit': 100, - 'name': 'unit.tests.:www.dynamic:A:one', - 'geoipContinents': ['AS', 'OC'], - 'geoipCountries': ['ES', 'SE', 'UA'], - 'regions': [{ - 'continentCode': 'NA', - 'countryCode': 'CA', - 'regionCode': 'NL'}], - 'id': 5303 - }), - - call('PUT', '/pools/A/1808521', data={ - 'name': 'unit.tests.:www.dynamic:A:two', - 'type': 'A', - 'numReturn': 1, - 'minAvailableFailover': 1, - 'ttl': 300, - 'values': [{'value': '1.2.3.4', 'weight': 1}], - 'id': 1808521, - 'geofilter': 1 - }), - - call('GET', '/domains/123123'), - call('POST', '/domains/123123/records/A', data={ - 'name': 'www.dynamic', - 'ttl': 300, - 'pools': [1808522], - 'recordOption': 'pools', - 'geolocation': { - 'geoipUserRegion': [5303] - } - }), - - call('POST', '/domains/123123/records/A', data={ - 'name': 'www.dynamic', - 'ttl': 300, - 'pools': [1808522], - 'recordOption': 'pools', - 'geolocation': { - 'geoipUserRegion': [5303] - } - }) - ], any_order=True) - - def test_dynamic_record_failures(self): - provider = ConstellixProvider('test', 'api', 'secret') - - resp = Mock() - resp.json = Mock() - provider._client._request = Mock(return_value=resp) - - # Let's handle some failures for pools - first if it's not a simple - # weighted pool - we'll be OK as we assume a weight of 1 for all - # entries - provider._client._request.reset_mock() - provider._client.records = Mock(return_value=[ - { - "id": 1808520, - "type": "A", - "name": "www.dynamic", - "geolocation": None, - "recordOption": "pools", - "ttl": 300, - "value": [], - "pools": [ - 1808521 - ] - } - ]) - - provider._client.pools = Mock(return_value=[{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:two", - "type": "A", - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - } - ] - }]) - - provider._client.geofilters = Mock(return_value=[]) - - wanted = Zone('unit.tests.', []) - - resp.json.side_effect = [ - ['{}'], - ['{}'], - ] - wanted.add_record(Record.new(wanted, 'www.dynamic', { - 'ttl': 300, - 'type': 'A', - 'values': [ - '1.2.3.4' - ], - 'dynamic': { - 'pools': { - 'two': { - 'values': [{ - 'value': '1.2.3.4' - }], - }, - }, - 'rules': [{ - 'pool': 'two', - }], - }, - })) - - plan = provider.plan(wanted) - self.assertIsNone(plan) - - def test_dynamic_record_updates(self): - provider = ConstellixProvider('test', 'api', 'secret') - - # Constellix API can return an error if you try and update a pool and - # don't change anything, so let's test we handle it silently - - provider._client.records = Mock(return_value=[ - { - "id": 1808520, - "type": "A", - "name": "www.dynamic", - "geolocation": { - "geoipFilter": 1 - }, - "recordOption": "pools", - "ttl": 300, - "value": [], - "pools": [ - 1808521 - ] - }, { - "id": 1808521, - "type": "A", - "name": "www.dynamic", - "geolocation": { - "geoipFilter": 5303 - }, - "recordOption": "pools", - "ttl": 300, - "value": [], - "pools": [ - 1808522 - ] - } - ]) - - provider._client.pools = Mock(return_value=[ - { - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:two", - "type": "A", - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - }, - { - "value": "1.2.3.5", - "weight": 1 - } - ] - }, - { - "id": 1808522, - "name": "unit.tests.:www.dynamic:A:one", - "type": "A", - "values": [ - { - "value": "1.2.3.6", - "weight": 1 - }, - { - "value": "1.2.3.7", - "weight": 1 - } - ] - } - ]) - - provider._client.geofilters = Mock(return_value=[ - { - "id": 6303, - "name": "some.other", - "filterRulesLimit": 100, - "createdTs": "2021-08-19T14:47:47Z", - "modifiedTs": "2021-08-19T14:47:47Z", - "geoipContinents": ["AS", "OC"], - "geoipCountries": ["ES", "SE", "UA"], - "regions": [ - { - "continentCode": "NA", - "countryCode": "CA", - "regionCode": "NL" - } - ] - }, { - "id": 5303, - "name": "unit.tests.:www.dynamic:A:one", - "filterRulesLimit": 100, - "geoipContinents": ["AS", "OC"], - "geoipCountries": ["ES", "SE", "UA"], - "regions": [ - { - "continentCode": "NA", - "countryCode": "CA", - "regionCode": "NL" - } - ] - } - ]) - - wanted = Zone('unit.tests.', []) - - wanted.add_record(Record.new(wanted, 'www.dynamic', { - 'ttl': 300, - 'type': 'A', - 'values': [ - '1.2.3.4' - ], - 'dynamic': { - 'pools': { - 'one': { - 'fallback': 'two', - 'values': [{ - 'value': '1.2.3.6', - 'weight': 1 - }, { - 'value': '1.2.3.7', - 'weight': 1 - }], - }, - 'two': { - 'values': [{ - 'value': '1.2.3.4', - 'weight': 1 - }], - }, - }, - 'rules': [{ - 'geos': [ - 'AS', - 'EU-ES', - 'EU-UA', - 'EU-SE', - 'OC' - ], - 'pool': 'one' - }, { - 'pool': 'two', - }], - }, - })) - - # Try an error we can handle - with requests_mock() as mock: - mock.get( - "https://api.dns.constellix.com/v1/domains", - status_code=200, - text='[{"id": 1234, "name": "unit.tests", "hasGeoIP": true}]') - mock.get( - "https://api.dns.constellix.com/v1/domains/1234", - status_code=200, - text='{"id": 1234, "name": "unit.tests", "hasGeoIP": true}') - mock.delete(ANY, status_code=200, - text='{}') - mock.put("https://api.dns.constellix.com/v1/pools/A/1808521", - status_code=400, - text='{"errors": [\"no changes to save\"]}') - mock.put("https://api.dns.constellix.com/v1/pools/A/1808522", - status_code=400, - text='{"errors": [\"no changes to save\"]}') - mock.put("https://api.dns.constellix.com/v1/geoFilters/5303", - status_code=400, - text='{"errors": [\"no changes to save\"]}') - mock.post(ANY, status_code=200, - text='[{"id": 1234}]') - - plan = provider.plan(wanted) - self.assertEquals(1, len(plan.changes)) - self.assertEquals(1, provider.apply(plan)) - - provider._client.geofilters = Mock(return_value=[ - { - "id": 5303, - "name": "unit.tests.:www.dynamic:A:one", - "filterRulesLimit": 100, - "regions": [ - { - "continentCode": "NA", - "countryCode": "CA", - "regionCode": "NL" - } - ] - } - ]) - - plan = provider.plan(wanted) - self.assertEquals(1, len(plan.changes)) - self.assertEquals(1, provider.apply(plan)) - - provider._client.geofilters = Mock(return_value=[ - { - "id": 5303, - "name": "unit.tests.:www.dynamic:A:one", - "filterRulesLimit": 100, - "geoipContinents": ["AS", "OC"], - } - ]) - - plan = provider.plan(wanted) - self.assertEquals(1, len(plan.changes)) - self.assertEquals(1, provider.apply(plan)) - - # Now what happens if an error happens that we can't handle - # geofilter case - with requests_mock() as mock: - mock.get( - "https://api.dns.constellix.com/v1/domains", - status_code=200, - text='[{"id": 1234, "name": "unit.tests", "hasGeoIP": true}]') - mock.get( - "https://api.dns.constellix.com/v1/domains/1234", - status_code=200, - text='{"id": 1234, "name": "unit.tests", "hasGeoIP": true}') - mock.delete(ANY, status_code=200, - text='{}') - mock.put("https://api.dns.constellix.com/v1/pools/A/1808521", - status_code=400, - text='{"errors": [\"no changes to save\"]}') - mock.put("https://api.dns.constellix.com/v1/pools/A/1808522", - status_code=400, - text='{"errors": [\"no changes to save\"]}') - mock.put("https://api.dns.constellix.com/v1/geoFilters/5303", - status_code=400, - text='{"errors": [\"generic error\"]}') - mock.post(ANY, status_code=200, - text='[{"id": 1234}]') - - plan = provider.plan(wanted) - self.assertEquals(1, len(plan.changes)) - with self.assertRaises(ConstellixClientBadRequest): - provider.apply(plan) - - # Now what happens if an error happens that we can't handle - with requests_mock() as mock: - mock.get( - "https://api.dns.constellix.com/v1/domains", - status_code=200, - text='[{"id": 1234, "name": "unit.tests", "hasGeoIP": true}]') - mock.get( - "https://api.dns.constellix.com/v1/domains/1234", - status_code=200, - text='{"id": 1234, "name": "unit.tests", "hasGeoIP": true}') - mock.delete(ANY, status_code=200, - text='{}') - mock.put("https://api.dns.constellix.com/v1/pools/A/1808521", - status_code=400, - text='{"errors": [\"generic error\"]}') - mock.put("https://api.dns.constellix.com/v1/pools/A/1808522", - status_code=400, - text='{"errors": [\"generic error\"]}') - mock.put("https://api.dns.constellix.com/v1/geoFilters/5303", - status_code=400, - text='{"errors": [\"generic error\"]}') - mock.post(ANY, status_code=200, - text='[{"id": 1234}]') - - plan = provider.plan(wanted) - self.assertEquals(1, len(plan.changes)) - with self.assertRaises(ConstellixClientBadRequest): - provider.apply(plan) - - def test_pools_that_are_notfound(self): - provider = ConstellixProvider('test', 'api', 'secret') - - provider._client.pools = Mock(return_value=[{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:two", - "type": "A", - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - } - ] - }]) - - self.assertIsNone(provider._client.pool_by_id('A', 1)) - self.assertIsNone(provider._client.pool('A', 'foobar')) - - def test_pools_are_cached_correctly(self): - provider = ConstellixProvider('test', 'api', 'secret') - - provider._client.pools = Mock(return_value=[{ - "id": 1808521, - "name": "unit.tests.:www.dynamic:A:two", - "type": "A", - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - } - ] - }]) - - found = provider._client.pool('A', 'unit.tests.:www.dynamic:A:two') - self.assertIsNotNone(found) - - not_found = provider._client.pool('AAAA', - 'unit.tests.:www.dynamic:A:two') - self.assertIsNone(not_found) - - provider._client.pools = Mock(return_value=[{ - "id": 42, - "name": "unit.tests.:www.dynamic:A:two", - "type": "A", - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - } - ] - }, { - "id": 451, - "name": "unit.tests.:www.dynamic:A:two", - "type": "AAAA", - "values": [ - { - "value": "1.2.3.4", - "weight": 1 - } - ] - }]) - - a_pool = provider._client.pool('A', 'unit.tests.:www.dynamic:A:two') - self.assertEquals(42, a_pool['id']) - - aaaa_pool = provider._client.pool('AAAA', - 'unit.tests.:www.dynamic:A:two') - self.assertEquals(451, aaaa_pool['id']) + def test_missing(self): + with self.assertRaises(ModuleNotFoundError): + from octodns.provider.constellix import ConstellixProvider + ConstellixProvider