1
0
mirror of https://github.com/github/octodns.git synced 2024-05-11 05:55:00 +00:00

Merge remote-tracking branch 'origin/master' into show-zone-create

This commit is contained in:
Ross McFarland
2018-03-03 10:18:32 -08:00
35 changed files with 1419 additions and 164 deletions

View File

@@ -60,7 +60,7 @@ better in the future :fingers_crossed:
#### Miscellaneous #### Miscellaneous
* Use a 3rd party lib for nautrual sorting of keys, rather than my old * Use a 3rd party lib for natural sorting of keys, rather than my old
implementation. Sorting can be disabled in the YamlProvider with implementation. Sorting can be disabled in the YamlProvider with
`enforce_order: False`. `enforce_order: False`.
* Semi-colon/escaping fixes and improvements. * Semi-colon/escaping fixes and improvements.

View File

@@ -150,8 +150,9 @@ The above command pulled the existing data out of Route53 and placed the results
| Provider | Record Support | GeoDNS Support | Notes | | Provider | Record Support | GeoDNS Support | Notes |
|--|--|--|--| |--|--|--|--|
| [AzureProvider](/octodns/provider/azuredns.py) | A, AAAA, CNAME, MX, NS, PTR, SRV, TXT | No | | | [AzureProvider](/octodns/provider/azuredns.py) | A, AAAA, CNAME, MX, NS, PTR, SRV, TXT | No | |
| [CloudflareProvider](/octodns/provider/cloudflare.py) | A, AAAA, ALIAS, CAA, CNAME, MX, NS, SPF, TXT | No | CAA tags restricted | | [CloudflareProvider](/octodns/provider/cloudflare.py) | A, AAAA, ALIAS, CAA, CNAME, MX, NS, SPF, SRV, TXT | No | CAA tags restricted |
| [DigitalOceanProvider](/octodns/provider/digitalocean.py) | A, AAAA, CAA, CNAME, MX, NS, TXT, SRV | No | CAA tags restricted | | [DigitalOceanProvider](/octodns/provider/digitalocean.py) | A, AAAA, CAA, CNAME, MX, NS, TXT, SRV | No | CAA tags restricted |
| [DnsMadeEasyProvider](/octodns/provider/dnsmadeeasy.py) | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted |
| [DnsimpleProvider](/octodns/provider/dnsimple.py) | All | No | CAA tags restricted | | [DnsimpleProvider](/octodns/provider/dnsimple.py) | All | No | CAA tags restricted |
| [DynProvider](/octodns/provider/dyn.py) | All | Yes | | | [DynProvider](/octodns/provider/dyn.py) | All | Yes | |
| [GoogleCloudProvider](/octodns/provider/googlecloud.py) | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | | | [GoogleCloudProvider](/octodns/provider/googlecloud.py) | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | |
@@ -168,6 +169,7 @@ The above command pulled the existing data out of Route53 and placed the results
* ALIAS support varies a lot from provider to provider care should be taken to verify that your needs are met in detail. * ALIAS support varies a lot from provider to provider care should be taken to verify that your needs are met in detail.
* Dyn's UI doesn't allow editing or view of TTL, but the API accepts and stores the value provided, this value does not appear to be used when served * Dyn's UI doesn't allow editing or view of TTL, but the API accepts and stores the value provided, this value does not appear to be used when served
* Dnsimple's uses the configured TTL when serving things through the ALIAS, there's also a secondary TXT record created alongside the ALIAS that octoDNS ignores * Dnsimple's uses the configured TTL when serving things through the ALIAS, there's also a secondary TXT record created alongside the ALIAS that octoDNS ignores
* octoDNS itself supports non-ASCII character sets, but in testing Cloudflare is the only provider where that is currently functional end-to-end. Others have failures either in the client libraries or API calls
## Custom Sources and Providers ## Custom Sources and Providers

View File

@@ -53,11 +53,11 @@ The geo labels breakdown based on:
1. 1.
- 'AF': 14, # Continental Africa - 'AF': 14, # Continental Africa
- 'AN': 17, # Continental Antartica - 'AN': 17, # Continental Antarctica
- 'AS': 15, # Contentinal Asia - 'AS': 15, # Continental Asia
- 'EU': 13, # Contentinal Europe - 'EU': 13, # Continental Europe
- 'NA': 11, # Continental North America - 'NA': 11, # Continental North America
- 'OC': 16, # Contentinal Austrailia/Oceania - 'OC': 16, # Continental Australia/Oceania
- 'SA': 12, # Continental South America - 'SA': 12, # Continental South America
2. ISO Country Code https://en.wikipedia.org/wiki/ISO_3166-2 2. ISO Country Code https://en.wikipedia.org/wiki/ISO_3166-2

View File

@@ -65,7 +65,7 @@ def main():
resolver = AsyncResolver(configure=False, resolver = AsyncResolver(configure=False,
num_workers=int(args.num_workers)) num_workers=int(args.num_workers))
if not ip_addr_re.match(server): if not ip_addr_re.match(server):
server = str(query(server, 'A')[0]) server = unicode(query(server, 'A')[0])
log.info('server=%s', server) log.info('server=%s', server)
resolver.nameservers = [server] resolver.nameservers = [server]
resolver.lifetime = int(args.timeout) resolver.lifetime = int(args.timeout)
@@ -81,12 +81,12 @@ def main():
stdout.write(',') stdout.write(',')
stdout.write(record._type) stdout.write(record._type)
stdout.write(',') stdout.write(',')
stdout.write(str(record.ttl)) stdout.write(unicode(record.ttl))
compare = {} compare = {}
for future in futures: for future in futures:
stdout.write(',') stdout.write(',')
try: try:
answers = [str(r) for r in future.result()] answers = [unicode(r) for r in future.result()]
except (NoAnswer, NoNameservers): except (NoAnswer, NoNameservers):
answers = ['*no answer*'] answers = ['*no answer*']
except NXDOMAIN: except NXDOMAIN:

View File

@@ -26,7 +26,7 @@ def main():
help='Limit sync to the specified zone(s)') help='Limit sync to the specified zone(s)')
# --sources isn't an option here b/c filtering sources out would be super # --sources isn't an option here b/c filtering sources out would be super
# dangerous since you could eaily end up with an empty zone and delete # dangerous since you could easily end up with an empty zone and delete
# everything, or even just part of things when there are multiple sources # everything, or even just part of things when there are multiple sources
parser.add_argument('--target', default=[], action='append', parser.add_argument('--target', default=[], action='append',

View File

@@ -51,7 +51,7 @@ class MakeThreadFuture(object):
class MainThreadExecutor(object): class MainThreadExecutor(object):
''' '''
Dummy executor that runs things on the main thread during the involcation Dummy executor that runs things on the main thread during the invocation
of submit, but still returns a future object with the result. This allows of submit, but still returns a future object with the result. This allows
code to be written to handle async, even in the case where we don't want to code to be written to handle async, even in the case where we don't want to
use multiple threads/workers and would prefer that things flow as if use multiple threads/workers and would prefer that things flow as if

View File

@@ -39,7 +39,7 @@ class _AzureRecord(object):
} }
def __init__(self, resource_group, record, delete=False): def __init__(self, resource_group, record, delete=False):
'''Contructor for _AzureRecord. '''Constructor for _AzureRecord.
Notes on Azure records: An Azure record set has the form Notes on Azure records: An Azure record set has the form
RecordSet(name=<...>, type=<...>, arecords=[...], aaaa_records, ..) RecordSet(name=<...>, type=<...>, arecords=[...], aaaa_records, ..)
@@ -222,7 +222,7 @@ class AzureProvider(BaseProvider):
azuredns: azuredns:
class: octodns.provider.azuredns.AzureProvider class: octodns.provider.azuredns.AzureProvider
client_id: env/AZURE_APPLICATION_ID client_id: env/AZURE_APPLICATION_ID
key: env/AZURE_AUTHENICATION_KEY key: env/AZURE_AUTHENTICATION_KEY
directory_id: env/AZURE_DIRECTORY_ID directory_id: env/AZURE_DIRECTORY_ID
sub_id: env/AZURE_SUBSCRIPTION_ID sub_id: env/AZURE_SUBSCRIPTION_ID
resource_group: 'TestResource1' resource_group: 'TestResource1'

