Add Constellix Providerpull/388/head
| @ -0,0 +1,447 @@ | |||||
| # | |||||
| # | |||||
| # | |||||
| from __future__ import absolute_import, division, print_function, \ | |||||
| unicode_literals | |||||
| from collections import defaultdict | |||||
| from requests import Session | |||||
| from base64 import b64encode | |||||
| from ipaddress import ip_address | |||||
| import hashlib | |||||
| import hmac | |||||
| import logging | |||||
| import time | |||||
| from ..record import Record | |||||
| from .base import BaseProvider | |||||
| class ConstellixClientException(Exception): | |||||
| pass | |||||
| class ConstellixClientBadRequest(ConstellixClientException): | |||||
| def __init__(self, resp): | |||||
| errors = resp.json()['errors'] | |||||
| super(ConstellixClientBadRequest, self).__init__( | |||||
| '\n - {}'.format('\n - '.join(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/domains' | |||||
| 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 | |||||
| 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 = '{}{}'.format(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', '/').json() | |||||
| zones += resp | |||||
| self._domains = {'{}.'.format(z['name']): z['id'] for z in zones} | |||||
| return self._domains | |||||
| def domain(self, name): | |||||
| path = '/{}'.format(self.domains.get(name)) | |||||
| return self._request('GET', path).json() | |||||
| def domain_create(self, name): | |||||
| self._request('POST', '/', data={'names': [name]}) | |||||
| def _absolutize_value(self, value, zone_name): | |||||
| if value == '': | |||||
| value = zone_name | |||||
| elif not value.endswith('.'): | |||||
| value = '{}.{}'.format(value, zone_name) | |||||
| return value | |||||
| def records(self, zone_name): | |||||
| zone_id = self.domains.get(zone_name, False) | |||||
| path = '/{}/records'.format(zone_id) | |||||
| 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, unicode): | |||||
| 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) | |||||
| # compress IPv6 addresses | |||||
| if record['type'] == 'AAAA': | |||||
| for i, v in enumerate(value): | |||||
| value[i] = str(ip_address(v)) | |||||
| 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 = '/{}/records/{}'.format(zone_id, record_type) | |||||
| self._request('POST', path, data=params) | |||||
| def record_delete(self, zone_name, record_type, record_id): | |||||
| zone_id = self.domains.get(zone_name, False) | |||||
| path = '/{}/records/{}/{}'.format(zone_id, record_type, record_id) | |||||
| self._request('DELETE', path) | |||||
| 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 = False | |||||
| 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('ConstellixProvider[{}]'.format(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._zone_records = {} | |||||
| def _data_for_multiple(self, _type, records): | |||||
| record = records[0] | |||||
| return { | |||||
| 'ttl': record['ttl'], | |||||
| 'type': _type, | |||||
| 'values': record['value'] | |||||
| } | |||||
| _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, '_data_for_{}'.format(_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 _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 _apply_Create(self, change): | |||||
| new = change.new | |||||
| params_for = getattr(self, '_params_for_{}'.format(new._type)) | |||||
| for params in params_for(new): | |||||
| self._client.record_create(new.zone.name, new._type, params) | |||||
| def _apply_Update(self, change): | |||||
| self._apply_Delete(change) | |||||
| self._apply_Create(change) | |||||
| def _apply_Delete(self, change): | |||||
| existing = change.existing | |||||
| zone = existing.zone | |||||
| for record in self.zone_records(zone): | |||||
| if existing.name == record['name'] and \ | |||||
| existing._type == record['type']: | |||||
| self._client.record_delete(zone.name, record['type'], | |||||
| record['id']) | |||||
| 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, '_apply_{}'.format(class_name))(change) | |||||
| # Clear out the cache if any | |||||
| self._zone_records.pop(desired.name, None) | |||||
| @ -0,0 +1,28 @@ | |||||
| [{ | |||||
| "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": [] | |||||
| }] | |||||
| @ -0,0 +1,598 @@ | |||||
| [{ | |||||
| "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": 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": 1808603, | |||||
| "type": "ANAME", | |||||
| "recordType": "aname", | |||||
| "name": "sub", | |||||
| "recordOption": "roundRobin", | |||||
| "noAnswer": false, | |||||
| "note": "", | |||||
| "ttl": 1800, | |||||
| "gtdRegion": 1, | |||||
| "parentId": 123123, | |||||
| "parent": "domain", | |||||
| "source": "Domain", | |||||
| "modifiedTs": 1565153387855, | |||||
| "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": 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": [] | |||||
| }] | |||||
| @ -0,0 +1,218 @@ | |||||
| # | |||||
| # | |||||
| # | |||||
| from __future__ import absolute_import, division, print_function, \ | |||||
| unicode_literals | |||||
| from mock import Mock, 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 ConstellixClientNotFound, \ | |||||
| ConstellixProvider | |||||
| from octodns.provider.yaml import YamlProvider | |||||
| from octodns.zone import Zone | |||||
| import json | |||||
| 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.' | |||||
| })) | |||||
| expected.add_record(Record.new(expected, 'sub', { | |||||
| 'ttl': 1800, | |||||
| 'type': 'ALIAS', | |||||
| 'value': 'aname.unit.tests.' | |||||
| })) | |||||
| for record in list(expected.records): | |||||
| if record.name == 'sub' and record._type == 'NS': | |||||
| expected._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', ctx.exception.message) | |||||
| # 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', | |||||
| ctx.exception.message) | |||||
| # 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='<html><head></head><body></body></html>') | |||||
| zone = Zone('unit.tests.', []) | |||||
| provider.populate(zone) | |||||
| self.assertEquals(set(), zone.records) | |||||
| # No diffs == no changes | |||||
| with requests_mock() as mock: | |||||
| base = 'https://api.dns.constellix.com/v1/domains' | |||||
| with open('tests/fixtures/constellix-domains.json') as fh: | |||||
| mock.get('{}{}'.format(base, '/'), text=fh.read()) | |||||
| with open('tests/fixtures/constellix-records.json') as fh: | |||||
| mock.get('{}{}'.format(base, '/123123/records'), | |||||
| text=fh.read()) | |||||
| zone = Zone('unit.tests.', []) | |||||
| provider.populate(zone) | |||||
| self.assertEquals(15, len(zone.records)) | |||||
| changes = self.expected.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(15, 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) | |||||
| with open('tests/fixtures/constellix-domains.json') as fh: | |||||
| domains = json.load(fh) | |||||
| # non-existent domain, create everything | |||||
| resp.json.side_effect = [ | |||||
| ConstellixClientNotFound, # no zone in populate | |||||
| ConstellixClientNotFound, # no domain during apply | |||||
| domains | |||||
| ] | |||||
| plan = provider.plan(self.expected) | |||||
| # No root NS, no ignored, no excluded, no unsupported | |||||
| n = len(self.expected.records) - 5 | |||||
| self.assertEquals(n, len(plan.changes)) | |||||
| self.assertEquals(n, provider.apply(plan)) | |||||
| provider._client._request.assert_has_calls([ | |||||
| # created the domain | |||||
| call('POST', '/', data={'names': ['unit.tests']}), | |||||
| # get all domains to build the cache | |||||
| call('GET', '/'), | |||||
| call('POST', '/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(20, provider._client._request.call_count) | |||||
| provider._client._request.reset_mock() | |||||
| provider._client.records = Mock(return_value=[ | |||||
| { | |||||
| 'id': 11189897, | |||||
| 'type': 'A', | |||||
| 'name': 'www', | |||||
| 'ttl': 300, | |||||
| 'value': [ | |||||
| '1.2.3.4', | |||||
| '2.2.3.4', | |||||
| ] | |||||
| }, { | |||||
| 'id': 11189898, | |||||
| 'type': 'A', | |||||
| 'name': 'ttl', | |||||
| 'ttl': 600, | |||||
| 'value': [ | |||||
| '3.2.3.4' | |||||
| ] | |||||
| } | |||||
| ]) | |||||
| # Domain exists, we don't care about return | |||||
| resp.json.side_effect = ['{}'] | |||||
| wanted = Zone('unit.tests.', []) | |||||
| wanted.add_record(Record.new(wanted, 'ttl', { | |||||
| 'ttl': 300, | |||||
| 'type': 'A', | |||||
| 'value': '3.2.3.4' | |||||
| })) | |||||
| plan = provider.plan(wanted) | |||||
| self.assertEquals(2, len(plan.changes)) | |||||
| self.assertEquals(2, provider.apply(plan)) | |||||
| # recreate for update, and deletes for the 2 parts of the other | |||||
| provider._client._request.assert_has_calls([ | |||||
| call('POST', '/123123/records/A', data={ | |||||
| 'roundRobin': [{ | |||||
| 'value': '3.2.3.4' | |||||
| }], | |||||
| 'name': 'ttl', | |||||
| 'ttl': 300 | |||||
| }), | |||||
| call('DELETE', '/123123/records/A/11189897'), | |||||
| call('DELETE', '/123123/records/A/11189898') | |||||
| ], any_order=True) | |||||