| @ -0,0 +1,343 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from collections import defaultdict | |||
| from requests import Session | |||
| import logging | |||
| from ..record import Record | |||
| from .base import BaseProvider | |||
| class DigitalOceanClientException(Exception): | |||
| pass | |||
| class DigitalOceanClientNotFound(DigitalOceanClientException): | |||
| def __init__(self): | |||
| super(DigitalOceanClientNotFound, self).__init__('Not Found') | |||
| class DigitalOceanClientUnauthorized(DigitalOceanClientException): | |||
| def __init__(self): | |||
| super(DigitalOceanClientUnauthorized, self).__init__('Unauthorized') | |||
| class DigitalOceanClient(object): | |||
| BASE = 'https://api.digitalocean.com/v2' | |||
| def __init__(self, token): | |||
| sess = Session() | |||
| sess.headers.update({'Authorization': 'Bearer {}'.format(token)}) | |||
| self._sess = sess | |||
| def _request(self, method, path, params=None, data=None): | |||
| url = '{}{}'.format(self.BASE, path) | |||
| resp = self._sess.request(method, url, params=params, json=data) | |||
| if resp.status_code == 401: | |||
| raise DigitalOceanClientUnauthorized() | |||
| if resp.status_code == 404: | |||
| raise DigitalOceanClientNotFound() | |||
| resp.raise_for_status() | |||
| return resp | |||
| def domain(self, name): | |||
| path = '/domains/{}'.format(name) | |||
| return self._request('GET', path).json() | |||
| def domain_create(self, name): | |||
| # Digitalocean requires an IP on zone creation | |||
| self._request('POST', '/domains', data={'name': name, | |||
| 'ip_address': '192.0.2.1'}) | |||
| # After the zone is created, immediately delete the record | |||
| records = self.records(name) | |||
| for record in records: | |||
| if record['name'] == '' and record['type'] == 'A': | |||
| self.record_delete(name, record['id']) | |||
| def records(self, zone_name): | |||
| path = '/domains/{}/records'.format(zone_name) | |||
| ret = [] | |||
| page = 1 | |||
| while True: | |||
| data = self._request('GET', path, {'page': page}).json() | |||
| ret += data['domain_records'] | |||
| links = data['links'] | |||
| # https://developers.digitalocean.com/documentation/v2/#links | |||
| # pages exists if there is more than 1 page | |||
| # last doesn't exist if you're on the last page | |||
| try: | |||
| links['pages']['last'] | |||
| page += 1 | |||
| except KeyError: | |||
| break | |||
| for record in ret: | |||
| # change any apex record to empty string | |||
| if record['name'] == '@': | |||
| record['name'] = '' | |||
| # change any apex value to zone name | |||
| if record['data'] == '@': | |||
| record['data'] = zone_name | |||
| return ret | |||
| def record_create(self, zone_name, params): | |||
| path = '/domains/{}/records'.format(zone_name) | |||
| # change empty name string to @, DO uses @ for apex record names | |||
| if params['name'] == '': | |||
| params['name'] = '@' | |||
| self._request('POST', path, data=params) | |||
| def record_delete(self, zone_name, record_id): | |||
| path = '/domains/{}/records/{}'.format(zone_name, record_id) | |||
| self._request('DELETE', path) | |||
| class DigitalOceanProvider(BaseProvider): | |||
| ''' | |||
| DigitalOcean DNS provider using API v2 | |||
| digitalocean: | |||
| class: octodns.provider.digitalocean.DigitalOceanProvider | |||
| # Your DigitalOcean API token (required) | |||
| token: foo | |||
| ''' | |||
| SUPPORTS_GEO = False | |||
| SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'TXT', 'SRV')) | |||
| def __init__(self, id, token, *args, **kwargs): | |||
| self.log = logging.getLogger('DigitalOceanProvider[{}]'.format(id)) | |||
| self.log.debug('__init__: id=%s, token=***', id) | |||
| super(DigitalOceanProvider, self).__init__(id, *args, **kwargs) | |||
| self._client = DigitalOceanClient(token) | |||
| self._zone_records = {} | |||
| def _data_for_multiple(self, _type, records): | |||
| return { | |||
| 'ttl': records[0]['ttl'], | |||
| 'type': _type, | |||
| 'values': [r['data'] for r in records] | |||
| } | |||
| _data_for_A = _data_for_multiple | |||
| _data_for_AAAA = _data_for_multiple | |||
| def _data_for_CAA(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| values.append({ | |||
| 'flags': record['flags'], | |||
| 'tag': record['tag'], | |||
| 'value': record['data'], | |||
| }) | |||
| return { | |||
| 'ttl': records[0]['ttl'], | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| def _data_for_CNAME(self, _type, records): | |||
| record = records[0] | |||
| return { | |||
| 'ttl': record['ttl'], | |||
| 'type': _type, | |||
| 'value': '{}.'.format(record['data']) | |||
| } | |||
| def _data_for_MX(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| values.append({ | |||
| 'preference': record['priority'], | |||
| 'exchange': '{}.'.format(record['data']) | |||
| }) | |||
| return { | |||
| 'ttl': records[0]['ttl'], | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| def _data_for_NS(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| data = '{}.'.format(record['data']) | |||
| values.append(data) | |||
| return { | |||
| 'ttl': records[0]['ttl'], | |||
| 'type': _type, | |||
| 'values': values, | |||
| } | |||
| def _data_for_SRV(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| values.append({ | |||
| 'port': record['port'], | |||
| 'priority': record['priority'], | |||
| 'target': '{}.'.format(record['data']), | |||
| 'weight': record['weight'] | |||
| }) | |||
| return { | |||
| 'type': _type, | |||
| 'ttl': records[0]['ttl'], | |||
| 'values': values | |||
| } | |||
| def _data_for_TXT(self, _type, records): | |||
| values = [value['data'].replace(';', '\;') for value in records] | |||
| return { | |||
| 'ttl': records[0]['ttl'], | |||
| 'type': _type, | |||
| '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[:-1]) | |||
| except DigitalOceanClientNotFound: | |||
| 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'] | |||
| 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) | |||
| 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): | |||
| for value in record.values: | |||
| yield { | |||
| 'data': value, | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| _params_for_A = _params_for_multiple | |||
| _params_for_AAAA = _params_for_multiple | |||
| _params_for_NS = _params_for_multiple | |||
| def _params_for_CAA(self, record): | |||
| for value in record.values: | |||
| yield { | |||
| 'data': '{}.'.format(value.value), | |||
| 'flags': value.flags, | |||
| 'name': record.name, | |||
| 'tag': value.tag, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| def _params_for_single(self, record): | |||
| yield { | |||
| 'data': record.value, | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| _params_for_CNAME = _params_for_single | |||
| def _params_for_MX(self, record): | |||
| for value in record.values: | |||
| yield { | |||
| 'data': value.exchange, | |||
| 'name': record.name, | |||
| 'priority': value.preference, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| def _params_for_SRV(self, record): | |||
| for value in record.values: | |||
| yield { | |||
| 'data': value.target, | |||
| 'name': record.name, | |||
| 'port': value.port, | |||
| 'priority': value.priority, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type, | |||
| 'weight': value.weight | |||
| } | |||
| def _params_for_TXT(self, record): | |||
| # DigitalOcean doesn't want things escaped in values so we | |||
| # have to strip them here and add them when going the other way | |||
| for value in record.values: | |||
| yield { | |||
| 'data': value.replace('\;', ';'), | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| 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[:-1], 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[:-1], 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)) | |||
| domain_name = desired.name[:-1] | |||
| try: | |||
| self._client.domain(domain_name) | |||
| except DigitalOceanClientNotFound: | |||
| self.log.debug('_apply: no matching zone, creating domain') | |||
| self._client.domain_create(domain_name) | |||
| 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,382 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from collections import defaultdict | |||
| from requests import Session | |||
| from time import strftime, gmtime, sleep | |||
| import hashlib | |||
| import hmac | |||
| import logging | |||
| from ..record import Record | |||
| from .base import BaseProvider | |||
| class DnsMadeEasyClientException(Exception): | |||
| pass | |||
| class DnsMadeEasyClientBadRequest(DnsMadeEasyClientException): | |||
| def __init__(self, resp): | |||
| errors = resp.json()['error'] | |||
| super(DnsMadeEasyClientBadRequest, self).__init__( | |||
| '\n - {}'.format('\n - '.join(errors))) | |||
| class DnsMadeEasyClientUnauthorized(DnsMadeEasyClientException): | |||
| def __init__(self): | |||
| super(DnsMadeEasyClientUnauthorized, self).__init__('Unauthorized') | |||
| class DnsMadeEasyClientNotFound(DnsMadeEasyClientException): | |||
| def __init__(self): | |||
| super(DnsMadeEasyClientNotFound, self).__init__('Not Found') | |||
| class DnsMadeEasyClient(object): | |||
| PRODUCTION = 'https://api.dnsmadeeasy.com/V2.0/dns/managed' | |||
| SANDBOX = 'https://api.sandbox.dnsmadeeasy.com/V2.0/dns/managed' | |||
| def __init__(self, api_key, secret_key, sandbox=False, | |||
| ratelimit_delay=0.0): | |||
| self.api_key = api_key | |||
| self.secret_key = secret_key | |||
| self._base = self.SANDBOX if sandbox else self.PRODUCTION | |||
| self.ratelimit_delay = ratelimit_delay | |||
| self._sess = Session() | |||
| self._sess.headers.update({'x-dnsme-apiKey': self.api_key}) | |||
| self._domains = None | |||
| def _current_time(self): | |||
| return strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime()) | |||
| def _hmac_hash(self, now): | |||
| return hmac.new(self.secret_key.encode(), now.encode(), | |||
| hashlib.sha1).hexdigest() | |||
| def _request(self, method, path, params=None, data=None): | |||
| now = self._current_time() | |||
| hmac_hash = self._hmac_hash(now) | |||
| headers = { | |||
| 'x-dnsme-hmac': hmac_hash, | |||
| 'x-dnsme-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 DnsMadeEasyClientBadRequest(resp) | |||
| if resp.status_code in [401, 403]: | |||
| raise DnsMadeEasyClientUnauthorized() | |||
| if resp.status_code == 404: | |||
| raise DnsMadeEasyClientNotFound() | |||
| resp.raise_for_status() | |||
| sleep(self.ratelimit_delay) | |||
| return resp | |||
| @property | |||
| def domains(self): | |||
| if self._domains is None: | |||
| zones = [] | |||
| # has pages in resp, do we need paging? | |||
| resp = self._request('GET', '/').json() | |||
| zones += resp['data'] | |||
| self._domains = {'{}.'.format(z['name']): z['id'] for z in zones} | |||
| return self._domains | |||
| def domain(self, name): | |||
| path = '/id/{}'.format(name) | |||
| return self._request('GET', path).json() | |||
| def domain_create(self, name): | |||
| self._request('POST', '/', data={'name': name}) | |||
| def records(self, zone_name): | |||
| zone_id = self.domains.get(zone_name, False) | |||
| path = '/{}/records'.format(zone_id) | |||
| ret = [] | |||
| # has pages in resp, do we need paging? | |||
| resp = self._request('GET', path).json() | |||
| ret += resp['data'] | |||
| # change relative values to absolute | |||
| for record in ret: | |||
| value = record['value'] | |||
| if record['type'] in ['CNAME', 'MX', 'NS', 'SRV']: | |||
| if value == '': | |||
| record['value'] = zone_name | |||
| elif not value.endswith('.'): | |||
| record['value'] = '{}.{}'.format(value, zone_name) | |||
| return ret | |||
| def record_create(self, zone_name, params): | |||
| zone_id = self.domains.get(zone_name, False) | |||
| path = '/{}/records'.format(zone_id) | |||
| self._request('POST', path, data=params) | |||
| def record_delete(self, zone_name, record_id): | |||
| zone_id = self.domains.get(zone_name, False) | |||
| path = '/{}/records/{}'.format(zone_id, record_id) | |||
| self._request('DELETE', path) | |||
| class DnsMadeEasyProvider(BaseProvider): | |||
| ''' | |||
| DNSMadeEasy DNS provider using v2.0 API | |||
| dnsmadeeasy: | |||
| class: octodns.provider.dnsmadeeasy.DnsMadeEasyProvider | |||
| # Your DnsMadeEasy api key (required) | |||
| api_key: env/DNSMADEEASY_API_KEY | |||
| # Your DnsMadeEasy secret key (required) | |||
| secret_key: env/DNSMADEEASY_SECRET_KEY | |||
| # Whether or not to use Sandbox environment | |||
| # (optional, default is false) | |||
| sandbox: true | |||
| ''' | |||
| SUPPORTS_GEO = False | |||
| SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', | |||
| 'NS', 'PTR', 'SPF', 'SRV', 'TXT')) | |||
| def __init__(self, id, api_key, secret_key, sandbox=False, | |||
| ratelimit_delay=0.0, *args, **kwargs): | |||
| self.log = logging.getLogger('DnsMadeEasyProvider[{}]'.format(id)) | |||
| self.log.debug('__init__: id=%s, api_key=***, secret_key=***, ' | |||
| 'sandbox=%s', id, sandbox) | |||
| super(DnsMadeEasyProvider, self).__init__(id, *args, **kwargs) | |||
| self._client = DnsMadeEasyClient(api_key, secret_key, sandbox, | |||
| ratelimit_delay) | |||
| self._zone_records = {} | |||
| def _data_for_multiple(self, _type, records): | |||
| return { | |||
| 'ttl': records[0]['ttl'], | |||
| 'type': _type, | |||
| 'values': [r['value'] for r in records] | |||
| } | |||
| _data_for_A = _data_for_multiple | |||
| _data_for_AAAA = _data_for_multiple | |||
| _data_for_NS = _data_for_multiple | |||
| def _data_for_CAA(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| values.append({ | |||
| 'flags': record['issuerCritical'], | |||
| 'tag': record['caaType'], | |||
| 'value': record['value'][1:-1] | |||
| }) | |||
| return { | |||
| 'ttl': records[0]['ttl'], | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| def _data_for_TXT(self, _type, records): | |||
| values = [value['value'].replace(';', '\;') for value in records] | |||
| return { | |||
| 'ttl': records[0]['ttl'], | |||
| 'type': _type, | |||
| 'values': values | |||
| } | |||
| _data_for_SPF = _data_for_TXT | |||
| def _data_for_MX(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| values.append({ | |||
| 'preference': record['mxLevel'], | |||
| 'exchange': record['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 | |||
| _data_for_PTR = _data_for_single | |||
| def _data_for_SRV(self, _type, records): | |||
| values = [] | |||
| for record in records: | |||
| values.append({ | |||
| 'port': record['port'], | |||
| 'priority': record['priority'], | |||
| 'target': record['value'], | |||
| 'weight': record['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 DnsMadeEasyClientNotFound: | |||
| 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'] | |||
| 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) | |||
| 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): | |||
| for value in record.values: | |||
| yield { | |||
| 'value': value, | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| _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 { | |||
| 'value': record.value, | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| _params_for_CNAME = _params_for_single | |||
| _params_for_PTR = _params_for_single | |||
| def _params_for_MX(self, record): | |||
| for value in record.values: | |||
| yield { | |||
| 'value': value.exchange, | |||
| 'name': record.name, | |||
| 'mxLevel': value.preference, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| def _params_for_SRV(self, record): | |||
| for value in record.values: | |||
| yield { | |||
| 'value': value.target, | |||
| 'name': record.name, | |||
| 'port': value.port, | |||
| 'priority': value.priority, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type, | |||
| 'weight': value.weight | |||
| } | |||
| def _params_for_TXT(self, record): | |||
| # DNSMadeEasy does not want values escaped | |||
| for value in record.chunked_values: | |||
| yield { | |||
| 'value': value.replace('\;', ';'), | |||
| 'name': record.name, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| _params_for_SPF = _params_for_TXT | |||
| def _params_for_CAA(self, record): | |||
| for value in record.values: | |||
| yield { | |||
| 'value': value.value, | |||
| 'issuerCritical': value.flags, | |||
| 'name': record.name, | |||
| 'caaType': value.tag, | |||
| 'ttl': record.ttl, | |||
| 'type': record._type | |||
| } | |||
| 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, 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['id']) | |||
| def _apply(self, plan): | |||
| desired = plan.desired | |||
| changes = plan.changes | |||
| self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, | |||
| len(changes)) | |||
| domain_name = desired.name[:-1] | |||
| try: | |||
| self._client.domain(domain_name) | |||
| except DnsMadeEasyClientNotFound: | |||
| self.log.debug('_apply: no matching zone, creating domain') | |||
| self._client.domain_create(domain_name) | |||
| 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,285 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from StringIO import StringIO | |||
| from logging import DEBUG, ERROR, INFO, WARN, getLogger | |||
| from sys import stdout | |||
| class UnsafePlan(Exception): | |||
| pass | |||
| class Plan(object): | |||
| log = getLogger('Plan') | |||
| MAX_SAFE_UPDATE_PCENT = .3 | |||
| MAX_SAFE_DELETE_PCENT = .3 | |||
| MIN_EXISTING_RECORDS = 10 | |||
| def __init__(self, existing, desired, changes, exists, | |||
| update_pcent_threshold=MAX_SAFE_UPDATE_PCENT, | |||
| delete_pcent_threshold=MAX_SAFE_DELETE_PCENT): | |||
| self.existing = existing | |||
| self.desired = desired | |||
| self.changes = changes | |||
| self.exists = exists | |||
| self.update_pcent_threshold = update_pcent_threshold | |||
| self.delete_pcent_threshold = delete_pcent_threshold | |||
| change_counts = { | |||
| 'Create': 0, | |||
| 'Delete': 0, | |||
| 'Update': 0 | |||
| } | |||
| for change in changes: | |||
| change_counts[change.__class__.__name__] += 1 | |||
| self.change_counts = change_counts | |||
| try: | |||
| existing_n = len(self.existing.records) | |||
| except AttributeError: | |||
| existing_n = 0 | |||
| self.log.debug('__init__: Creates=%d, Updates=%d, Deletes=%d' | |||
| 'Existing=%d', | |||
| self.change_counts['Create'], | |||
| self.change_counts['Update'], | |||
| self.change_counts['Delete'], existing_n) | |||
| def raise_if_unsafe(self): | |||
| # TODO: what is safe really? | |||
| if self.existing and \ | |||
| len(self.existing.records) >= self.MIN_EXISTING_RECORDS: | |||
| existing_record_count = len(self.existing.records) | |||
| update_pcent = self.change_counts['Update'] / existing_record_count | |||
| delete_pcent = self.change_counts['Delete'] / existing_record_count | |||
| if update_pcent > self.update_pcent_threshold: | |||
| raise UnsafePlan('Too many updates, {:.2f} is over {:.2f} %' | |||
| '({}/{})'.format( | |||
| update_pcent * 100, | |||
| self.update_pcent_threshold * 100, | |||
| self.change_counts['Update'], | |||
| existing_record_count)) | |||
| if delete_pcent > self.delete_pcent_threshold: | |||
| raise UnsafePlan('Too many deletes, {:.2f} is over {:.2f} %' | |||
| '({}/{})'.format( | |||
| delete_pcent * 100, | |||
| self.delete_pcent_threshold * 100, | |||
| self.change_counts['Delete'], | |||
| existing_record_count)) | |||
| def __repr__(self): | |||
| return 'Creates={}, Updates={}, Deletes={}, Existing Records={}' \ | |||
| .format(self.change_counts['Create'], self.change_counts['Update'], | |||
| self.change_counts['Delete'], | |||
| len(self.existing.records)) | |||
| class _PlanOutput(object): | |||
| def __init__(self, name): | |||
| self.name = name | |||
| class PlanLogger(_PlanOutput): | |||
| def __init__(self, name, level='info'): | |||
| super(PlanLogger, self).__init__(name) | |||
| try: | |||
| self.level = { | |||
| 'debug': DEBUG, | |||
| 'info': INFO, | |||
| 'warn': WARN, | |||
| 'warning': WARN, | |||
| 'error': ERROR | |||
| }[level.lower()] | |||
| except (AttributeError, KeyError): | |||
| raise Exception('Unsupported level: {}'.format(level)) | |||
| def run(self, log, plans, *args, **kwargs): | |||
| hr = '*************************************************************' \ | |||
| '*******************\n' | |||
| buf = StringIO() | |||
| buf.write('\n') | |||
| if plans: | |||
| current_zone = None | |||
| for target, plan in plans: | |||
| if plan.desired.name != current_zone: | |||
| current_zone = plan.desired.name | |||
| buf.write(hr) | |||
| buf.write('* ') | |||
| buf.write(current_zone) | |||
| buf.write('\n') | |||
| buf.write(hr) | |||
| buf.write('* ') | |||
| buf.write(target.id) | |||
| buf.write(' (') | |||
| buf.write(target) | |||
| buf.write(')\n* ') | |||
| if plan.exists is False: | |||
| buf.write('Create ') | |||
| buf.write(str(plan.desired)) | |||
| buf.write('\n* ') | |||
| for change in plan.changes: | |||
| buf.write(change.__repr__(leader='* ')) | |||
| buf.write('\n* ') | |||
| buf.write('Summary: ') | |||
| buf.write(plan) | |||
| buf.write('\n') | |||
| else: | |||
| buf.write(hr) | |||
| buf.write('No changes were planned\n') | |||
| buf.write(hr) | |||
| buf.write('\n') | |||
| log.log(self.level, buf.getvalue()) | |||
| def _value_stringifier(record, sep): | |||
| try: | |||
| values = [unicode(v) for v in record.values] | |||
| except AttributeError: | |||
| values = [record.value] | |||
| for code, gv in sorted(getattr(record, 'geo', {}).items()): | |||
| vs = ', '.join([unicode(v) for v in gv.values]) | |||
| values.append('{}: {}'.format(code, vs)) | |||
| return sep.join(values) | |||
| class PlanMarkdown(_PlanOutput): | |||
| def run(self, plans, fh=stdout, *args, **kwargs): | |||
| if plans: | |||
| current_zone = None | |||
| for target, plan in plans: | |||
| if plan.desired.name != current_zone: | |||
| current_zone = plan.desired.name | |||
| fh.write('## ') | |||
| fh.write(current_zone) | |||
| fh.write('\n\n') | |||
| fh.write('### ') | |||
| fh.write(target.id) | |||
| fh.write('\n\n') | |||
| fh.write('| Operation | Name | Type | TTL | Value | Source |\n' | |||
| '|--|--|--|--|--|--|\n') | |||
| if plan.exists is False: | |||
| fh.write('| Create | ') | |||
| fh.write(str(plan.desired)) | |||
| fh.write(' | | | | |\n') | |||
| for change in plan.changes: | |||
| existing = change.existing | |||
| new = change.new | |||
| record = change.record | |||
| fh.write('| ') | |||
| fh.write(change.__class__.__name__) | |||
| fh.write(' | ') | |||
| fh.write(record.name) | |||
| fh.write(' | ') | |||
| fh.write(record._type) | |||
| fh.write(' | ') | |||
| # TTL | |||
| if existing: | |||
| fh.write(unicode(existing.ttl)) | |||
| fh.write(' | ') | |||
| fh.write(_value_stringifier(existing, '; ')) | |||
| fh.write(' | |\n') | |||
| if new: | |||
| fh.write('| | | | ') | |||
| if new: | |||
| fh.write(unicode(new.ttl)) | |||
| fh.write(' | ') | |||
| fh.write(_value_stringifier(new, '; ')) | |||
| fh.write(' | ') | |||
| if new.source: | |||
| fh.write(new.source.id) | |||
| fh.write(' |\n') | |||
| fh.write('\nSummary: ') | |||
| fh.write(unicode(plan)) | |||
| fh.write('\n\n') | |||
| else: | |||
| fh.write('## No changes were planned\n') | |||
| class PlanHtml(_PlanOutput): | |||
| def run(self, plans, fh=stdout, *args, **kwargs): | |||
| if plans: | |||
| current_zone = None | |||
| for target, plan in plans: | |||
| if plan.desired.name != current_zone: | |||
| current_zone = plan.desired.name | |||
| fh.write('<h2>') | |||
| fh.write(current_zone) | |||
| fh.write('</h2>\n') | |||
| fh.write('<h3>') | |||
| fh.write(target.id) | |||
| fh.write('''</h3> | |||
| <table> | |||
| <tr> | |||
| <th>Operation</th> | |||
| <th>Name</th> | |||
| <th>Type</th> | |||
| <th>TTL</th> | |||
| <th>Value</th> | |||
| <th>Source</th> | |||
| </tr> | |||
| ''') | |||
| if plan.exists is False: | |||
| fh.write(' <tr>\n <td>Create</td>\n <td colspan=5>') | |||
| fh.write(str(plan.desired)) | |||
| fh.write('</td>\n </tr>\n') | |||
| for change in plan.changes: | |||
| existing = change.existing | |||
| new = change.new | |||
| record = change.record | |||
| fh.write(' <tr>\n <td>') | |||
| fh.write(change.__class__.__name__) | |||
| fh.write('</td>\n <td>') | |||
| fh.write(record.name) | |||
| fh.write('</td>\n <td>') | |||
| fh.write(record._type) | |||
| fh.write('</td>\n') | |||
| # TTL | |||
| if existing: | |||
| fh.write(' <td>') | |||
| fh.write(unicode(existing.ttl)) | |||
| fh.write('</td>\n <td>') | |||
| fh.write(_value_stringifier(existing, '<br/>')) | |||
| fh.write('</td>\n <td></td>\n </tr>\n') | |||
| if new: | |||
| fh.write(' <tr>\n <td colspan=3></td>\n') | |||
| if new: | |||
| fh.write(' <td>') | |||
| fh.write(unicode(new.ttl)) | |||
| fh.write('</td>\n <td>') | |||
| fh.write(_value_stringifier(new, '<br/>')) | |||
| fh.write('</td>\n <td>') | |||
| if new.source: | |||
| fh.write(new.source.id) | |||
| fh.write('</td>\n </tr>\n') | |||
| fh.write(' <tr>\n <td colspan=6>Summary: ') | |||
| fh.write(unicode(plan)) | |||
| fh.write('</td>\n </tr>\n</table>\n') | |||
| else: | |||
| fh.write('<b>No changes were planned</b>') | |||
| @ -0,0 +1,376 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from requests import HTTPError, Session, post | |||
| from collections import defaultdict | |||
| import logging | |||
| import string | |||
| import time | |||
| from ..record import Record | |||
| from .base import BaseProvider | |||
| def add_trailing_dot(s): | |||
| assert s | |||
| assert s[-1] != '.' | |||
| return s + '.' | |||
| def remove_trailing_dot(s): | |||
| assert s | |||
| assert s[-1] == '.' | |||
| return s[:-1] | |||
| def escape_semicolon(s): | |||
| assert s | |||
| return string.replace(s, ';', '\;') | |||
| def unescape_semicolon(s): | |||
| assert s | |||
| return string.replace(s, '\;', ';') | |||
| class RackspaceProvider(BaseProvider): | |||
| SUPPORTS_GEO = False | |||
| SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NS', 'PTR', 'SPF', | |||
| 'TXT')) | |||
| TIMEOUT = 5 | |||
| def __init__(self, id, username, api_key, ratelimit_delay=0.0, *args, | |||
| **kwargs): | |||
| ''' | |||
| Rackspace API v1 Provider | |||
| rackspace: | |||
| class: octodns.provider.rackspace.RackspaceProvider | |||
| # The the username to authenticate with (required) | |||
| username: username | |||
| # The api key that grants access for that user (required) | |||
| api_key: api-key | |||
| ''' | |||
| self.log = logging.getLogger('RackspaceProvider[{}]'.format(id)) | |||
| super(RackspaceProvider, self).__init__(id, *args, **kwargs) | |||
| auth_token, dns_endpoint = self._get_auth_token(username, api_key) | |||
| self.dns_endpoint = dns_endpoint | |||
| self.ratelimit_delay = float(ratelimit_delay) | |||
| sess = Session() | |||
| sess.headers.update({'X-Auth-Token': auth_token}) | |||
| self._sess = sess | |||
| # Map record type, name, and data to an id when populating so that | |||
| # we can find the id for update and delete operations. | |||
| self._id_map = {} | |||
| def _get_auth_token(self, username, api_key): | |||
| ret = post('https://identity.api.rackspacecloud.com/v2.0/tokens', | |||
| json={"auth": { | |||
| "RAX-KSKEY:apiKeyCredentials": {"username": username, | |||
| "apiKey": api_key}}}, | |||
| ) | |||
| cloud_dns_endpoint = \ | |||
| [x for x in ret.json()['access']['serviceCatalog'] if | |||
| x['name'] == 'cloudDNS'][0]['endpoints'][0]['publicURL'] | |||
| return ret.json()['access']['token']['id'], cloud_dns_endpoint | |||
| def _get_zone_id_for(self, zone): | |||
| ret = self._request('GET', 'domains', pagination_key='domains') | |||
| return [x for x in ret if x['name'] == zone.name[:-1]][0]['id'] | |||
| def _request(self, method, path, data=None, pagination_key=None): | |||
| self.log.debug('_request: method=%s, path=%s', method, path) | |||
| url = '{}/{}'.format(self.dns_endpoint, path) | |||
| if pagination_key: | |||
| resp = self._paginated_request_for_url(method, url, data, | |||
| pagination_key) | |||
| else: | |||
| resp = self._request_for_url(method, url, data) | |||
| time.sleep(self.ratelimit_delay) | |||
| return resp | |||
| def _request_for_url(self, method, url, data): | |||
| resp = self._sess.request(method, url, json=data, timeout=self.TIMEOUT) | |||
| self.log.debug('_request: status=%d', resp.status_code) | |||
| resp.raise_for_status() | |||
| return resp | |||
| def _paginated_request_for_url(self, method, url, data, pagination_key): | |||
| acc = [] | |||
| resp = self._sess.request(method, url, json=data, timeout=self.TIMEOUT) | |||
| self.log.debug('_request: status=%d', resp.status_code) | |||
| resp.raise_for_status() | |||
| acc.extend(resp.json()[pagination_key]) | |||
| next_page = [x for x in resp.json().get('links', []) if | |||
| x['rel'] == 'next'] | |||
| if next_page: | |||
| url = next_page[0]['href'] | |||
| acc.extend(self._paginated_request_for_url(method, url, data, | |||
| pagination_key)) | |||
| return acc | |||
| else: | |||
| return acc | |||
| def _post(self, path, data=None): | |||
| return self._request('POST', path, data=data) | |||
| def _put(self, path, data=None): | |||
| return self._request('PUT', path, data=data) | |||
| def _delete(self, path, data=None): | |||
| return self._request('DELETE', path, data=data) | |||
| @classmethod | |||
| def _key_for_record(cls, rs_record): | |||
| return rs_record['type'], rs_record['name'], rs_record['data'] | |||
| def _data_for_multiple(self, rrset): | |||
| return { | |||
| 'type': rrset[0]['type'], | |||
| 'values': [r['data'] for r in rrset], | |||
| 'ttl': rrset[0]['ttl'] | |||
| } | |||
| _data_for_A = _data_for_multiple | |||
| _data_for_AAAA = _data_for_multiple | |||
| def _data_for_NS(self, rrset): | |||
| return { | |||
| 'type': rrset[0]['type'], | |||
| 'values': [add_trailing_dot(r['data']) for r in rrset], | |||
| 'ttl': rrset[0]['ttl'] | |||
| } | |||
| def _data_for_single(self, record): | |||
| return { | |||
| 'type': record[0]['type'], | |||
| 'value': add_trailing_dot(record[0]['data']), | |||
| 'ttl': record[0]['ttl'] | |||
| } | |||
| _data_for_ALIAS = _data_for_single | |||
| _data_for_CNAME = _data_for_single | |||
| _data_for_PTR = _data_for_single | |||
| def _data_for_textual(self, rrset): | |||
| return { | |||
| 'type': rrset[0]['type'], | |||
| 'values': [escape_semicolon(r['data']) for r in rrset], | |||
| 'ttl': rrset[0]['ttl'] | |||
| } | |||
| _data_for_SPF = _data_for_textual | |||
| _data_for_TXT = _data_for_textual | |||
| def _data_for_MX(self, rrset): | |||
| values = [] | |||
| for record in rrset: | |||
| values.append({ | |||
| 'priority': record['priority'], | |||
| 'value': add_trailing_dot(record['data']), | |||
| }) | |||
| return { | |||
| 'type': rrset[0]['type'], | |||
| 'values': values, | |||
| 'ttl': rrset[0]['ttl'] | |||
| } | |||
| def populate(self, zone, target=False, lenient=False): | |||
| self.log.debug('populate: name=%s', zone.name) | |||
| resp_data = None | |||
| try: | |||
| domain_id = self._get_zone_id_for(zone) | |||
| resp_data = self._request('GET', | |||
| 'domains/{}/records'.format(domain_id), | |||
| pagination_key='records') | |||
| self.log.debug('populate: loaded') | |||
| except HTTPError as e: | |||
| if e.response.status_code == 401: | |||
| # Nicer error message for auth problems | |||
| raise Exception('Rackspace request unauthorized') | |||
| elif e.response.status_code == 404: | |||
| # Zone not found leaves the zone empty instead of failing. | |||
| return False | |||
| raise | |||
| before = len(zone.records) | |||
| if resp_data: | |||
| records = self._group_records(resp_data) | |||
| for record_type, records_of_type in records.items(): | |||
| for raw_record_name, record_set in records_of_type.items(): | |||
| data_for = getattr(self, | |||
| '_data_for_{}'.format(record_type)) | |||
| record_name = zone.hostname_from_fqdn(raw_record_name) | |||
| record = Record.new(zone, record_name, | |||
| data_for(record_set), | |||
| source=self) | |||
| zone.add_record(record) | |||
| self.log.info('populate: found %s records, exists=True', | |||
| len(zone.records) - before) | |||
| return True | |||
| def _group_records(self, all_records): | |||
| records = defaultdict(lambda: defaultdict(list)) | |||
| for record in all_records: | |||
| self._id_map[self._key_for_record(record)] = record['id'] | |||
| records[record['type']][record['name']].append(record) | |||
| return records | |||
| @staticmethod | |||
| def _record_for_single(record, value): | |||
| return { | |||
| 'name': remove_trailing_dot(record.fqdn), | |||
| 'type': record._type, | |||
| 'data': value, | |||
| 'ttl': max(record.ttl, 300), | |||
| } | |||
| _record_for_A = _record_for_single | |||
| _record_for_AAAA = _record_for_single | |||
| @staticmethod | |||
| def _record_for_named(record, value): | |||
| return { | |||
| 'name': remove_trailing_dot(record.fqdn), | |||
| 'type': record._type, | |||
| 'data': remove_trailing_dot(value), | |||
| 'ttl': max(record.ttl, 300), | |||
| } | |||
| _record_for_NS = _record_for_named | |||
| _record_for_ALIAS = _record_for_named | |||
| _record_for_CNAME = _record_for_named | |||
| _record_for_PTR = _record_for_named | |||
| @staticmethod | |||
| def _record_for_textual(record, value): | |||
| return { | |||
| 'name': remove_trailing_dot(record.fqdn), | |||
| 'type': record._type, | |||
| 'data': unescape_semicolon(value), | |||
| 'ttl': max(record.ttl, 300), | |||
| } | |||
| _record_for_SPF = _record_for_textual | |||
| _record_for_TXT = _record_for_textual | |||
| @staticmethod | |||
| def _record_for_MX(record, value): | |||
| return { | |||
| 'name': remove_trailing_dot(record.fqdn), | |||
| 'type': record._type, | |||
| 'data': remove_trailing_dot(value.exchange), | |||
| 'ttl': max(record.ttl, 300), | |||
| 'priority': value.preference | |||
| } | |||
| def _get_values(self, record): | |||
| try: | |||
| return record.values | |||
| except AttributeError: | |||
| return [record.value] | |||
| def _mod_Create(self, change): | |||
| return self._create_given_change_values(change, | |||
| self._get_values(change.new)) | |||
| def _create_given_change_values(self, change, values): | |||
| transformer = getattr(self, "_record_for_{}".format(change.new._type)) | |||
| return [transformer(change.new, v) for v in values] | |||
| def _mod_Update(self, change): | |||
| existing_values = self._get_values(change.existing) | |||
| new_values = self._get_values(change.new) | |||
| # A reduction in number of values in an update record needs | |||
| # to get upgraded into a Delete change for the removed values. | |||
| deleted_values = set(existing_values) - set(new_values) | |||
| delete_out = self._delete_given_change_values(change, deleted_values) | |||
| # An increase in number of values in an update record needs | |||
| # to get upgraded into a Create change for the added values. | |||
| create_values = set(new_values) - set(existing_values) | |||
| create_out = self._create_given_change_values(change, create_values) | |||
| update_out = [] | |||
| update_values = set(new_values).intersection(set(existing_values)) | |||
| for value in update_values: | |||
| transformer = getattr(self, | |||
| "_record_for_{}".format(change.new._type)) | |||
| prior_rs_record = transformer(change.existing, value) | |||
| prior_key = self._key_for_record(prior_rs_record) | |||
| next_rs_record = transformer(change.new, value) | |||
| next_key = self._key_for_record(next_rs_record) | |||
| next_rs_record["id"] = self._id_map[prior_key] | |||
| del next_rs_record["type"] | |||
| update_out.append(next_rs_record) | |||
| self._id_map[next_key] = self._id_map[prior_key] | |||
| del self._id_map[prior_key] | |||
| return create_out, update_out, delete_out | |||
| def _mod_Delete(self, change): | |||
| return self._delete_given_change_values(change, self._get_values( | |||
| change.existing)) | |||
| def _delete_given_change_values(self, change, values): | |||
| transformer = getattr(self, "_record_for_{}".format( | |||
| change.existing._type)) | |||
| out = [] | |||
| for value in values: | |||
| rs_record = transformer(change.existing, value) | |||
| key = self._key_for_record(rs_record) | |||
| out.append('id=' + self._id_map[key]) | |||
| del self._id_map[key] | |||
| return out | |||
| def _apply(self, plan): | |||
| desired = plan.desired | |||
| changes = plan.changes | |||
| self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, | |||
| len(changes)) | |||
| # Creates, updates, and deletes are processed by different endpoints | |||
| # and are broken out by record-set entries; pre-process everything | |||
| # into these buckets in order to minimize the number of API calls. | |||
| domain_id = self._get_zone_id_for(desired) | |||
| creates = [] | |||
| updates = [] | |||
| deletes = [] | |||
| for change in changes: | |||
| if change.__class__.__name__ == 'Create': | |||
| creates += self._mod_Create(change) | |||
| elif change.__class__.__name__ == 'Update': | |||
| add_creates, add_updates, add_deletes = self._mod_Update( | |||
| change) | |||
| creates += add_creates | |||
| updates += add_updates | |||
| deletes += add_deletes | |||
| else: | |||
| assert change.__class__.__name__ == 'Delete' | |||
| deletes += self._mod_Delete(change) | |||
| if deletes: | |||
| params = "&".join(sorted(deletes)) | |||
| self._delete('domains/{}/records?{}'.format(domain_id, params)) | |||
| if updates: | |||
| data = {"records": sorted(updates, key=lambda v: v['name'])} | |||
| self._put('domains/{}/records'.format(domain_id), data=data) | |||
| if creates: | |||
| data = {"records": sorted(creates, key=lambda v: v['type'] + | |||
| v['name'] + | |||
| v.get('data', ''))} | |||
| self._post('domains/{}/records'.format(domain_id), data=data) | |||
| @ -1,7 +0,0 @@ | |||
| coverage | |||
| mock | |||
| nose | |||
| pep8 | |||
| pyflakes | |||
| requests_mock | |||
| setuptools>=36.4.0 | |||
| @ -1,23 +0,0 @@ | |||
| # These are known good versions. You're free to use others and things will | |||
| # likely work, but no promises are made, especilly if you go older. | |||
| PyYaml==3.12 | |||
| azure-mgmt-dns==1.0.1 | |||
| azure-common==1.1.6 | |||
| boto3==1.4.6 | |||
| botocore==1.6.8 | |||
| dnspython==1.15.0 | |||
| docutils==0.14 | |||
| dyn==1.8.0 | |||
| futures==3.1.1 | |||
| google-cloud==0.27.0 | |||
| incf.countryutils==1.0 | |||
| ipaddress==1.0.18 | |||
| jmespath==0.9.3 | |||
| msrestazure==0.4.10 | |||
| natsort==5.0.3 | |||
| nsone==0.9.14 | |||
| ovh==0.4.7 | |||
| python-dateutil==2.6.1 | |||
| requests==2.13.0 | |||
| s3transfer==0.1.10 | |||
| six==1.10.0 | |||
| @ -0,0 +1,68 @@ | |||
| [metadata] | |||
| name = octodns | |||
| description = "DNS as code - Tools for managing DNS across multiple providers" | |||
| long_description = file: README.md | |||
| version = attr: octodns.__VERSION__ | |||
| author = Ross McFarland | |||
| author_email = rwmcfa1@gmail.com | |||
| url = https://github.com/github/octodns | |||
| license = MIT | |||
| keywords = dns, providers | |||
| classifiers = | |||
| License :: OSI Approved :: MIT License | |||
| Programming Language :: Python | |||
| Programming Language :: Python :: 2.7 | |||
| Programming Language :: Python :: 3 | |||
| Programming Language :: Python :: 3.3 | |||
| Programming Language :: Python :: 3.4 | |||
| Programming Language :: Python :: 3.5 | |||
| Programming Language :: Python :: 3.6 | |||
| [options] | |||
| install_requires = | |||
| PyYaml>=3.12 | |||
| dnspython>=1.15.0 | |||
| futures>=3.1.1 | |||
| incf.countryutils>=1.0 | |||
| ipaddress>=1.0.18 | |||
| natsort>=5.0.3 | |||
| python-dateutil>=2.6.1 | |||
| requests>=2.13.0 | |||
| packages = find: | |||
| include_package_data = True | |||
| [options.entry_points] | |||
| console_scripts = | |||
| octodns-compare = octodns.cmds.compare:main | |||
| octodns-dump = octodns.cmds.dump:main | |||
| octodns-report = octodns.cmds.report:main | |||
| octodns-sync = octodns.cmds.sync:main | |||
| octodns-validate = octodns.cmds.validate:main | |||
| [options.packages.find] | |||
| exclude = | |||
| tests | |||
| [options.extras_require] | |||
| dev = | |||
| azure-mgmt-dns==1.0.1 | |||
| azure-common==1.1.6 | |||
| boto3>=1.4.6 | |||
| botocore>=1.6.8 | |||
| docutils>=0.14 | |||
| dyn>=1.8.0 | |||
| google-cloud>=0.27.0 | |||
| jmespath>=0.9.3 | |||
| msrestazure==0.4.10 | |||
| nsone>=0.9.17 | |||
| ovh>=0.4.7 | |||
| s3transfer>=0.1.10 | |||
| six>=1.10.0 | |||
| test = | |||
| coverage | |||
| mock | |||
| nose | |||
| pycodestyle | |||
| pyflakes | |||
| requests_mock | |||
| setuptools>=36.4.0 | |||
| @ -1,47 +1,5 @@ | |||
| #!/usr/bin/env python | |||
| from setuptools import setup | |||
| from os.path import dirname, join | |||
| import octodns | |||
| try: | |||
| from setuptools import find_packages, setup | |||
| except ImportError: | |||
| from distutils.core import find_packages, setup | |||
| cmds = ( | |||
| 'compare', | |||
| 'dump', | |||
| 'report', | |||
| 'sync', | |||
| 'validate' | |||
| ) | |||
| cmds_dir = join(dirname(__file__), 'octodns', 'cmds') | |||
| console_scripts = { | |||
| 'octodns-{name} = octodns.cmds.{name}:main'.format(name=name) | |||
| for name in cmds | |||
| } | |||
| setup( | |||
| author='Ross McFarland', | |||
| author_email='rwmcfa1@gmail.com', | |||
| description=octodns.__doc__, | |||
| entry_points={ | |||
| 'console_scripts': console_scripts, | |||
| }, | |||
| install_requires=[ | |||
| 'PyYaml>=3.12', | |||
| 'dnspython>=1.15.0', | |||
| 'futures>=3.0.5', | |||
| 'incf.countryutils>=1.0', | |||
| 'ipaddress>=1.0.18', | |||
| 'natsort>=5.0.3', | |||
| 'python-dateutil>=2.6.0', | |||
| 'requests>=2.13.0' | |||
| ], | |||
| license='MIT', | |||
| long_description=open('README.md').read(), | |||
| name='octodns', | |||
| packages=find_packages(), | |||
| url='https://github.com/github/octodns', | |||
| version=octodns.__VERSION__, | |||
| ) | |||
| setup() | |||
| @ -0,0 +1,7 @@ | |||
| manager: | |||
| plan_outputs: | |||
| 'bad': | |||
| class: octodns.provider.plan.PlanLogger | |||
| invalid: config | |||
| providers: {} | |||
| zones: {} | |||
| @ -0,0 +1,5 @@ | |||
| manager: | |||
| plan_outputs: | |||
| 'bad': {} | |||
| providers: {} | |||
| zones: {} | |||
| @ -0,0 +1,177 @@ | |||
| { | |||
| "domain_records": [{ | |||
| "id": 11189874, | |||
| "type": "NS", | |||
| "name": "@", | |||
| "data": "ns1.digitalocean.com", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 3600, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189875, | |||
| "type": "NS", | |||
| "name": "@", | |||
| "data": "ns2.digitalocean.com", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 3600, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189876, | |||
| "type": "NS", | |||
| "name": "@", | |||
| "data": "ns3.digitalocean.com", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 3600, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189877, | |||
| "type": "NS", | |||
| "name": "under", | |||
| "data": "ns1.unit.tests", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 3600, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189878, | |||
| "type": "NS", | |||
| "name": "under", | |||
| "data": "ns2.unit.tests", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 3600, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189879, | |||
| "type": "SRV", | |||
| "name": "_srv._tcp", | |||
| "data": "foo-1.unit.tests", | |||
| "priority": 10, | |||
| "port": 30, | |||
| "ttl": 600, | |||
| "weight": 20, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189880, | |||
| "type": "SRV", | |||
| "name": "_srv._tcp", | |||
| "data": "foo-2.unit.tests", | |||
| "priority": 12, | |||
| "port": 30, | |||
| "ttl": 600, | |||
| "weight": 20, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189881, | |||
| "type": "TXT", | |||
| "name": "txt", | |||
| "data": "Bah bah black sheep", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 600, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189882, | |||
| "type": "TXT", | |||
| "name": "txt", | |||
| "data": "have you any wool.", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 600, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189883, | |||
| "type": "A", | |||
| "name": "@", | |||
| "data": "1.2.3.4", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 300, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189884, | |||
| "type": "A", | |||
| "name": "@", | |||
| "data": "1.2.3.5", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 300, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189885, | |||
| "type": "A", | |||
| "name": "www", | |||
| "data": "2.2.3.6", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 300, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189886, | |||
| "type": "MX", | |||
| "name": "mx", | |||
| "data": "smtp-4.unit.tests", | |||
| "priority": 10, | |||
| "port": null, | |||
| "ttl": 300, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189887, | |||
| "type": "MX", | |||
| "name": "mx", | |||
| "data": "smtp-2.unit.tests", | |||
| "priority": 20, | |||
| "port": null, | |||
| "ttl": 300, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189888, | |||
| "type": "MX", | |||
| "name": "mx", | |||
| "data": "smtp-3.unit.tests", | |||
| "priority": 30, | |||
| "port": null, | |||
| "ttl": 300, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }], | |||
| "links": { | |||
| "pages": { | |||
| "last": "https://api.digitalocean.com/v2/domains/unit.tests/records?page=2", | |||
| "next": "https://api.digitalocean.com/v2/domains/unit.tests/records?page=2" | |||
| } | |||
| }, | |||
| "meta": { | |||
| "total": 21 | |||
| } | |||
| } | |||
| @ -0,0 +1,89 @@ | |||
| { | |||
| "domain_records": [{ | |||
| "id": 11189889, | |||
| "type": "MX", | |||
| "name": "mx", | |||
| "data": "smtp-1.unit.tests", | |||
| "priority": 40, | |||
| "port": null, | |||
| "ttl": 300, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189890, | |||
| "type": "AAAA", | |||
| "name": "aaaa", | |||
| "data": "2601:644:500:e210:62f8:1dff:feb8:947a", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 600, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189891, | |||
| "type": "CNAME", | |||
| "name": "cname", | |||
| "data": "@", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 300, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189892, | |||
| "type": "A", | |||
| "name": "www.sub", | |||
| "data": "2.2.3.6", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 300, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189893, | |||
| "type": "TXT", | |||
| "name": "txt", | |||
| "data": "v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 600, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }, { | |||
| "id": 11189894, | |||
| "type": "CAA", | |||
| "name": "@", | |||
| "data": "ca.unit.tests", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 3600, | |||
| "weight": null, | |||
| "flags": 0, | |||
| "tag": "issue" | |||
| }, { | |||
| "id": 11189895, | |||
| "type": "CNAME", | |||
| "name": "included", | |||
| "data": "@", | |||
| "priority": null, | |||
| "port": null, | |||
| "ttl": 3600, | |||
| "weight": null, | |||
| "flags": null, | |||
| "tag": null | |||
| }], | |||
| "links": { | |||
| "pages": { | |||
| "first": "https://api.digitalocean.com/v2/domains/unit.tests/records?page=1", | |||
| "prev": "https://api.digitalocean.com/v2/domains/unit.tests/records?page=1" | |||
| } | |||
| }, | |||
| "meta": { | |||
| "total": 21 | |||
| } | |||
| } | |||
| @ -0,0 +1,16 @@ | |||
| { | |||
| "totalPages": 1, | |||
| "totalRecords": 1, | |||
| "data": [{ | |||
| "created": 1511740800000, | |||
| "folderId": 1990, | |||
| "gtdEnabled": false, | |||
| "pendingActionId": 0, | |||
| "updated": 1511766661574, | |||
| "processMulti": false, | |||
| "activeThirdParties": [], | |||
| "name": "unit.tests", | |||
| "id": 123123 | |||
| }], | |||
| "page": 0 | |||
| } | |||
| @ -0,0 +1,312 @@ | |||
| { | |||
| "totalPages": 1, | |||
| "totalRecords": 21, | |||
| "data": [{ | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "caaType": "issue", | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "issuerCritical": 0, | |||
| "ttl": 3600, | |||
| "source": 1, | |||
| "name": "", | |||
| "value": "\"ca.unit.tests\"", | |||
| "id": 11189874, | |||
| "type": "CAA" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 300, | |||
| "source": 1, | |||
| "name": "", | |||
| "value": "1.2.3.4", | |||
| "id": 11189875, | |||
| "type": "A" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 300, | |||
| "source": 1, | |||
| "name": "", | |||
| "value": "1.2.3.5", | |||
| "id": 11189876, | |||
| "type": "A" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 600, | |||
| "weight": 20, | |||
| "source": 1, | |||
| "name": "_srv._tcp", | |||
| "value": "foo-1.unit.tests.", | |||
| "id": 11189877, | |||
| "priority": 10, | |||
| "type": "SRV", | |||
| "port": 30 | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 600, | |||
| "weight": 20, | |||
| "source": 1, | |||
| "name": "_srv._tcp", | |||
| "value": "foo-2.unit.tests.", | |||
| "id": 11189878, | |||
| "priority": 12, | |||
| "type": "SRV", | |||
| "port": 30 | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 600, | |||
| "source": 1, | |||
| "name": "aaaa", | |||
| "value": "2601:644:500:e210:62f8:1dff:feb8:947a", | |||
| "id": 11189879, | |||
| "type": "AAAA" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 300, | |||
| "source": 1, | |||
| "name": "cname", | |||
| "value": "", | |||
| "id": 11189880, | |||
| "type": "CNAME" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 3600, | |||
| "source": 1, | |||
| "name": "included", | |||
| "value": "", | |||
| "id": 11189881, | |||
| "type": "CNAME" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "mxLevel": 30, | |||
| "ttl": 300, | |||
| "source": 1, | |||
| "name": "mx", | |||
| "value": "smtp-3.unit.tests.", | |||
| "id": 11189882, | |||
| "type": "MX" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "mxLevel": 20, | |||
| "ttl": 300, | |||
| "source": 1, | |||
| "name": "mx", | |||
| "value": "smtp-2.unit.tests.", | |||
| "id": 11189883, | |||
| "type": "MX" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "mxLevel": 10, | |||
| "ttl": 300, | |||
| "source": 1, | |||
| "name": "mx", | |||
| "value": "smtp-4.unit.tests.", | |||
| "id": 11189884, | |||
| "type": "MX" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "mxLevel": 40, | |||
| "ttl": 300, | |||
| "source": 1, | |||
| "name": "mx", | |||
| "value": "smtp-1.unit.tests.", | |||
| "id": 11189885, | |||
| "type": "MX" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 600, | |||
| "source": 1, | |||
| "name": "spf", | |||
| "value": "\"v=spf1 ip4:192.168.0.1/16-all\"", | |||
| "id": 11189886, | |||
| "type": "SPF" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 600, | |||
| "source": 1, | |||
| "name": "txt", | |||
| "value": "\"Bah bah black sheep\"", | |||
| "id": 11189887, | |||
| "type": "TXT" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 600, | |||
| "source": 1, | |||
| "name": "txt", | |||
| "value": "\"have you any wool.\"", | |||
| "id": 11189888, | |||
| "type": "TXT" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 600, | |||
| "source": 1, | |||
| "name": "txt", | |||
| "value": "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"", | |||
| "id": 11189889, | |||
| "type": "TXT" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 3600, | |||
| "source": 1, | |||
| "name": "under", | |||
| "value": "ns1.unit.tests.", | |||
| "id": 11189890, | |||
| "type": "NS" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 3600, | |||
| "source": 1, | |||
| "name": "under", | |||
| "value": "ns2", | |||
| "id": 11189891, | |||
| "type": "NS" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 300, | |||
| "source": 1, | |||
| "name": "www", | |||
| "value": "2.2.3.6", | |||
| "id": 11189892, | |||
| "type": "A" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 300, | |||
| "source": 1, | |||
| "name": "www.sub", | |||
| "value": "2.2.3.6", | |||
| "id": 11189893, | |||
| "type": "A" | |||
| }, { | |||
| "failover": false, | |||
| "monitor": false, | |||
| "sourceId": 123123, | |||
| "dynamicDns": false, | |||
| "failed": false, | |||
| "gtdLocation": "DEFAULT", | |||
| "hardLink": false, | |||
| "ttl": 300, | |||
| "source": 1, | |||
| "name": "ptr", | |||
| "value": "foo.bar.com.", | |||
| "id": 11189894, | |||
| "type": "PTR" | |||
| }], | |||
| "page": 0 | |||
| } | |||
| @ -0,0 +1,87 @@ | |||
| { | |||
| "access": { | |||
| "token": { | |||
| "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", | |||
| "expires": "2014-11-24T22:05:39.115Z", | |||
| "tenant": { | |||
| "id": "110011", | |||
| "name": "110011" | |||
| }, | |||
| "RAX-AUTH:authenticatedBy": [ | |||
| "APIKEY" | |||
| ] | |||
| }, | |||
| "serviceCatalog": [ | |||
| { | |||
| "name": "cloudDatabases", | |||
| "endpoints": [ | |||
| { | |||
| "publicURL": "https://syd.databases.api.rackspacecloud.com/v1.0/110011", | |||
| "region": "SYD", | |||
| "tenantId": "110011" | |||
| }, | |||
| { | |||
| "publicURL": "https://dfw.databases.api.rackspacecloud.com/v1.0/110011", | |||
| "region": "DFW", | |||
| "tenantId": "110011" | |||
| }, | |||
| { | |||
| "publicURL": "https://ord.databases.api.rackspacecloud.com/v1.0/110011", | |||
| "region": "ORD", | |||
| "tenantId": "110011" | |||
| }, | |||
| { | |||
| "publicURL": "https://iad.databases.api.rackspacecloud.com/v1.0/110011", | |||
| "region": "IAD", | |||
| "tenantId": "110011" | |||
| }, | |||
| { | |||
| "publicURL": "https://hkg.databases.api.rackspacecloud.com/v1.0/110011", | |||
| "region": "HKG", | |||
| "tenantId": "110011" | |||
| } | |||
| ], | |||
| "type": "rax:database" | |||
| }, | |||
| { | |||
| "name": "cloudDNS", | |||
| "endpoints": [ | |||
| { | |||
| "publicURL": "https://dns.api.rackspacecloud.com/v1.0/110011", | |||
| "tenantId": "110011" | |||
| } | |||
| ], | |||
| "type": "rax:dns" | |||
| }, | |||
| { | |||
| "name": "rackCDN", | |||
| "endpoints": [ | |||
| { | |||
| "internalURL": "https://global.cdn.api.rackspacecloud.com/v1.0/110011", | |||
| "publicURL": "https://global.cdn.api.rackspacecloud.com/v1.0/110011", | |||
| "tenantId": "110011" | |||
| } | |||
| ], | |||
| "type": "rax:cdn" | |||
| } | |||
| ], | |||
| "user": { | |||
| "id": "123456", | |||
| "roles": [ | |||
| { | |||
| "description": "A Role that allows a user access to keystone Service methods", | |||
| "id": "6", | |||
| "name": "compute:default", | |||
| "tenantId": "110011" | |||
| }, | |||
| { | |||
| "description": "User Admin Role.", | |||
| "id": "3", | |||
| "name": "identity:user-admin" | |||
| } | |||
| ], | |||
| "name": "jsmith", | |||
| "RAX-AUTH:defaultRegion": "ORD" | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,68 @@ | |||
| { | |||
| "totalEntries" : 10, | |||
| "domains" : [ { | |||
| "name" : "example.com", | |||
| "id" : 2725233, | |||
| "comment" : "Optional domain comment...", | |||
| "updated" : "2011-06-24T01:23:15.000+0000", | |||
| "accountId" : 1234, | |||
| "emailAddress" : "sample@rackspace.com", | |||
| "created" : "2011-06-24T01:12:51.000+0000" | |||
| }, { | |||
| "name" : "sub1.example.com", | |||
| "id" : 2725257, | |||
| "comment" : "1st sample subdomain", | |||
| "updated" : "2011-06-23T03:09:34.000+0000", | |||
| "accountId" : 1234, | |||
| "emailAddress" : "sample@rackspace.com", | |||
| "created" : "2011-06-23T03:09:33.000+0000" | |||
| }, { | |||
| "name" : "sub2.example.com", | |||
| "id" : 2725258, | |||
| "comment" : "1st sample subdomain", | |||
| "updated" : "2011-06-23T03:52:55.000+0000", | |||
| "accountId" : 1234, | |||
| "emailAddress" : "sample@rackspace.com", | |||
| "created" : "2011-06-23T03:52:55.000+0000" | |||
| }, { | |||
| "name" : "north.example.com", | |||
| "id" : 2725260, | |||
| "updated" : "2011-06-23T03:53:10.000+0000", | |||
| "accountId" : 1234, | |||
| "emailAddress" : "sample@rackspace.com", | |||
| "created" : "2011-06-23T03:53:09.000+0000" | |||
| }, { | |||
| "name" : "south.example.com", | |||
| "id" : 2725261, | |||
| "comment" : "Final sample subdomain", | |||
| "updated" : "2011-06-23T03:53:14.000+0000", | |||
| "accountId" : 1234, | |||
| "emailAddress" : "sample@rackspace.com", | |||
| "created" : "2011-06-23T03:53:14.000+0000" | |||
| }, { | |||
| "name" : "region2.example.net", | |||
| "id" : 2725352, | |||
| "updated" : "2011-06-23T20:21:06.000+0000", | |||
| "accountId" : 1234, | |||
| "created" : "2011-06-23T19:24:27.000+0000" | |||
| }, { | |||
| "name" : "example.org", | |||
| "id" : 2718984, | |||
| "updated" : "2011-05-03T14:47:32.000+0000", | |||
| "accountId" : 1234, | |||
| "created" : "2011-05-03T14:47:30.000+0000" | |||
| }, { | |||
| "name" : "rackspace.example", | |||
| "id" : 2722346, | |||
| "updated" : "2011-06-21T15:54:31.000+0000", | |||
| "accountId" : 1234, | |||
| "created" : "2011-06-15T19:02:07.000+0000" | |||
| }, { | |||
| "name" : "unit.tests", | |||
| "id" : 2722347, | |||
| "comment" : "Sample comment", | |||
| "updated" : "2011-06-21T15:54:31.000+0000", | |||
| "accountId" : 1234, | |||
| "created" : "2011-06-15T19:02:07.000+0000" | |||
| } ] | |||
| } | |||
| @ -0,0 +1,29 @@ | |||
| { | |||
| "totalEntries" : 3, | |||
| "records" : [{ | |||
| "name" : "unit.tests.", | |||
| "id" : "A-6822995", | |||
| "type" : "A", | |||
| "data" : "1.2.3.4", | |||
| "updated" : "2011-06-24T01:12:53.000+0000", | |||
| "ttl" : 600, | |||
| "created" : "2011-06-24T01:12:53.000+0000" | |||
| }, { | |||
| "name" : "unit.tests.", | |||
| "id" : "NS-454454", | |||
| "type" : "NS", | |||
| "data" : "ns1.example.com", | |||
| "updated" : "2011-06-24T01:12:51.000+0000", | |||
| "ttl" : 600, | |||
| "created" : "2011-06-24T01:12:51.000+0000" | |||
| }, { | |||
| "name" : "unit.tests.", | |||
| "id" : "NS-454455", | |||
| "type" : "NS", | |||
| "data" : "ns2.example.com", | |||
| "updated" : "2011-06-24T01:12:52.000+0000", | |||
| "ttl" : 600, | |||
| "created" : "2011-06-24T01:12:52.000+0000" | |||
| }], | |||
| "links" : [] | |||
| } | |||
| @ -0,0 +1,33 @@ | |||
| { | |||
| "totalEntries" : 6, | |||
| "records" : [ { | |||
| "name" : "ftp.example.com", | |||
| "id" : "A-6817754", | |||
| "type" : "A", | |||
| "data" : "192.0.2.8", | |||
| "updated" : "2011-05-19T13:07:08.000+0000", | |||
| "ttl" : 5771, | |||
| "created" : "2011-05-18T19:53:09.000+0000" | |||
| }, { | |||
| "name" : "example.com", | |||
| "id" : "A-6822994", | |||
| "type" : "A", | |||
| "data" : "192.0.2.17", | |||
| "updated" : "2011-06-24T01:12:52.000+0000", | |||
| "ttl" : 86400, | |||
| "created" : "2011-06-24T01:12:52.000+0000" | |||
| }, { | |||
| "name" : "example.com", | |||
| "id" : "NS-6251982", | |||
| "type" : "NS", | |||
| "data" : "ns.rackspace.com", | |||
| "updated" : "2011-06-24T01:12:51.000+0000", | |||
| "ttl" : 3600, | |||
| "created" : "2011-06-24T01:12:51.000+0000" | |||
| } ], | |||
| "links" : [ { | |||
| "content" : "", | |||
| "href" : "https://localhost/v1.0/1234/domains/domain_id/records?limit=3&offset=3", | |||
| "rel" : "next" | |||
| } ] | |||
| } | |||
| @ -0,0 +1,35 @@ | |||
| { | |||
| "totalEntries" : 6, | |||
| "records" : [ { | |||
| "name" : "example.com", | |||
| "id" : "NS-6251983", | |||
| "type" : "NS", | |||
| "data" : "ns2.rackspace.com", | |||
| "updated" : "2011-06-24T01:12:51.000+0000", | |||
| "ttl" : 3600, | |||
| "created" : "2011-06-24T01:12:51.000+0000" | |||
| }, { | |||
| "name" : "example.com", | |||
| "priority" : 5, | |||
| "id" : "MX-3151218", | |||
| "type" : "MX", | |||
| "data" : "mail.example.com", | |||
| "updated" : "2011-06-24T01:12:53.000+0000", | |||
| "ttl" : 3600, | |||
| "created" : "2011-06-24T01:12:53.000+0000" | |||
| }, { | |||
| "name" : "www.example.com", | |||
| "id" : "CNAME-9778009", | |||
| "type" : "CNAME", | |||
| "comment" : "This is a comment on the CNAME record", | |||
| "data" : "example.com", | |||
| "updated" : "2011-06-24T01:12:54.000+0000", | |||
| "ttl" : 5400, | |||
| "created" : "2011-06-24T01:12:54.000+0000" | |||
| } ], | |||
| "links" : [ { | |||
| "content" : "", | |||
| "href" : "https://dns.api.rackspacecloud.com/v1.0/1234/domains/domain_id/records?limit=3&offset=0", | |||
| "rel" : "previous" | |||
| }] | |||
| } | |||
| @ -0,0 +1,113 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| from StringIO import StringIO | |||
| from logging import getLogger | |||
| from unittest import TestCase | |||
| from octodns.provider.plan import Plan, PlanHtml, PlanLogger, PlanMarkdown | |||
| from octodns.record import Create, Delete, Record, Update | |||
| from octodns.zone import Zone | |||
| from helpers import SimpleProvider | |||
| simple = SimpleProvider() | |||
| zone = Zone('unit.tests.', []) | |||
| existing = Record.new(zone, 'a', { | |||
| 'ttl': 300, | |||
| 'type': 'A', | |||
| # This matches the zone data above, one to swap, one to leave | |||
| 'values': ['1.1.1.1', '2.2.2.2'], | |||
| }) | |||
| new = Record.new(zone, 'a', { | |||
| 'geo': { | |||
| 'AF': ['5.5.5.5'], | |||
| 'NA-US': ['6.6.6.6'] | |||
| }, | |||
| 'ttl': 300, | |||
| 'type': 'A', | |||
| # This leaves one, swaps ones, and adds one | |||
| 'values': ['2.2.2.2', '3.3.3.3', '4.4.4.4'], | |||
| }, simple) | |||
| create = Create(Record.new(zone, 'b', { | |||
| 'ttl': 60, | |||
| 'type': 'CNAME', | |||
| 'value': 'foo.unit.tests.' | |||
| }, simple)) | |||
| create2 = Create(Record.new(zone, 'c', { | |||
| 'ttl': 60, | |||
| 'type': 'CNAME', | |||
| 'value': 'foo.unit.tests.' | |||
| })) | |||
| update = Update(existing, new) | |||
| delete = Delete(new) | |||
| changes = [create, create2, delete, update] | |||
| plans = [ | |||
| (simple, Plan(zone, zone, changes, True)), | |||
| (simple, Plan(zone, zone, changes, False)), | |||
| ] | |||
| class TestPlanLogger(TestCase): | |||
| def test_invalid_level(self): | |||
| with self.assertRaises(Exception) as ctx: | |||
| PlanLogger('invalid', 'not-a-level') | |||
| self.assertEquals('Unsupported level: not-a-level', | |||
| ctx.exception.message) | |||
| def test_create(self): | |||
| class MockLogger(object): | |||
| def __init__(self): | |||
| self.out = StringIO() | |||
| def log(self, level, msg): | |||
| self.out.write(msg) | |||
| log = MockLogger() | |||
| PlanLogger('logger').run(log, plans) | |||
| out = log.out.getvalue() | |||
| self.assertTrue('Summary: Creates=2, Updates=1, ' | |||
| 'Deletes=1, Existing Records=0' in out) | |||
| class TestPlanHtml(TestCase): | |||
| log = getLogger('TestPlanHtml') | |||
| def test_empty(self): | |||
| out = StringIO() | |||
| PlanHtml('html').run([], fh=out) | |||
| self.assertEquals('<b>No changes were planned</b>', out.getvalue()) | |||
| def test_simple(self): | |||
| out = StringIO() | |||
| PlanHtml('html').run(plans, fh=out) | |||
| out = out.getvalue() | |||
| self.assertTrue(' <td colspan=6>Summary: Creates=2, Updates=1, ' | |||
| 'Deletes=1, Existing Records=0</td>' in out) | |||
| class TestPlanMarkdown(TestCase): | |||
| log = getLogger('TestPlanMarkdown') | |||
| def test_empty(self): | |||
| out = StringIO() | |||
| PlanMarkdown('markdown').run([], fh=out) | |||
| self.assertEquals('## No changes were planned\n', out.getvalue()) | |||
| def test_simple(self): | |||
| out = StringIO() | |||
| PlanMarkdown('markdown').run(plans, fh=out) | |||
| out = out.getvalue() | |||
| self.assertTrue('## unit.tests.' in out) | |||
| self.assertTrue('Create | b | CNAME | 60 | foo.unit.tests.' in out) | |||
| self.assertTrue('Update | a | A | 300 | 1.1.1.1;' in out) | |||
| self.assertTrue('NA-US: 6.6.6.6 | test' in out) | |||
| self.assertTrue('Delete | a | A | 300 | 2.2.2.2;' in out) | |||
| @ -0,0 +1,243 @@ | |||
| # | |||
| # | |||
| # | |||
| 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.digitalocean import DigitalOceanClientNotFound, \ | |||
| DigitalOceanProvider | |||
| from octodns.provider.yaml import YamlProvider | |||
| from octodns.zone import Zone | |||
| class TestDigitalOceanProvider(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.', | |||
| ] | |||
| })) | |||
| for record in list(expected.records): | |||
| if record.name == 'sub' and record._type == 'NS': | |||
| expected._remove_record(record) | |||
| break | |||
| def test_populate(self): | |||
| provider = DigitalOceanProvider('test', 'token') | |||
| # Bad auth | |||
| with requests_mock() as mock: | |||
| mock.get(ANY, status_code=401, | |||
| text='{"id":"unauthorized",' | |||
| '"message":"Unable to authenticate you."}') | |||
| with self.assertRaises(Exception) as ctx: | |||
| zone = Zone('unit.tests.', []) | |||
| provider.populate(zone) | |||
| self.assertEquals('Unauthorized', 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-existant zone doesn't populate anything | |||
| with requests_mock() as mock: | |||
| mock.get(ANY, status_code=404, | |||
| text='{"id":"not_found","message":"The resource you ' | |||
| 'were accessing could not be found."}') | |||
| zone = Zone('unit.tests.', []) | |||
| provider.populate(zone) | |||
| self.assertEquals(set(), zone.records) | |||
| # No diffs == no changes | |||
| with requests_mock() as mock: | |||
| base = 'https://api.digitalocean.com/v2/domains/unit.tests/' \ | |||
| 'records?page=' | |||
| with open('tests/fixtures/digitalocean-page-1.json') as fh: | |||
| mock.get('{}{}'.format(base, 1), text=fh.read()) | |||
| with open('tests/fixtures/digitalocean-page-2.json') as fh: | |||
| mock.get('{}{}'.format(base, 2), text=fh.read()) | |||
| zone = Zone('unit.tests.', []) | |||
| provider.populate(zone) | |||
| self.assertEquals(12, 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(12, len(again.records)) | |||
| # bust the cache | |||
| del provider._zone_records[zone.name] | |||
| def test_apply(self): | |||
| provider = DigitalOceanProvider('test', 'token') | |||
| resp = Mock() | |||
| resp.json = Mock() | |||
| provider._client._request = Mock(return_value=resp) | |||
| domain_after_creation = { | |||
| "domain_records": [{ | |||
| "id": 11189874, | |||
| "type": "NS", | |||
| "name": "@", | |||
| "data": "ns1.digitalocean.com", | |||
| "priority": None, | |||
| "port": None, | |||
| "ttl": 3600, | |||
| "weight": None, | |||
| "flags": None, | |||
| "tag": None | |||
| }, { | |||
| "id": 11189875, | |||
| "type": "NS", | |||
| "name": "@", | |||
| "data": "ns2.digitalocean.com", | |||
| "priority": None, | |||
| "port": None, | |||
| "ttl": 3600, | |||
| "weight": None, | |||
| "flags": None, | |||
| "tag": None | |||
| }, { | |||
| "id": 11189876, | |||
| "type": "NS", | |||
| "name": "@", | |||
| "data": "ns3.digitalocean.com", | |||
| "priority": None, | |||
| "port": None, | |||
| "ttl": 3600, | |||
| "weight": None, | |||
| "flags": None, | |||
| "tag": None | |||
| }, { | |||
| "id": 11189877, | |||
| "type": "A", | |||
| "name": "@", | |||
| "data": "192.0.2.1", | |||
| "priority": None, | |||
| "port": None, | |||
| "ttl": 3600, | |||
| "weight": None, | |||
| "flags": None, | |||
| "tag": None | |||
| }], | |||
| "links": {}, | |||
| "meta": { | |||
| "total": 4 | |||
| } | |||
| } | |||
| # non-existant domain, create everything | |||
| resp.json.side_effect = [ | |||
| DigitalOceanClientNotFound, # no zone in populate | |||
| DigitalOceanClientNotFound, # no domain during apply | |||
| domain_after_creation | |||
| ] | |||
| plan = provider.plan(self.expected) | |||
| # No root NS, no ignored, no excluded, no unsupported | |||
| n = len(self.expected.records) - 7 | |||
| self.assertEquals(n, len(plan.changes)) | |||
| self.assertEquals(n, provider.apply(plan)) | |||
| self.assertFalse(plan.exists) | |||
| provider._client._request.assert_has_calls([ | |||
| # created the domain | |||
| call('POST', '/domains', data={'ip_address': '192.0.2.1', | |||
| 'name': 'unit.tests'}), | |||
| # get all records in newly created zone | |||
| call('GET', '/domains/unit.tests/records', {'page': 1}), | |||
| # delete the initial A record | |||
| call('DELETE', '/domains/unit.tests/records/11189877'), | |||
| # created at least one of the record with expected data | |||
| call('POST', '/domains/unit.tests/records', data={ | |||
| 'name': '_srv._tcp', | |||
| 'weight': 20, | |||
| 'data': 'foo-1.unit.tests.', | |||
| 'priority': 10, | |||
| 'ttl': 600, | |||
| 'type': 'SRV', | |||
| 'port': 30 | |||
| }), | |||
| ]) | |||
| self.assertEquals(24, provider._client._request.call_count) | |||
| provider._client._request.reset_mock() | |||
| # delete 1 and update 1 | |||
| provider._client.records = Mock(return_value=[ | |||
| { | |||
| 'id': 11189897, | |||
| 'name': 'www', | |||
| 'data': '1.2.3.4', | |||
| 'ttl': 300, | |||
| 'type': 'A', | |||
| }, | |||
| { | |||
| 'id': 11189898, | |||
| 'name': 'www', | |||
| 'data': '2.2.3.4', | |||
| 'ttl': 300, | |||
| 'type': 'A', | |||
| }, | |||
| { | |||
| 'id': 11189899, | |||
| 'name': 'ttl', | |||
| 'data': '3.2.3.4', | |||
| 'ttl': 600, | |||
| 'type': 'A', | |||
| } | |||
| ]) | |||
| # 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.assertTrue(plan.exists) | |||
| self.assertEquals(2, len(plan.changes)) | |||
| self.assertEquals(2, provider.apply(plan)) | |||
| # recreate for update, and delete for the 2 parts of the other | |||
| provider._client._request.assert_has_calls([ | |||
| call('POST', '/domains/unit.tests/records', data={ | |||
| 'data': '3.2.3.4', | |||
| 'type': 'A', | |||
| 'name': 'ttl', | |||
| 'ttl': 300 | |||
| }), | |||
| call('DELETE', '/domains/unit.tests/records/11189899'), | |||
| call('DELETE', '/domains/unit.tests/records/11189897'), | |||
| call('DELETE', '/domains/unit.tests/records/11189898') | |||
| ], any_order=True) | |||
| @ -0,0 +1,202 @@ | |||
| # | |||
| # | |||
| # | |||
| 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.dnsmadeeasy import DnsMadeEasyClientNotFound, \ | |||
| DnsMadeEasyProvider | |||
| from octodns.provider.yaml import YamlProvider | |||
| from octodns.zone import Zone | |||
| import json | |||
| class TestDnsMadeEasyProvider(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.', | |||
| ] | |||
| })) | |||
| for record in list(expected.records): | |||
| if record.name == 'sub' and record._type == 'NS': | |||
| expected._remove_record(record) | |||
| break | |||
| def test_populate(self): | |||
| provider = DnsMadeEasyProvider('test', 'api', 'secret') | |||
| # Bad auth | |||
| with requests_mock() as mock: | |||
| mock.get(ANY, status_code=401, | |||
| text='{"error": ["API key not found"]}') | |||
| 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='{"error": ["Rate limit exceeded"]}') | |||
| with self.assertRaises(Exception) as ctx: | |||
| zone = Zone('unit.tests.', []) | |||
| provider.populate(zone) | |||
| self.assertEquals('\n - Rate limit exceeded', | |||
| 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-existant 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.dnsmadeeasy.com/V2.0/dns/managed' | |||
| with open('tests/fixtures/dnsmadeeasy-domains.json') as fh: | |||
| mock.get('{}{}'.format(base, '/'), text=fh.read()) | |||
| with open('tests/fixtures/dnsmadeeasy-records.json') as fh: | |||
| mock.get('{}{}'.format(base, '/123123/records'), | |||
| text=fh.read()) | |||
| zone = Zone('unit.tests.', []) | |||
| provider.populate(zone) | |||
| self.assertEquals(13, 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(13, len(again.records)) | |||
| # bust the cache | |||
| del provider._zone_records[zone.name] | |||
| def test_apply(self): | |||
| # Create provider with sandbox enabled | |||
| provider = DnsMadeEasyProvider('test', 'api', 'secret', True) | |||
| resp = Mock() | |||
| resp.json = Mock() | |||
| provider._client._request = Mock(return_value=resp) | |||
| with open('tests/fixtures/dnsmadeeasy-domains.json') as fh: | |||
| domains = json.load(fh) | |||
| # non-existant domain, create everything | |||
| resp.json.side_effect = [ | |||
| DnsMadeEasyClientNotFound, # no zone in populate | |||
| DnsMadeEasyClientNotFound, # 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={'name': 'unit.tests'}), | |||
| # get all domains to build the cache | |||
| call('GET', '/'), | |||
| # created at least one of the record with expected data | |||
| call('POST', '/123123/records', data={ | |||
| 'name': '_srv._tcp', | |||
| 'weight': 20, | |||
| 'value': 'foo-1.unit.tests.', | |||
| 'priority': 10, | |||
| 'ttl': 600, | |||
| 'type': 'SRV', | |||
| 'port': 30 | |||
| }), | |||
| ]) | |||
| self.assertEquals(25, provider._client._request.call_count) | |||
| provider._client._request.reset_mock() | |||
| # delete 1 and update 1 | |||
| provider._client.records = Mock(return_value=[ | |||
| { | |||
| 'id': 11189897, | |||
| 'name': 'www', | |||
| 'value': '1.2.3.4', | |||
| 'ttl': 300, | |||
| 'type': 'A', | |||
| }, | |||
| { | |||
| 'id': 11189898, | |||
| 'name': 'www', | |||
| 'value': '2.2.3.4', | |||
| 'ttl': 300, | |||
| 'type': 'A', | |||
| }, | |||
| { | |||
| 'id': 11189899, | |||
| 'name': 'ttl', | |||
| 'value': '3.2.3.4', | |||
| 'ttl': 600, | |||
| 'type': 'A', | |||
| } | |||
| ]) | |||
| # 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', data={ | |||
| 'value': '3.2.3.4', | |||
| 'type': 'A', | |||
| 'name': 'ttl', | |||
| 'ttl': 300 | |||
| }), | |||
| call('DELETE', '/123123/records/11189899'), | |||
| call('DELETE', '/123123/records/11189897'), | |||
| call('DELETE', '/123123/records/11189898') | |||
| ], any_order=True) | |||
| @ -0,0 +1,866 @@ | |||
| # | |||
| # | |||
| # | |||
| from __future__ import absolute_import, division, print_function, \ | |||
| unicode_literals | |||
| import json | |||
| import re | |||
| from unittest import TestCase | |||
| from urlparse import urlparse | |||
| from requests import HTTPError | |||
| from requests_mock import ANY, mock as requests_mock | |||
| from octodns.provider.rackspace import RackspaceProvider | |||
| from octodns.record import Record | |||
| from octodns.zone import Zone | |||
| EMPTY_TEXT = ''' | |||
| { | |||
| "totalEntries" : 0, | |||
| "records" : [] | |||
| } | |||
| ''' | |||
| with open('./tests/fixtures/rackspace-auth-response.json') as fh: | |||
| AUTH_RESPONSE = fh.read() | |||
| with open('./tests/fixtures/rackspace-list-domains-response.json') as fh: | |||
| LIST_DOMAINS_RESPONSE = fh.read() | |||
| with open('./tests/fixtures/rackspace-sample-recordset-page1.json') as fh: | |||
| RECORDS_PAGE_1 = fh.read() | |||
| with open('./tests/fixtures/rackspace-sample-recordset-page2.json') as fh: | |||
| RECORDS_PAGE_2 = fh.read() | |||
| class TestRackspaceProvider(TestCase): | |||
| def setUp(self): | |||
| self.maxDiff = 1000 | |||
| with requests_mock() as mock: | |||
| mock.post(ANY, status_code=200, text=AUTH_RESPONSE) | |||
| self.provider = RackspaceProvider('identity', 'test', 'api-key', | |||
| '0') | |||
| self.assertTrue(mock.called_once) | |||
| def test_bad_auth(self): | |||
| with requests_mock() as mock: | |||
| mock.get(ANY, status_code=401, text='Unauthorized') | |||
| with self.assertRaises(Exception) as ctx: | |||
| zone = Zone('unit.tests.', []) | |||
| self.provider.populate(zone) | |||
| self.assertTrue('unauthorized' in ctx.exception.message) | |||
| self.assertTrue(mock.called_once) | |||
| def test_server_error(self): | |||
| 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.', []) | |||
| self.provider.populate(zone) | |||
| self.assertEquals(502, ctx.exception.response.status_code) | |||
| self.assertTrue(mock.called_once) | |||
| def test_nonexistent_zone(self): | |||
| # Non-existent zone doesn't populate anything | |||
| with requests_mock() as mock: | |||
| mock.get(ANY, status_code=404, | |||
| json={'error': "Could not find domain 'unit.tests.'"}) | |||
| zone = Zone('unit.tests.', []) | |||
| exists = self.provider.populate(zone) | |||
| self.assertEquals(set(), zone.records) | |||
| self.assertTrue(mock.called_once) | |||
| self.assertFalse(exists) | |||
| def test_multipage_populate(self): | |||
| with requests_mock() as mock: | |||
| mock.get(re.compile('domains$'), status_code=200, | |||
| text=LIST_DOMAINS_RESPONSE) | |||
| mock.get(re.compile('records'), status_code=200, | |||
| text=RECORDS_PAGE_1) | |||
| mock.get(re.compile('records.*offset=3'), status_code=200, | |||
| text=RECORDS_PAGE_2) | |||
| zone = Zone('unit.tests.', []) | |||
| self.provider.populate(zone) | |||
| self.assertEquals(5, len(zone.records)) | |||
| def test_plan_disappearing_ns_records(self): | |||
| expected = Zone('unit.tests.', []) | |||
| expected.add_record(Record.new(expected, '', { | |||
| 'type': 'NS', | |||
| 'ttl': 600, | |||
| 'values': ['8.8.8.8.', '9.9.9.9.'] | |||
| })) | |||
| expected.add_record(Record.new(expected, 'sub', { | |||
| 'type': 'NS', | |||
| 'ttl': 600, | |||
| 'values': ['8.8.8.8.', '9.9.9.9.'] | |||
| })) | |||
| with requests_mock() as mock: | |||
| mock.get(re.compile('domains$'), status_code=200, | |||
| text=LIST_DOMAINS_RESPONSE) | |||
| mock.get(re.compile('records'), status_code=200, text=EMPTY_TEXT) | |||
| plan = self.provider.plan(expected) | |||
| self.assertTrue(mock.called) | |||
| self.assertTrue(plan.exists) | |||
| # OctoDNS does not propagate top-level NS records. | |||
| self.assertEquals(1, len(plan.changes)) | |||
| def test_fqdn_a_record(self): | |||
| expected = Zone('example.com.', []) | |||
| # expected.add_record(Record.new(expected, 'foo', '1.2.3.4')) | |||
| with requests_mock() as list_mock: | |||
| list_mock.get(re.compile('domains$'), status_code=200, | |||
| text=LIST_DOMAINS_RESPONSE) | |||
| list_mock.get(re.compile('records'), status_code=200, | |||
| json={'records': [ | |||
| {'type': 'A', | |||
| 'name': 'foo.example.com', | |||
| 'id': 'A-111111', | |||
| 'data': '1.2.3.4', | |||
| 'ttl': 300}]}) | |||
| plan = self.provider.plan(expected) | |||
| self.assertTrue(list_mock.called) | |||
| self.assertEqual(1, len(plan.changes)) | |||
| self.assertTrue( | |||
| plan.changes[0].existing.fqdn == 'foo.example.com.') | |||
| with requests_mock() as mock: | |||
| def _assert_deleting(request, context): | |||
| parts = urlparse(request.url) | |||
| self.assertEqual('id=A-111111', parts.query) | |||
| mock.get(re.compile('domains$'), status_code=200, | |||
| text=LIST_DOMAINS_RESPONSE) | |||
| mock.delete(re.compile('domains/.*/records?.*'), status_code=202, | |||
| text=_assert_deleting) | |||
| self.provider.apply(plan) | |||
| self.assertTrue(mock.called) | |||
| def _test_apply_with_data(self, data): | |||
| expected = Zone('unit.tests.', []) | |||
| for record in data.OtherRecords: | |||
| expected.add_record( | |||
| Record.new(expected, record['subdomain'], record['data'])) | |||
| with requests_mock() as list_mock: | |||
| list_mock.get(re.compile('domains$'), status_code=200, | |||
| text=LIST_DOMAINS_RESPONSE) | |||
| list_mock.get(re.compile('records'), status_code=200, | |||
| json=data.OwnRecords) | |||
| plan = self.provider.plan(expected) | |||
| self.assertTrue(list_mock.called) | |||
| if not data.ExpectChanges: | |||
| self.assertFalse(plan) | |||
| return | |||
| with requests_mock() as mock: | |||
| called = set() | |||
| def make_assert_sending_right_body(expected): | |||
| def _assert_sending_right_body(request, _context): | |||
| called.add(request.method) | |||
| if request.method != 'DELETE': | |||
| self.assertEqual(request.headers['content-type'], | |||
| 'application/json') | |||
| self.assertDictEqual(expected, | |||
| json.loads(request.body)) | |||
| else: | |||
| parts = urlparse(request.url) | |||
| self.assertEqual(expected, parts.query) | |||
| return '' | |||
| return _assert_sending_right_body | |||
| mock.get(re.compile('domains$'), status_code=200, | |||
| text=LIST_DOMAINS_RESPONSE) | |||
| mock.post(re.compile('domains/.*/records$'), status_code=202, | |||
| text=make_assert_sending_right_body( | |||
| data.ExpectedAdditions)) | |||
| mock.delete(re.compile('domains/.*/records?.*'), status_code=202, | |||
| text=make_assert_sending_right_body( | |||
| data.ExpectedDeletions)) | |||
| mock.put(re.compile('domains/.*/records$'), status_code=202, | |||
| text=make_assert_sending_right_body(data.ExpectedUpdates)) | |||
| self.provider.apply(plan) | |||
| self.assertTrue(data.ExpectedAdditions is None or "POST" in called) | |||
| self.assertTrue( | |||
| data.ExpectedDeletions is None or "DELETE" in called) | |||
| self.assertTrue(data.ExpectedUpdates is None or "PUT" in called) | |||
| def test_apply_no_change_empty(self): | |||
| class TestData(object): | |||
| OtherRecords = [] | |||
| OwnRecords = { | |||
| "totalEntries": 0, | |||
| "records": [] | |||
| } | |||
| ExpectChanges = False | |||
| ExpectedAdditions = None | |||
| ExpectedDeletions = None | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_no_change_a_records(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": '', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 300, | |||
| 'values': ['1.2.3.4', '1.2.3.5', '1.2.3.6'] | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 3, | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "A-111111", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "id": "A-222222", | |||
| "type": "A", | |||
| "data": "1.2.3.5", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "id": "A-333333", | |||
| "type": "A", | |||
| "data": "1.2.3.6", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = False | |||
| ExpectedAdditions = None | |||
| ExpectedDeletions = None | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_no_change_a_records_cross_zone(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": 'foo', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 300, | |||
| 'value': '1.2.3.4' | |||
| } | |||
| }, | |||
| { | |||
| "subdomain": 'bar', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 300, | |||
| 'value': '1.2.3.4' | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 3, | |||
| "records": [{ | |||
| "name": "foo.unit.tests", | |||
| "id": "A-111111", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "bar.unit.tests", | |||
| "id": "A-222222", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = False | |||
| ExpectedAdditions = None | |||
| ExpectedDeletions = None | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_one_addition(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": '', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 300, | |||
| 'value': '1.2.3.4' | |||
| } | |||
| }, | |||
| { | |||
| "subdomain": 'foo', | |||
| "data": { | |||
| 'type': 'NS', | |||
| 'ttl': 300, | |||
| 'value': 'ns.example.com.' | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 0, | |||
| "records": [] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = { | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "foo.unit.tests", | |||
| "type": "NS", | |||
| "data": "ns.example.com", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectedDeletions = None | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_create_MX(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": '', | |||
| "data": { | |||
| 'type': 'MX', | |||
| 'ttl': 300, | |||
| 'value': { | |||
| 'value': 'mail1.example.com.', | |||
| 'priority': 1, | |||
| } | |||
| } | |||
| }, | |||
| { | |||
| "subdomain": 'foo', | |||
| "data": { | |||
| 'type': 'MX', | |||
| 'ttl': 300, | |||
| 'value': { | |||
| 'value': 'mail2.example.com.', | |||
| 'priority': 2 | |||
| } | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 0, | |||
| "records": [] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = { | |||
| "records": [{ | |||
| "name": "foo.unit.tests", | |||
| "type": "MX", | |||
| "data": "mail2.example.com", | |||
| "priority": 2, | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "type": "MX", | |||
| "data": "mail1.example.com", | |||
| "priority": 1, | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectedDeletions = None | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_multiple_additions_splatting(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": '', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 300, | |||
| 'values': ['1.2.3.4', '1.2.3.5', '1.2.3.6'] | |||
| } | |||
| }, | |||
| { | |||
| "subdomain": 'foo', | |||
| "data": { | |||
| 'type': 'NS', | |||
| 'ttl': 300, | |||
| 'values': ['ns1.example.com.', 'ns2.example.com.'] | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 0, | |||
| "records": [] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = { | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "type": "A", | |||
| "data": "1.2.3.5", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "type": "A", | |||
| "data": "1.2.3.6", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "foo.unit.tests", | |||
| "type": "NS", | |||
| "data": "ns1.example.com", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "foo.unit.tests", | |||
| "type": "NS", | |||
| "data": "ns2.example.com", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectedDeletions = None | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_multiple_additions_namespaced(self): | |||
| class TestData(object): | |||
| OtherRecords = [{ | |||
| "subdomain": 'foo', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 300, | |||
| 'value': '1.2.3.4' | |||
| } | |||
| }, { | |||
| "subdomain": 'bar', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 300, | |||
| 'value': '1.2.3.4' | |||
| } | |||
| }, { | |||
| "subdomain": 'foo', | |||
| "data": { | |||
| 'type': 'NS', | |||
| 'ttl': 300, | |||
| 'value': 'ns.example.com.' | |||
| } | |||
| }] | |||
| OwnRecords = { | |||
| "totalEntries": 0, | |||
| "records": [] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = { | |||
| "records": [{ | |||
| "name": "bar.unit.tests", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "foo.unit.tests", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "foo.unit.tests", | |||
| "type": "NS", | |||
| "data": "ns.example.com", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectedDeletions = None | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_single_deletion(self): | |||
| class TestData(object): | |||
| OtherRecords = [] | |||
| OwnRecords = { | |||
| "totalEntries": 1, | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "A-111111", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "foo.unit.tests", | |||
| "id": "NS-111111", | |||
| "type": "NS", | |||
| "data": "ns.example.com", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = None | |||
| ExpectedDeletions = "id=A-111111&id=NS-111111" | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_multiple_deletions(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": '', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 300, | |||
| 'value': '1.2.3.5' | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 3, | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "A-111111", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "id": "A-222222", | |||
| "type": "A", | |||
| "data": "1.2.3.5", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "id": "A-333333", | |||
| "type": "A", | |||
| "data": "1.2.3.6", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "foo.unit.tests", | |||
| "id": "NS-111111", | |||
| "type": "NS", | |||
| "data": "ns.example.com", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = None | |||
| ExpectedDeletions = "id=A-111111&id=A-333333&id=NS-111111" | |||
| ExpectedUpdates = { | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "A-222222", | |||
| "data": "1.2.3.5", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_multiple_deletions_cross_zone(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": '', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 300, | |||
| 'value': '1.2.3.4' | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 3, | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "A-111111", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "foo.unit.tests", | |||
| "id": "A-222222", | |||
| "type": "A", | |||
| "data": "1.2.3.5", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "bar.unit.tests", | |||
| "id": "A-333333", | |||
| "type": "A", | |||
| "data": "1.2.3.6", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = None | |||
| ExpectedDeletions = "id=A-222222&id=A-333333" | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_delete_cname(self): | |||
| class TestData(object): | |||
| OtherRecords = [] | |||
| OwnRecords = { | |||
| "totalEntries": 3, | |||
| "records": [{ | |||
| "name": "foo.unit.tests", | |||
| "id": "CNAME-111111", | |||
| "type": "CNAME", | |||
| "data": "a.example.com", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = None | |||
| ExpectedDeletions = "id=CNAME-111111" | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_single_update(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": '', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 3600, | |||
| 'value': '1.2.3.4' | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 1, | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "A-111111", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = None | |||
| ExpectedDeletions = None | |||
| ExpectedUpdates = { | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "A-111111", | |||
| "data": "1.2.3.4", | |||
| "ttl": 3600 | |||
| }] | |||
| } | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_update_TXT(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": '', | |||
| "data": { | |||
| 'type': 'TXT', | |||
| 'ttl': 300, | |||
| 'value': 'othervalue' | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 1, | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "TXT-111111", | |||
| "type": "TXT", | |||
| "data": "somevalue", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = { | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "type": "TXT", | |||
| "data": "othervalue", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectedDeletions = 'id=TXT-111111' | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_update_MX(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": '', | |||
| "data": { | |||
| 'type': 'MX', | |||
| 'ttl': 300, | |||
| 'value': {u'priority': 50, u'value': 'mx.test.com.'} | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 1, | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "MX-111111", | |||
| "type": "MX", | |||
| "priority": 20, | |||
| "data": "mx.test.com", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = { | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "type": "MX", | |||
| "priority": 50, | |||
| "data": "mx.test.com", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectedDeletions = 'id=MX-111111' | |||
| ExpectedUpdates = None | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_multiple_updates(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": '', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 3600, | |||
| 'values': ['1.2.3.4', '1.2.3.5', '1.2.3.6'] | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 3, | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "A-111111", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "id": "A-222222", | |||
| "type": "A", | |||
| "data": "1.2.3.5", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "id": "A-333333", | |||
| "type": "A", | |||
| "data": "1.2.3.6", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = None | |||
| ExpectedDeletions = None | |||
| ExpectedUpdates = { | |||
| "records": [{ | |||
| "name": "unit.tests", | |||
| "id": "A-222222", | |||
| "data": "1.2.3.5", | |||
| "ttl": 3600 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "id": "A-111111", | |||
| "data": "1.2.3.4", | |||
| "ttl": 3600 | |||
| }, { | |||
| "name": "unit.tests", | |||
| "id": "A-333333", | |||
| "data": "1.2.3.6", | |||
| "ttl": 3600 | |||
| }] | |||
| } | |||
| return self._test_apply_with_data(TestData) | |||
| def test_apply_multiple_updates_cross_zone(self): | |||
| class TestData(object): | |||
| OtherRecords = [ | |||
| { | |||
| "subdomain": 'foo', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 3600, | |||
| 'value': '1.2.3.4' | |||
| } | |||
| }, | |||
| { | |||
| "subdomain": 'bar', | |||
| "data": { | |||
| 'type': 'A', | |||
| 'ttl': 3600, | |||
| 'value': '1.2.3.4' | |||
| } | |||
| } | |||
| ] | |||
| OwnRecords = { | |||
| "totalEntries": 2, | |||
| "records": [{ | |||
| "name": "foo.unit.tests", | |||
| "id": "A-111111", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }, { | |||
| "name": "bar.unit.tests", | |||
| "id": "A-222222", | |||
| "type": "A", | |||
| "data": "1.2.3.4", | |||
| "ttl": 300 | |||
| }] | |||
| } | |||
| ExpectChanges = True | |||
| ExpectedAdditions = None | |||
| ExpectedDeletions = None | |||
| ExpectedUpdates = { | |||
| "records": [{ | |||
| "name": "bar.unit.tests", | |||
| "id": "A-222222", | |||
| "data": "1.2.3.4", | |||
| "ttl": 3600 | |||
| }, { | |||
| "name": "foo.unit.tests", | |||
| "id": "A-111111", | |||
| "data": "1.2.3.4", | |||
| "ttl": 3600 | |||
| }] | |||
| } | |||
| return self._test_apply_with_data(TestData) | |||