View File

@@ -17,7 +17,8 @@ class BaseProvider(BaseSource):
delete_pcent_threshold=Plan.MAX_SAFE_DELETE_PCENT): delete_pcent_threshold=Plan.MAX_SAFE_DELETE_PCENT):
super(BaseProvider, self).__init__(id) super(BaseProvider, self).__init__(id)
self.log.debug('__init__: id=%s, apply_disabled=%s, ' self.log.debug('__init__: id=%s, apply_disabled=%s, '
'update_pcent_threshold=%d, delete_pcent_threshold=%d', 'update_pcent_threshold=%.2f'
'delete_pcent_threshold=%.2f',
id, id,
apply_disabled, apply_disabled,
update_pcent_threshold, update_pcent_threshold,
@@ -29,14 +30,14 @@ class BaseProvider(BaseSource):
def _include_change(self, change): def _include_change(self, change):
''' '''
An opportunity for providers to filter out false positives due to An opportunity for providers to filter out false positives due to
pecularities in their implementation. E.g. minimum TTLs. peculiarities in their implementation. E.g. minimum TTLs.
''' '''
return True return True
def _extra_changes(self, existing, changes): def _extra_changes(self, existing, changes):
''' '''
An opportunity for providers to add extra changes to the plan that are An opportunity for providers to add extra changes to the plan that are
necessary to update ancilary record data or configure the zone. E.g. necessary to update ancillary record data or configure the zone. E.g.
base NS records. base NS records.
''' '''
return [] return []
@@ -66,7 +67,7 @@ class BaseProvider(BaseSource):
extra = self._extra_changes(existing, changes) extra = self._extra_changes(existing, changes)
if extra: if extra:
self.log.info('plan: extra changes\n %s', '\n ' self.log.info('plan: extra changes\n %s', '\n '
.join([str(c) for c in extra])) .join([unicode(c) for c in extra]))
changes += extra changes += extra
if changes: if changes:

View File

@@ -14,14 +14,18 @@ from ..record import Record, Update
from .base import BaseProvider from .base import BaseProvider
class CloudflareAuthenticationError(Exception): class CloudflareError(Exception):
def __init__(self, data): def __init__(self, data):
try: try:
message = data['errors'][0]['message'] message = data['errors'][0]['message']
except (IndexError, KeyError): except (IndexError, KeyError):
message = 'Authentication error' message = 'Cloudflare error'
super(CloudflareAuthenticationError, self).__init__(message) super(CloudflareError, self).__init__(message)
class CloudflareAuthenticationError(CloudflareError):
def __init__(self, data):
CloudflareError.__init__(self, data)
class CloudflareProvider(BaseProvider): class CloudflareProvider(BaseProvider):
@@ -34,18 +38,24 @@ class CloudflareProvider(BaseProvider):
email: dns-manager@example.com email: dns-manager@example.com
# The api key (required) # The api key (required)
token: foo token: foo
# Import CDN enabled records as CNAME to {}.cdn.cloudflare.net. Records
# ending at .cdn.cloudflare.net. will be ignored when this provider is
# not used as the source and the cdn option is enabled.
#
# See: https://support.cloudflare.com/hc/en-us/articles/115000830351
cdn: false
''' '''
SUPPORTS_GEO = False SUPPORTS_GEO = False
# TODO: support SRV SUPPORTS = set(('ALIAS', 'A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SRV',
SUPPORTS = set(('ALIAS', 'A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SPF', 'SPF', 'TXT'))
'TXT'))
MIN_TTL = 120 MIN_TTL = 120
TIMEOUT = 15 TIMEOUT = 15
def __init__(self, id, email, token, *args, **kwargs): def __init__(self, id, email, token, cdn=False, *args, **kwargs):
self.log = getLogger('CloudflareProvider[{}]'.format(id)) self.log = getLogger('CloudflareProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, email=%s, token=***', id, email) self.log.debug('__init__: id=%s, email=%s, token=***, cdn=%s', id,
email, cdn)
super(CloudflareProvider, self).__init__(id, *args, **kwargs) super(CloudflareProvider, self).__init__(id, *args, **kwargs)
sess = Session() sess = Session()
@@ -53,6 +63,7 @@ class CloudflareProvider(BaseProvider):
'X-Auth-Email': email, 'X-Auth-Email': email,
'X-Auth-Key': token, 'X-Auth-Key': token,
}) })
self.cdn = cdn
self._sess = sess self._sess = sess
self._zones = None self._zones = None
@@ -65,8 +76,11 @@ class CloudflareProvider(BaseProvider):
resp = self._sess.request(method, url, params=params, json=data, resp = self._sess.request(method, url, params=params, json=data,
timeout=self.TIMEOUT) timeout=self.TIMEOUT)
self.log.debug('_request: status=%d', resp.status_code) self.log.debug('_request: status=%d', resp.status_code)
if resp.status_code == 400:
raise CloudflareError(resp.json())
if resp.status_code == 403: if resp.status_code == 403:
raise CloudflareAuthenticationError(resp.json()) raise CloudflareAuthenticationError(resp.json())
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
@@ -88,6 +102,18 @@ class CloudflareProvider(BaseProvider):
return self._zones return self._zones
def _data_for_cdn(self, name, _type, records):
self.log.info('CDN rewrite for %s', records[0]['name'])
_type = "CNAME"
if name == "":
_type = "ALIAS"
return {
'ttl': records[0]['ttl'],
'type': _type,
'value': '{}.cdn.cloudflare.net.'.format(records[0]['name']),
}
def _data_for_multiple(self, _type, records): def _data_for_multiple(self, _type, records):
return { return {
'ttl': records[0]['ttl'], 'ttl': records[0]['ttl'],
@@ -147,6 +173,21 @@ class CloudflareProvider(BaseProvider):
'values': ['{}.'.format(r['content']) for r in records], 'values': ['{}.'.format(r['content']) for r in records],
} }
def _data_for_SRV(self, _type, records):
values = []
for r in records:
values.append({
'priority': r['data']['priority'],
'weight': r['data']['weight'],
'port': r['data']['port'],
'target': '{}.'.format(r['data']['target']),
})
return {
'type': _type,
'ttl': records[0]['ttl'],
'values': values
}
def zone_records(self, zone): def zone_records(self, zone):
if zone.name not in self._zone_records: if zone.name not in self._zone_records:
zone_id = self.zones.get(zone.name, False) zone_id = self.zones.get(zone.name, False)
@@ -169,6 +210,20 @@ class CloudflareProvider(BaseProvider):
return self._zone_records[zone.name] return self._zone_records[zone.name]
def _record_for(self, zone, name, _type, records, lenient):
# rewrite Cloudflare proxied records
if self.cdn and records[0]['proxied']:
data = self._data_for_cdn(name, _type, records)
else:
# Cloudflare supports ALIAS semantics with root CNAMEs
if _type == 'CNAME' and name == '':
_type = 'ALIAS'
data_for = getattr(self, '_data_for_{}'.format(_type))
data = data_for(_type, records)
return Record.new(zone, name, data, source=self, lenient=lenient)
def populate(self, zone, target=False, lenient=False): def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient) target, lenient)
@@ -187,15 +242,17 @@ class CloudflareProvider(BaseProvider):
for name, types in values.items(): for name, types in values.items():
for _type, records in types.items(): for _type, records in types.items():
record = self._record_for(zone, name, _type, records,
lenient)
# Cloudflare supports ALIAS semantics with root CNAMEs # only one rewrite is needed for names where the proxy is
if _type == 'CNAME' and name == '': # enabled at multiple records with a different type but
_type = 'ALIAS' # the same name
if (self.cdn and records[0]['proxied'] and
record in zone._records[name]):
self.log.info('CDN rewrite %s already in zone', name)
continue
data_for = getattr(self, '_data_for_{}'.format(_type))
data = data_for(_type, records)
record = Record.new(zone, name, data, source=self,
lenient=lenient)
zone.add_record(record) zone.add_record(record)
self.log.info('populate: found %s records, exists=%s', self.log.info('populate: found %s records, exists=%s',
@@ -206,9 +263,16 @@ class CloudflareProvider(BaseProvider):
if isinstance(change, Update): if isinstance(change, Update):
existing = change.existing.data existing = change.existing.data
new = change.new.data new = change.new.data
new['ttl'] = max(120, new['ttl']) new['ttl'] = max(self.MIN_TTL, new['ttl'])
if new == existing: if new == existing:
return False return False
# If this is a record to enable Cloudflare CDN don't update as
# we don't know the original values.
if (change.record._type in ('ALIAS', 'CNAME') and
change.record.value.endswith('.cdn.cloudflare.net.')):
return False
return True return True
def _contents_for_multiple(self, record): def _contents_for_multiple(self, record):
@@ -244,6 +308,21 @@ class CloudflareProvider(BaseProvider):
'content': value.exchange 'content': value.exchange
} }
def _contents_for_SRV(self, record):
service, proto = record.name.split('.', 2)
for value in record.values:
yield {
'data': {
'service': service,
'proto': proto,
'name': record.zone.name,
'priority': value.priority,
'weight': value.weight,
'port': value.port,
'target': value.target[:-1],
}
}
def _gen_contents(self, record): def _gen_contents(self, record):
name = record.fqdn[:-1] name = record.fqdn[:-1]
_type = record._type _type = record._type
@@ -299,10 +378,6 @@ class CloudflareProvider(BaseProvider):
for c in self._gen_contents(change.new) for c in self._gen_contents(change.new)
} }
# We need a list of keys to consider for diffs, use the first content
# before we muck with anything
keys = existing_contents.values()[0].keys()
# Find the things we need to add # Find the things we need to add
adds = [] adds = []
for k, content in new_contents.items(): for k, content in new_contents.items():
@@ -312,22 +387,25 @@ class CloudflareProvider(BaseProvider):
except KeyError: except KeyError:
adds.append(content) adds.append(content)
zone_id = self.zones[change.new.zone.name] zone = change.new.zone
zone_id = self.zones[zone.name]
# Find things we need to remove # Find things we need to remove
name = change.new.fqdn[:-1] hostname = zone.hostname_from_fqdn(change.new.fqdn[:-1])
_type = change.new._type _type = change.new._type
# OK, work through each record from the zone # OK, work through each record from the zone
for record in self.zone_records(change.new.zone): for record in self.zone_records(zone):
if name == record['name'] and _type == record['type']: name = zone.hostname_from_fqdn(record['name'])
# This is match for our name and type, we need to look at # Use the _record_for so that we include all of standard
# contents now, build a dict of the relevant keys and vals # conversion logic
content = {} r = self._record_for(zone, name, record['type'], [record], True)
for k in keys: if hostname == r.name and _type == r._type:
content[k] = record[k]
# :-( # Round trip the single value through a record to contents flow
if _type in ('CNAME', 'MX', 'NS'): # to get a consistent _gen_contents result that matches what
content['content'] += '.' # went in to new_contents
content = self._gen_contents(r).next()
# If the hash of that dict isn't in new this record isn't # If the hash of that dict isn't in new this record isn't
# needed # needed
if self._hash_content(content) not in new_contents: if self._hash_content(content) not in new_contents:

View File

@@ -56,7 +56,7 @@ class DigitalOceanClient(object):
self._request('POST', '/domains', data={'name': name, self._request('POST', '/domains', data={'name': name,
'ip_address': '192.0.2.1'}) 'ip_address': '192.0.2.1'})
# After the zone is created, immeadiately delete the record # After the zone is created, immediately delete the record
records = self.records(name) records = self.records(name)
for record in records: for record in records:
if record['name'] == '' and record['type'] == 'A': if record['name'] == '' and record['type'] == 'A':

View File

@@ -160,7 +160,7 @@ class DnsimpleProvider(BaseProvider):
record['content'].split(' ', 5) record['content'].split(' ', 5)
except ValueError: except ValueError:
# their api will let you create invalid records, this # their api will let you create invalid records, this
# essnetially handles that by ignoring them for values # essentially handles that by ignoring them for values
# purposes. That will cause updates to happen to delete them if # purposes. That will cause updates to happen to delete them if
# they shouldn't exist or update them if they're wrong # they shouldn't exist or update them if they're wrong
continue continue

View File

@@ -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)

View File

@@ -40,7 +40,7 @@ class _CachingDynZone(DynZone):
cls.log.debug('get: fetched') cls.log.debug('get: fetched')
except DynectGetError: except DynectGetError:
if not create: if not create:
cls.log.debug("get: does't exist") cls.log.debug("get: doesn't exist")
return None return None
# this value shouldn't really matter, it's not tied to # this value shouldn't really matter, it's not tied to
# whois or anything # whois or anything
@@ -129,11 +129,11 @@ class DynProvider(BaseProvider):
REGION_CODES = { REGION_CODES = {
'NA': 11, # Continental North America 'NA': 11, # Continental North America
'SA': 12, # Continental South America 'SA': 12, # Continental South America
'EU': 13, # Contentinal Europe 'EU': 13, # Continental Europe
'AF': 14, # Continental Africa 'AF': 14, # Continental Africa
'AS': 15, # Contentinal Asia 'AS': 15, # Continental Asia
'OC': 16, # Contentinal Austrailia/Oceania 'OC': 16, # Continental Australia/Oceania
'AN': 17, # Continental Antartica 'AN': 17, # Continental Antarctica
} }
_sess_create_lock = Lock() _sess_create_lock = Lock()
@@ -166,7 +166,7 @@ class DynProvider(BaseProvider):
if DynectSession.get_session() is None: if DynectSession.get_session() is None:
# We need to create a new session for this thread and DynectSession # We need to create a new session for this thread and DynectSession
# creation is not thread-safe so we have to do the locking. If we # creation is not thread-safe so we have to do the locking. If we
# don't and multiple sessions start creattion before the the first # don't and multiple sessions start creation before the the first
# has finished (long time b/c it makes http calls) the subsequent # has finished (long time b/c it makes http calls) the subsequent
# creates will blow away DynectSession._instances, potentially # creates will blow away DynectSession._instances, potentially
# multiple times if there are multiple creates in flight. Only the # multiple times if there are multiple creates in flight. Only the
@@ -291,7 +291,7 @@ class DynProvider(BaseProvider):
try: try:
fqdn, _type = td.label.split(':', 1) fqdn, _type = td.label.split(':', 1)
except ValueError as e: except ValueError as e:
self.log.warn("Failed to load TraficDirector '%s': %s", self.log.warn("Failed to load TrafficDirector '%s': %s",
td.label, e.message) td.label, e.message)
continue continue
tds[fqdn][_type] = td tds[fqdn][_type] = td

View File

@@ -127,9 +127,10 @@ class GoogleCloudProvider(BaseProvider):
:type return: new google.cloud.dns.ManagedZone :type return: new google.cloud.dns.ManagedZone
""" """
# Zone name must begin with a letter, end with a letter or digit, # Zone name must begin with a letter, end with a letter or digit,
# and only contain lowercase letters, digits or dashes # and only contain lowercase letters, digits or dashes,
zone_name = '{}-{}'.format( # and be 63 characters or less
dns_name[:-1].replace('.', '-'), uuid4().hex) zone_name = 'zone-{}-{}'.format(
dns_name.replace('.', '-'), uuid4().hex)[:63]
gcloud_zone = self.gcloud_client.zone( gcloud_zone = self.gcloud_client.zone(
name=zone_name, name=zone_name,

View File

@@ -75,9 +75,9 @@ class Ns1Provider(BaseProvider):
else: else:
values.extend(answer['answer']) values.extend(answer['answer'])
codes.append([]) codes.append([])
values = [str(x) for x in values] values = [unicode(x) for x in values]
geo = OrderedDict( geo = OrderedDict(
{str(k): [str(x) for x in v] for k, v in geo.items()} {unicode(k): [unicode(x) for x in v] for k, v in geo.items()}
) )
data['values'] = values data['values'] = values
data['geo'] = geo data['geo'] = geo
@@ -236,11 +236,10 @@ class Ns1Provider(BaseProvider):
}, },
) )
params['filters'] = [] params['filters'] = []
if len(params['answers']) > 1: if has_country:
params['filters'].append( params['filters'].append(
{"filter": "shuffle", "config": {}} {"filter": "shuffle", "config": {}}
) )
if has_country:
params['filters'].append( params['filters'].append(
{"filter": "geotarget_country", "config": {}} {"filter": "geotarget_country", "config": {}}
) )

View File

@@ -269,10 +269,11 @@ class OvhProvider(BaseProvider):
def _params_for_SRV(record): def _params_for_SRV(record):
for value in record.values: for value in record.values:
yield { yield {
'subDomain': '{} {} {} {}'.format(value.priority, 'target': '{} {} {} {}'.format(value.priority,
value.weight, value.port, value.weight,
value.target), value.port,
'target': record.name, value.target),
'subDomain': record.name,
'ttl': record.ttl, 'ttl': record.ttl,
'fieldType': record._type 'fieldType': record._type
} }
@@ -281,10 +282,10 @@ class OvhProvider(BaseProvider):
def _params_for_SSHFP(record): def _params_for_SSHFP(record):
for value in record.values: for value in record.values:
yield { yield {
'subDomain': '{} {} {}'.format(value.algorithm, 'target': '{} {} {}'.format(value.algorithm,
value.fingerprint_type, value.fingerprint_type,
value.fingerprint), value.fingerprint),
'target': record.name, 'subDomain': record.name,
'ttl': record.ttl, 'ttl': record.ttl,
'fieldType': record._type 'fieldType': record._type
} }

View File

@@ -61,17 +61,17 @@ class Plan(object):
delete_pcent = self.change_counts['Delete'] / existing_record_count delete_pcent = self.change_counts['Delete'] / existing_record_count
if update_pcent > self.update_pcent_threshold: if update_pcent > self.update_pcent_threshold:
raise UnsafePlan('Too many updates, {} is over {} percent' raise UnsafePlan('Too many updates, {:.2f} is over {:.2f} %'
'({}/{})'.format( '({}/{})'.format(
update_pcent, update_pcent * 100,
self.MAX_SAFE_UPDATE_PCENT * 100, self.update_pcent_threshold * 100,
self.change_counts['Update'], self.change_counts['Update'],
existing_record_count)) existing_record_count))
if delete_pcent > self.delete_pcent_threshold: if delete_pcent > self.delete_pcent_threshold:
raise UnsafePlan('Too many deletes, {} is over {} percent' raise UnsafePlan('Too many deletes, {:.2f} is over {:.2f} %'
'({}/{})'.format( '({}/{})'.format(
delete_pcent, delete_pcent * 100,
self.MAX_SAFE_DELETE_PCENT * 100, self.delete_pcent_threshold * 100,
self.change_counts['Delete'], self.change_counts['Delete'],
existing_record_count)) existing_record_count))
@@ -147,11 +147,11 @@ class PlanLogger(_PlanOutput):
def _value_stringifier(record, sep): def _value_stringifier(record, sep):
try: try:
values = [str(v) for v in record.values] values = [unicode(v) for v in record.values]
except AttributeError: except AttributeError:
values = [record.value] values = [record.value]
for code, gv in sorted(getattr(record, 'geo', {}).items()): for code, gv in sorted(getattr(record, 'geo', {}).items()):
vs = ', '.join([str(v) for v in gv.values]) vs = ', '.join([unicode(v) for v in gv.values])
values.append('{}: {}'.format(code, vs)) values.append('{}: {}'.format(code, vs))
return sep.join(values) return sep.join(values)
@@ -193,7 +193,7 @@ class PlanMarkdown(_PlanOutput):
fh.write(' | ') fh.write(' | ')
# TTL # TTL
if existing: if existing:
fh.write(str(existing.ttl)) fh.write(unicode(existing.ttl))
fh.write(' | ') fh.write(' | ')
fh.write(_value_stringifier(existing, '; ')) fh.write(_value_stringifier(existing, '; '))
fh.write(' | |\n') fh.write(' | |\n')
@@ -201,7 +201,7 @@ class PlanMarkdown(_PlanOutput):
fh.write('| | | | ') fh.write('| | | | ')
if new: if new:
fh.write(str(new.ttl)) fh.write(unicode(new.ttl))
fh.write(' | ') fh.write(' | ')
fh.write(_value_stringifier(new, '; ')) fh.write(_value_stringifier(new, '; '))
fh.write(' | ') fh.write(' | ')
@@ -210,7 +210,7 @@ class PlanMarkdown(_PlanOutput):
fh.write(' |\n') fh.write(' |\n')
fh.write('\nSummary: ') fh.write('\nSummary: ')
fh.write(str(plan)) fh.write(unicode(plan))
fh.write('\n\n') fh.write('\n\n')
else: else:
fh.write('## No changes were planned\n') fh.write('## No changes were planned\n')
@@ -261,7 +261,7 @@ class PlanHtml(_PlanOutput):
# TTL # TTL
if existing: if existing:
fh.write(' <td>') fh.write(' <td>')
fh.write(str(existing.ttl)) fh.write(unicode(existing.ttl))
fh.write('</td>\n <td>') fh.write('</td>\n <td>')
fh.write(_value_stringifier(existing, '<br/>')) fh.write(_value_stringifier(existing, '<br/>'))
fh.write('</td>\n <td></td>\n </tr>\n') fh.write('</td>\n <td></td>\n </tr>\n')
@@ -270,7 +270,7 @@ class PlanHtml(_PlanOutput):
if new: if new:
fh.write(' <td>') fh.write(' <td>')
fh.write(str(new.ttl)) fh.write(unicode(new.ttl))
fh.write('</td>\n <td>') fh.write('</td>\n <td>')
fh.write(_value_stringifier(new, '<br/>')) fh.write(_value_stringifier(new, '<br/>'))
fh.write('</td>\n <td>') fh.write('</td>\n <td>')
@@ -279,7 +279,7 @@ class PlanHtml(_PlanOutput):
fh.write('</td>\n </tr>\n') fh.write('</td>\n </tr>\n')
fh.write(' <tr>\n <td colspan=6>Summary: ') fh.write(' <tr>\n <td colspan=6>Summary: ')
fh.write(str(plan)) fh.write(unicode(plan))
fh.write('</td>\n </tr>\n</table>\n') fh.write('</td>\n </tr>\n</table>\n')
else: else:
fh.write('<b>No changes were planned</b>') fh.write('<b>No changes were planned</b>')

View File

@@ -178,7 +178,7 @@ class PowerDnsBaseProvider(BaseProvider):
raise Exception('PowerDNS unauthorized host={}' raise Exception('PowerDNS unauthorized host={}'
.format(self.host)) .format(self.host))
elif e.response.status_code == 422: elif e.response.status_code == 422:
# 422 means powerdns doesn't know anything about the requsted # 422 means powerdns doesn't know anything about the requested
# domain. We'll just ignore it here and leave the zone # domain. We'll just ignore it here and leave the zone
# untouched. # untouched.
pass pass
@@ -297,8 +297,8 @@ class PowerDnsBaseProvider(BaseProvider):
return [] return []
# sorting mostly to make things deterministic for testing, but in # sorting mostly to make things deterministic for testing, but in
# theory it let us find what we're after quickier (though sorting would # theory it let us find what we're after quicker (though sorting would
# ve more exepensive.) # be more expensive.)
for record in sorted(existing.records): for record in sorted(existing.records):
if record == ns: if record == ns:
# We've found the top-level NS record, return any changes # We've found the top-level NS record, return any changes
@@ -344,7 +344,7 @@ class PowerDnsBaseProvider(BaseProvider):
e.response.text) e.response.text)
raise raise
self.log.info('_apply: creating zone=%s', desired.name) self.log.info('_apply: creating zone=%s', desired.name)
# 422 means powerdns doesn't know anything about the requsted # 422 means powerdns doesn't know anything about the requested
# domain. We'll try to create it with the correct records instead # domain. We'll try to create it with the correct records instead
# of update. Hopefully all the mods are creates :-) # of update. Hopefully all the mods are creates :-)
data = { data = {

View File

@@ -130,17 +130,9 @@ class RackspaceProvider(BaseProvider):
def _delete(self, path, data=None): def _delete(self, path, data=None):
return self._request('DELETE', path, data=data) return self._request('DELETE', path, data=data)
@staticmethod
def _as_unicode(s, codec):
if not isinstance(s, unicode):
return unicode(s, codec)
return s
@classmethod @classmethod
def _key_for_record(cls, rs_record): def _key_for_record(cls, rs_record):
return cls._as_unicode(rs_record['type'], 'ascii'), \ return rs_record['type'], rs_record['name'], rs_record['data']
cls._as_unicode(rs_record['name'], 'utf-8'), \
cls._as_unicode(rs_record['data'], 'utf-8')
def _data_for_multiple(self, rrset): def _data_for_multiple(self, rrset):
return { return {

View File

@@ -61,7 +61,7 @@ class _Route53Record(object):
# NOTE: we're using __hash__ and __cmp__ methods that consider # NOTE: we're using __hash__ and __cmp__ methods that consider
# _Route53Records equivalent if they have the same class, fqdn, and _type. # _Route53Records equivalent if they have the same class, fqdn, and _type.
# Values are ignored. This is usful when computing diffs/changes. # Values are ignored. This is useful when computing diffs/changes.
def __hash__(self): def __hash__(self):
'sub-classes should never use this method' 'sub-classes should never use this method'
@@ -682,7 +682,7 @@ class Route53Provider(BaseProvider):
.get('CountryCode', False) == '*': .get('CountryCode', False) == '*':
# it's a default record # it's a default record
continue continue
# we expect a healtcheck now # we expect a healthcheck now
try: try:
health_check_id = rrset['HealthCheckId'] health_check_id = rrset['HealthCheckId']
caller_ref = \ caller_ref = \
@@ -733,7 +733,7 @@ class Route53Provider(BaseProvider):
batch_rs_count) batch_rs_count)
# send the batch # send the batch
self._really_apply(batch, zone_id) self._really_apply(batch, zone_id)
# start a new batch with the lefovers # start a new batch with the leftovers
batch = mods batch = mods
batch_rs_count = mods_rs_count batch_rs_count = mods_rs_count

View File

@@ -122,7 +122,7 @@ class Record(object):
self.__class__.__name__, name) self.__class__.__name__, name)
self.zone = zone self.zone = zone
# force everything lower-case just to be safe # force everything lower-case just to be safe
self.name = str(name).lower() if name else name self.name = unicode(name).lower() if name else name
self.source = source self.source = source
self.ttl = int(data['ttl']) self.ttl = int(data['ttl'])
@@ -151,7 +151,7 @@ class Record(object):
# NOTE: we're using __hash__ and __cmp__ methods that consider Records # NOTE: we're using __hash__ and __cmp__ methods that consider Records
# equivalent if they have the same name & _type. Values are ignored. This # equivalent if they have the same name & _type. Values are ignored. This
# is usful when computing diffs/changes. # is useful when computing diffs/changes.
def __hash__(self): def __hash__(self):
return '{}:{}'.format(self.name, self._type).__hash__() return '{}:{}'.format(self.name, self._type).__hash__()
@@ -274,7 +274,8 @@ class _ValuesMixin(object):
return ret return ret
def __repr__(self): def __repr__(self):
values = "['{}']".format("', '".join([str(v) for v in self.values])) values = "['{}']".format("', '".join([unicode(v)
for v in self.values]))
return '<{} {} {}, {}, {}>'.format(self.__class__.__name__, return '<{} {} {}, {}, {}>'.format(self.__class__.__name__,
self._type, self.ttl, self._type, self.ttl,
self.fqdn, values) self.fqdn, values)
@@ -678,8 +679,8 @@ class PtrRecord(_ValueMixin, Record):
class SshfpValue(object): class SshfpValue(object):
VALID_ALGORITHMS = (1, 2) VALID_ALGORITHMS = (1, 2, 3)
VALID_FINGERPRINT_TYPES = (1,) VALID_FINGERPRINT_TYPES = (1, 2)
@classmethod @classmethod
def _validate_value(cls, value): def _validate_value(cls, value):

View File

@@ -37,10 +37,10 @@ class Zone(object):
if not name[-1] == '.': if not name[-1] == '.':
raise Exception('Invalid zone name {}, missing ending dot' raise Exception('Invalid zone name {}, missing ending dot'
.format(name)) .format(name))
# Force everyting to lowercase just to be safe # Force everything to lowercase just to be safe
self.name = str(name).lower() if name else name self.name = unicode(name).lower() if name else name
self.sub_zones = sub_zones self.sub_zones = sub_zones
# We're grouping by node, it allows us to efficently search for # We're grouping by node, it allows us to efficiently search for
# duplicates and detect when CNAMEs co-exist with other records # duplicates and detect when CNAMEs co-exist with other records
self._records = defaultdict(set) self._records = defaultdict(set)
# optional leading . to match empty hostname # optional leading . to match empty hostname

View File

@@ -11,4 +11,4 @@ git tag -s v$VERSION -m "Release $VERSION"
git push origin v$VERSION git push origin v$VERSION
echo "Tagged and pushed v$VERSION" echo "Tagged and pushed v$VERSION"
python setup.py sdist upload python setup.py sdist upload
echo "Updloaded $VERSION" echo "Uploaded $VERSION"

View File

@@ -156,14 +156,64 @@
"meta": { "meta": {
"auto_added": false "auto_added": false
} }
},
{
"id": "fc12ab34cd5611334422ab3322997656",
"type": "SRV",
"name": "_srv._tcp.unit.tests",
"data": {
"service": "_srv",
"proto": "_tcp",
"name": "unit.tests",
"priority": 12,
"weight": 20,
"port": 30,
"target": "foo-2.unit.tests"
},
"proxiable": true,
"proxied": false,
"ttl": 600,
"locked": false,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.940682Z",
"created_on": "2017-03-11T18:01:43.940682Z",
"meta": {
"auto_added": false
}
},
{
"id": "fc12ab34cd5611334422ab3322997656",
"type": "SRV",
"name": "_srv._tcp.unit.tests",
"data": {
"service": "_srv",
"proto": "_tcp",
"name": "unit.tests",
"priority": 10,
"weight": 20,
"port": 30,
"target": "foo-1.unit.tests"
},
"proxiable": true,
"proxied": false,
"ttl": 600,
"locked": false,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.940682Z",
"created_on": "2017-03-11T18:01:43.940682Z",
"meta": {
"auto_added": false
}
} }
], ],
"result_info": { "result_info": {
"page": 2, "page": 2,
"per_page": 10, "per_page": 11,
"total_pages": 2, "total_pages": 2,
"count": 9, "count": 9,
"total_count": 19 "total_count": 21
}, },
"success": true, "success": true,
"errors": [], "errors": [],

16
tests/fixtures/dnsmadeeasy-domains.json vendored Normal file
View File

@@ -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
}

312
tests/fixtures/dnsmadeeasy-records.json vendored Normal file
View File

@@ -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
}

View File

@@ -63,14 +63,14 @@ class TestBaseProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
with self.assertRaises(NotImplementedError) as ctx: with self.assertRaises(NotImplementedError) as ctx:
HasSupportsGeo('hassupportesgeo').populate(zone) HasSupportsGeo('hassupportsgeo').populate(zone)
self.assertEquals('Abstract base class, SUPPORTS property missing', self.assertEquals('Abstract base class, SUPPORTS property missing',
ctx.exception.message) ctx.exception.message)
class HasSupports(HasSupportsGeo): class HasSupports(HasSupportsGeo):
SUPPORTS = set(('A',)) SUPPORTS = set(('A',))
with self.assertRaises(NotImplementedError) as ctx: with self.assertRaises(NotImplementedError) as ctx:
HasSupports('hassupportes').populate(zone) HasSupports('hassupports').populate(zone)
self.assertEquals('Abstract base class, populate method missing', self.assertEquals('Abstract base class, populate method missing',
ctx.exception.message) ctx.exception.message)
@@ -94,7 +94,7 @@ class TestBaseProvider(TestCase):
'value': '1.2.3.4' 'value': '1.2.3.4'
})) }))
self.assertTrue(HasSupports('hassupportesgeo') self.assertTrue(HasSupports('hassupportsgeo')
.supports(list(zone.records)[0])) .supports(list(zone.records)[0]))
plan = HasPopulate('haspopulate').plan(zone) plan = HasPopulate('haspopulate').plan(zone)
@@ -178,7 +178,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), { zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@@ -210,7 +210,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), { zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@@ -236,7 +236,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), { zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@@ -258,7 +258,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), { zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@@ -284,7 +284,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), { zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@@ -307,7 +307,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), { zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'
@@ -335,7 +335,7 @@ class TestBaseProvider(TestCase):
}) })
for i in range(int(Plan.MIN_EXISTING_RECORDS)): for i in range(int(Plan.MIN_EXISTING_RECORDS)):
zone.add_record(Record.new(zone, str(i), { zone.add_record(Record.new(zone, unicode(i), {
'ttl': 60, 'ttl': 60,
'type': 'A', 'type': 'A',
'value': '2.3.4.5' 'value': '2.3.4.5'

View File

@@ -42,6 +42,20 @@ class TestCloudflareProvider(TestCase):
def test_populate(self): def test_populate(self):
provider = CloudflareProvider('test', 'email', 'token') provider = CloudflareProvider('test', 'email', 'token')
# Bad requests
with requests_mock() as mock:
mock.get(ANY, status_code=400,
text='{"success":false,"errors":[{"code":1101,'
'"message":"request was invalid"}],'
'"messages":[],"result":null}')
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals('CloudflareError', type(ctx.exception).__name__)
self.assertEquals('request was invalid', ctx.exception.message)
# Bad auth # Bad auth
with requests_mock() as mock: with requests_mock() as mock:
mock.get(ANY, status_code=403, mock.get(ANY, status_code=403,
@@ -52,6 +66,8 @@ class TestCloudflareProvider(TestCase):
with self.assertRaises(Exception) as ctx: with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals('CloudflareAuthenticationError',
type(ctx.exception).__name__)
self.assertEquals('Unknown X-Auth-Key or X-Auth-Email', self.assertEquals('Unknown X-Auth-Key or X-Auth-Email',
ctx.exception.message) ctx.exception.message)
@@ -62,7 +78,9 @@ class TestCloudflareProvider(TestCase):
with self.assertRaises(Exception) as ctx: with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals('Authentication error', ctx.exception.message) self.assertEquals('CloudflareAuthenticationError',
type(ctx.exception).__name__)
self.assertEquals('Cloudflare error', ctx.exception.message)
# General error # General error
with requests_mock() as mock: with requests_mock() as mock:
@@ -119,15 +137,16 @@ class TestCloudflareProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(11, len(zone.records)) self.assertEquals(12, len(zone.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes)) self.assertEquals(0, len(changes))
# re-populating the same zone/records comes out of cache, no calls # re-populating the same zone/records comes out of cache, no calls
again = Zone('unit.tests.', []) again = Zone('unit.tests.', [])
provider.populate(again) provider.populate(again)
self.assertEquals(11, len(again.records)) self.assertEquals(12, len(again.records))
def test_apply(self): def test_apply(self):
provider = CloudflareProvider('test', 'email', 'token') provider = CloudflareProvider('test', 'email', 'token')
@@ -141,12 +160,12 @@ class TestCloudflareProvider(TestCase):
'id': 42, 'id': 42,
} }
}, # zone create }, # zone create
] + [None] * 18 # individual record creates ] + [None] * 20 # individual record creates
# non-existant zone, create everything # non-existant zone, create everything
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
self.assertEquals(11, len(plan.changes)) self.assertEquals(12, len(plan.changes))
self.assertEquals(11, provider.apply(plan)) self.assertEquals(12, provider.apply(plan))
self.assertFalse(plan.exists) self.assertFalse(plan.exists)
provider._request.assert_has_calls([ provider._request.assert_has_calls([
@@ -172,7 +191,7 @@ class TestCloudflareProvider(TestCase):
}), }),
], True) ], True)
# expected number of total calls # expected number of total calls
self.assertEquals(20, provider._request.call_count) self.assertEquals(22, provider._request.call_count)
provider._request.reset_mock() provider._request.reset_mock()
@@ -486,3 +505,187 @@ class TestCloudflareProvider(TestCase):
'ttl': 300, 'ttl': 300,
'type': 'CNAME' 'type': 'CNAME'
}, list(contents)[0]) }, list(contents)[0])
def test_cdn(self):
provider = CloudflareProvider('test', 'email', 'token', True)
# A CNAME for us to transform to ALIAS
provider.zone_records = Mock(return_value=[
{
"id": "fc12ab34cd5611334422ab3322997642",
"type": "CNAME",
"name": "cname.unit.tests",
"content": "www.unit.tests",
"proxiable": True,
"proxied": True,
"ttl": 300,
"locked": False,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.420689Z",
"created_on": "2017-03-11T18:01:43.420689Z",
"meta": {
"auto_added": False
}
},
{
"id": "fc12ab34cd5611334422ab3322997642",
"type": "A",
"name": "a.unit.tests",
"content": "1.1.1.1",
"proxiable": True,
"proxied": True,
"ttl": 300,
"locked": False,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.420689Z",
"created_on": "2017-03-11T18:01:43.420689Z",
"meta": {
"auto_added": False
}
},
{
"id": "fc12ab34cd5611334422ab3322997642",
"type": "A",
"name": "a.unit.tests",
"content": "1.1.1.2",
"proxiable": True,
"proxied": True,
"ttl": 300,
"locked": False,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.420689Z",
"created_on": "2017-03-11T18:01:43.420689Z",
"meta": {
"auto_added": False
}
},
{
"id": "fc12ab34cd5611334422ab3322997642",
"type": "A",
"name": "multi.unit.tests",
"content": "1.1.1.3",
"proxiable": True,
"proxied": True,
"ttl": 300,
"locked": False,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.420689Z",
"created_on": "2017-03-11T18:01:43.420689Z",
"meta": {
"auto_added": False
}
},
{
"id": "fc12ab34cd5611334422ab3322997642",
"type": "AAAA",
"name": "multi.unit.tests",
"content": "::1",
"proxiable": True,
"proxied": True,
"ttl": 300,
"locked": False,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.420689Z",
"created_on": "2017-03-11T18:01:43.420689Z",
"meta": {
"auto_added": False
}
},
])
zone = Zone('unit.tests.', [])
provider.populate(zone)
# the two A records get merged into one CNAME record pointing to
# the CDN.
self.assertEquals(3, len(zone.records))
record = list(zone.records)[0]
self.assertEquals('multi', record.name)
self.assertEquals('multi.unit.tests.', record.fqdn)
self.assertEquals('CNAME', record._type)
self.assertEquals('multi.unit.tests.cdn.cloudflare.net.', record.value)
record = list(zone.records)[1]
self.assertEquals('cname', record.name)
self.assertEquals('cname.unit.tests.', record.fqdn)
self.assertEquals('CNAME', record._type)
self.assertEquals('cname.unit.tests.cdn.cloudflare.net.', record.value)
record = list(zone.records)[2]
self.assertEquals('a', record.name)
self.assertEquals('a.unit.tests.', record.fqdn)
self.assertEquals('CNAME', record._type)
self.assertEquals('a.unit.tests.cdn.cloudflare.net.', record.value)
# CDN enabled records can't be updated, we don't know the real values
# never point a Cloudflare record to itself.
wanted = Zone('unit.tests.', [])
wanted.add_record(Record.new(wanted, 'cname', {
'ttl': 300,
'type': 'CNAME',
'value': 'change.unit.tests.cdn.cloudflare.net.'
}))
wanted.add_record(Record.new(wanted, 'new', {
'ttl': 300,
'type': 'CNAME',
'value': 'new.unit.tests.cdn.cloudflare.net.'
}))
wanted.add_record(Record.new(wanted, 'created', {
'ttl': 300,
'type': 'CNAME',
'value': 'www.unit.tests.'
}))
plan = provider.plan(wanted)
self.assertEquals(1, len(plan.changes))
def test_cdn_alias(self):
provider = CloudflareProvider('test', 'email', 'token', True)
# A CNAME for us to transform to ALIAS
provider.zone_records = Mock(return_value=[
{
"id": "fc12ab34cd5611334422ab3322997642",
"type": "CNAME",
"name": "unit.tests",
"content": "www.unit.tests",
"proxiable": True,
"proxied": True,
"ttl": 300,
"locked": False,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.420689Z",
"created_on": "2017-03-11T18:01:43.420689Z",
"meta": {
"auto_added": False
}
},
])
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(1, len(zone.records))
record = list(zone.records)[0]
self.assertEquals('', record.name)
self.assertEquals('unit.tests.', record.fqdn)
self.assertEquals('ALIAS', record._type)
self.assertEquals('unit.tests.cdn.cloudflare.net.', record.value)
# CDN enabled records can't be updated, we don't know the real values
# never point a Cloudflare record to itself.
wanted = Zone('unit.tests.', [])
wanted.add_record(Record.new(wanted, '', {
'ttl': 300,
'type': 'ALIAS',
'value': 'change.unit.tests.cdn.cloudflare.net.'
}))
plan = provider.plan(wanted)
self.assertEquals(False, hasattr(plan, 'changes'))

View File

@@ -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)

View File

@@ -493,7 +493,7 @@ class TestDynProviderGeo(TestCase):
traffic_director_response = loads(fh.read()) traffic_director_response = loads(fh.read())
@property @property
def traffic_directors_reponse(self): def traffic_directors_response(self):
return { return {
'data': [{ 'data': [{
'active': 'Y', 'active': 'Y',
@@ -609,7 +609,7 @@ class TestDynProviderGeo(TestCase):
mock.side_effect = [{'data': []}] mock.side_effect = [{'data': []}]
self.assertEquals({}, provider.traffic_directors) self.assertEquals({}, provider.traffic_directors)
# a supported td and an ingored one # a supported td and an ignored one
response = { response = {
'data': [{ 'data': [{
'active': 'Y', 'active': 'Y',
@@ -652,7 +652,7 @@ class TestDynProviderGeo(TestCase):
set(tds.keys())) set(tds.keys()))
self.assertEquals(['A'], tds['unit.tests.'].keys()) self.assertEquals(['A'], tds['unit.tests.'].keys())
self.assertEquals(['A'], tds['geo.unit.tests.'].keys()) self.assertEquals(['A'], tds['geo.unit.tests.'].keys())
provider.log.warn.assert_called_with("Failed to load TraficDirector " provider.log.warn.assert_called_with("Failed to load TrafficDirector "
"'%s': %s", 'something else', "'%s': %s", 'something else',
'need more than 1 value to ' 'need more than 1 value to '
'unpack') 'unpack')
@@ -760,7 +760,7 @@ class TestDynProviderGeo(TestCase):
# only traffic director # only traffic director
mock.side_effect = [ mock.side_effect = [
# get traffic directors # get traffic directors
self.traffic_directors_reponse, self.traffic_directors_response,
# get traffic director # get traffic director
self.traffic_director_response, self.traffic_director_response,
# get zone # get zone
@@ -811,7 +811,7 @@ class TestDynProviderGeo(TestCase):
# both traffic director and regular, regular is ignored # both traffic director and regular, regular is ignored
mock.side_effect = [ mock.side_effect = [
# get traffic directors # get traffic directors
self.traffic_directors_reponse, self.traffic_directors_response,
# get traffic director # get traffic director
self.traffic_director_response, self.traffic_director_response,
# get zone # get zone
@@ -861,7 +861,7 @@ class TestDynProviderGeo(TestCase):
# busted traffic director # busted traffic director
mock.side_effect = [ mock.side_effect = [
# get traffic directors # get traffic directors
self.traffic_directors_reponse, self.traffic_directors_response,
# get traffic director # get traffic director
busted_traffic_director_response, busted_traffic_director_response,
# get zone # get zone
@@ -934,14 +934,14 @@ class TestDynProviderGeo(TestCase):
provider = DynProvider('test', 'cust', 'user', 'pass', provider = DynProvider('test', 'cust', 'user', 'pass',
traffic_directors_enabled=True) traffic_directors_enabled=True)
# will be tested seperately # will be tested separately
provider._mod_rulesets = MagicMock() provider._mod_rulesets = MagicMock()
mock.side_effect = [ mock.side_effect = [
# create traffic director # create traffic director
self.traffic_director_response, self.traffic_director_response,
# get traffic directors # get traffic directors
self.traffic_directors_reponse self.traffic_directors_response
] ]
provider._mod_geo_Create(None, Create(self.geo_record)) provider._mod_geo_Create(None, Create(self.geo_record))
# td now lives in cache # td now lives in cache

View File

@@ -364,10 +364,10 @@ class TestGoogleCloudProvider(TestCase):
# test_zone gets fed the same records as zone does, except it's in # test_zone gets fed the same records as zone does, except it's in
# the format returned by google API, so after populate they should look # the format returned by google API, so after populate they should look
# excactly the same. # exactly the same.
self.assertEqual(test_zone.records, zone.records) self.assertEqual(test_zone.records, zone.records)
test_zone2 = Zone('nonexistant.zone.', []) test_zone2 = Zone('nonexistent.zone.', [])
exists = provider.populate(test_zone2, False, False) exists = provider.populate(test_zone2, False, False)
self.assertFalse(exists) self.assertFalse(exists)
@@ -405,8 +405,8 @@ class TestGoogleCloudProvider(TestCase):
provider.gcloud_client.list_zones = Mock( provider.gcloud_client.list_zones = Mock(
return_value=DummyIterator([])) return_value=DummyIterator([]))
self.assertIsNone(provider.gcloud_zones.get("nonexistant.xone"), self.assertIsNone(provider.gcloud_zones.get("nonexistent.zone"),
msg="Check that nonexistant zones return None when" msg="Check that nonexistent zones return None when"
"there's no create=True flag") "there's no create=True flag")
def test__get_rrsets(self): def test__get_rrsets(self):
@@ -427,7 +427,22 @@ class TestGoogleCloudProvider(TestCase):
provider.gcloud_client.list_zones = Mock( provider.gcloud_client.list_zones = Mock(
return_value=DummyIterator([])) return_value=DummyIterator([]))
mock_zone = provider._create_gcloud_zone("nonexistant.zone.mock") mock_zone = provider._create_gcloud_zone("nonexistent.zone.mock")
mock_zone.create.assert_called() mock_zone.create.assert_called()
provider.gcloud_client.zone.assert_called() provider.gcloud_client.zone.assert_called()
def test__create_zone_ip6_arpa(self):
def _create_dummy_zone(name, dns_name):
return DummyGoogleCloudZone(name=name, dns_name=dns_name)
provider = self._get_provider()
provider.gcloud_client = Mock()
provider.gcloud_client.zone = Mock(side_effect=_create_dummy_zone)
mock_zone = \
provider._create_gcloud_zone('0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa')
self.assertRegexpMatches(mock_zone.name, '^[a-z][a-z0-9-]*[a-z0-9]$')
self.assertEqual(len(mock_zone.name), 63)

View File

@@ -199,14 +199,14 @@ class TestOvhProvider(TestCase):
api_record.append({ api_record.append({
'fieldType': 'SPF', 'fieldType': 'SPF',
'ttl': 1000, 'ttl': 1000,
'target': 'v=spf1 include:unit.texts.rerirect ~all', 'target': 'v=spf1 include:unit.texts.redirect ~all',
'subDomain': '', 'subDomain': '',
'id': 13 'id': 13
}) })
expected.add(Record.new(zone, '', { expected.add(Record.new(zone, '', {
'ttl': 1000, 'ttl': 1000,
'type': 'SPF', 'type': 'SPF',
'value': 'v=spf1 include:unit.texts.rerirect ~all' 'value': 'v=spf1 include:unit.texts.redirect ~all'
})) }))
# SSHFP # SSHFP
@@ -390,11 +390,11 @@ class TestOvhProvider(TestCase):
call(u'/domain/zone/unit.tests/record', fieldType=u'A', call(u'/domain/zone/unit.tests/record', fieldType=u'A',
subDomain=u'', target=u'1.2.3.4', ttl=100), subDomain=u'', target=u'1.2.3.4', ttl=100),
call(u'/domain/zone/unit.tests/record', fieldType=u'SRV', call(u'/domain/zone/unit.tests/record', fieldType=u'SRV',
subDomain=u'10 20 30 foo-1.unit.tests.', subDomain='_srv._tcp',
target='_srv._tcp', ttl=800), target=u'10 20 30 foo-1.unit.tests.', ttl=800),
call(u'/domain/zone/unit.tests/record', fieldType=u'SRV', call(u'/domain/zone/unit.tests/record', fieldType=u'SRV',
subDomain=u'40 50 60 foo-2.unit.tests.', subDomain='_srv._tcp',
target='_srv._tcp', ttl=800), target=u'40 50 60 foo-2.unit.tests.', ttl=800),
call(u'/domain/zone/unit.tests/record', fieldType=u'PTR', call(u'/domain/zone/unit.tests/record', fieldType=u'PTR',
subDomain='4', target=u'unit.tests.', ttl=900), subDomain='4', target=u'unit.tests.', ttl=900),
call(u'/domain/zone/unit.tests/record', fieldType=u'NS', call(u'/domain/zone/unit.tests/record', fieldType=u'NS',
@@ -402,8 +402,8 @@ class TestOvhProvider(TestCase):
call(u'/domain/zone/unit.tests/record', fieldType=u'NS', call(u'/domain/zone/unit.tests/record', fieldType=u'NS',
subDomain='www3', target=u'ns4.unit.tests.', ttl=700), subDomain='www3', target=u'ns4.unit.tests.', ttl=700),
call(u'/domain/zone/unit.tests/record', call(u'/domain/zone/unit.tests/record',
fieldType=u'SSHFP', target=u'', ttl=1100, fieldType=u'SSHFP', subDomain=u'', ttl=1100,
subDomain=u'1 1 bf6b6825d2977c511a475bbefb88a' target=u'1 1 bf6b6825d2977c511a475bbefb88a'
u'ad54' u'ad54'
u'a92ac73', u'a92ac73',
), ),
@@ -416,7 +416,7 @@ class TestOvhProvider(TestCase):
call(u'/domain/zone/unit.tests/record', fieldType=u'SPF', call(u'/domain/zone/unit.tests/record', fieldType=u'SPF',
subDomain=u'', ttl=1000, subDomain=u'', ttl=1000,
target=u'v=spf1 include:unit.texts.' target=u'v=spf1 include:unit.texts.'
u'rerirect ~all', u'redirect ~all',
), ),
call(u'/domain/zone/unit.tests/record', fieldType=u'A', call(u'/domain/zone/unit.tests/record', fieldType=u'A',
subDomain='sub', target=u'1.2.3.4', ttl=200), subDomain='sub', target=u'1.2.3.4', ttl=200),

View File

@@ -100,7 +100,7 @@ class TestPowerDnsProvider(TestCase):
# No existing records -> creates for every record in expected # No existing records -> creates for every record in expected
with requests_mock() as mock: with requests_mock() as mock:
mock.get(ANY, status_code=200, text=EMPTY_TEXT) mock.get(ANY, status_code=200, text=EMPTY_TEXT)
# post 201, is reponse to the create with data # post 201, is response to the create with data
mock.patch(ANY, status_code=201, text=assert_rrsets_callback) mock.patch(ANY, status_code=201, text=assert_rrsets_callback)
plan = provider.plan(expected) plan = provider.plan(expected)
@@ -119,7 +119,7 @@ class TestPowerDnsProvider(TestCase):
mock.get(ANY, status_code=422, text='') mock.get(ANY, status_code=422, text='')
# patch 422's, unknown zone # patch 422's, unknown zone
mock.patch(ANY, status_code=422, text=dumps(not_found)) mock.patch(ANY, status_code=422, text=dumps(not_found))
# post 201, is reponse to the create with data # post 201, is response to the create with data
mock.post(ANY, status_code=201, text=assert_rrsets_callback) mock.post(ANY, status_code=201, text=assert_rrsets_callback)
plan = provider.plan(expected) plan = provider.plan(expected)

View File

@@ -331,9 +331,9 @@ class TestRoute53Provider(TestCase):
stubber.assert_no_pending_responses() stubber.assert_no_pending_responses()
# Populate a zone that doesn't exist # Populate a zone that doesn't exist
noexist = Zone('does.not.exist.', []) nonexistent = Zone('does.not.exist.', [])
provider.populate(noexist) provider.populate(nonexistent)
self.assertEquals(set(), noexist.records) self.assertEquals(set(), nonexistent.records)
def test_sync(self): def test_sync(self):
provider, stubber = self._get_stubbed_provider() provider, stubber = self._get_stubbed_provider()

View File

@@ -430,7 +430,7 @@ class TestRecord(TestCase):
self.assertEqual(change.new, other) self.assertEqual(change.new, other)
# full sorting # full sorting
# equivilent # equivalent
b_naptr_value = b.values[0] b_naptr_value = b.values[0]
self.assertEquals(0, b_naptr_value.__cmp__(b_naptr_value)) self.assertEquals(0, b_naptr_value.__cmp__(b_naptr_value))
# by order # by order
@@ -710,7 +710,7 @@ class TestRecord(TestCase):
Record.new(self.zone, 'unknown', {}) Record.new(self.zone, 'unknown', {})
self.assertTrue('missing type' in ctx.exception.message) self.assertTrue('missing type' in ctx.exception.message)
# Unkown type # Unknown type
with self.assertRaises(Exception) as ctx: with self.assertRaises(Exception) as ctx:
Record.new(self.zone, 'unknown', { Record.new(self.zone, 'unknown', {
'type': 'XXX', 'type': 'XXX',
@@ -1360,7 +1360,7 @@ class TestRecordValidation(TestCase):
'ttl': 600, 'ttl': 600,
'value': { 'value': {
'algorithm': 'nope', 'algorithm': 'nope',
'fingerprint_type': 1, 'fingerprint_type': 2,
'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73' 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73'
} }
}) })
@@ -1386,7 +1386,7 @@ class TestRecordValidation(TestCase):
'type': 'SSHFP', 'type': 'SSHFP',
'ttl': 600, 'ttl': 600,
'value': { 'value': {
'algorithm': 1, 'algorithm': 2,
'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73' 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73'
} }
}) })
@@ -1398,7 +1398,7 @@ class TestRecordValidation(TestCase):
'type': 'SSHFP', 'type': 'SSHFP',
'ttl': 600, 'ttl': 600,
'value': { 'value': {
'algorithm': 1, 'algorithm': 3,
'fingerprint_type': 'yeeah', 'fingerprint_type': 'yeeah',
'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73' 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73'
} }