.*)$',
+ re.IGNORECASE)
+
+ SUPPORTS_GEO = False
+ SUPPORTS_DYNAMIC = False
+ SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NS',
+ 'SRV', 'SSHFP', 'CAA', 'TXT'))
+ BASE = 'https://dnsapi.mythic-beasts.com/'
+
+ def __init__(self, identifier, passwords, *args, **kwargs):
+ self.log = getLogger('MythicBeastsProvider[{}]'.format(identifier))
+
+ assert isinstance(passwords, dict), 'Passwords must be a dictionary'
+
+ self.log.debug(
+ '__init__: id=%s, registered zones; %s',
+ identifier,
+ passwords.keys())
+ super(MythicBeastsProvider, self).__init__(identifier, *args, **kwargs)
+
+ self._passwords = passwords
+ sess = Session()
+ self._sess = sess
+
+ def _request(self, method, path, data=None):
+ self.log.debug('_request: method=%s, path=%s data=%s',
+ method, path, data)
+
+ resp = self._sess.request(method, path, data=data)
+ self.log.debug(
+ '_request: status=%d data=%s',
+ resp.status_code,
+ resp.text[:20])
+
+ if resp.status_code == 401:
+ raise MythicBeastsUnauthorizedException(data['domain'])
+
+ if resp.status_code == 400:
+ raise MythicBeastsRecordException(
+ data['domain'],
+ data['command']
+ )
+ resp.raise_for_status()
+ return resp
+
+ def _post(self, data=None):
+ return self._request('POST', self.BASE, data=data)
+
+ def records(self, zone):
+ assert zone in self._passwords, 'Missing password for domain: {}' \
+ .format(remove_trailing_dot(zone))
+
+ return self._post({
+ 'domain': remove_trailing_dot(zone),
+ 'password': self._passwords[zone],
+ 'showall': 0,
+ 'command': 'LIST',
+ })
+
+ @staticmethod
+ def _data_for_single(_type, data):
+ return {
+ 'type': _type,
+ 'value': data['raw_values'][0]['value'],
+ 'ttl': data['raw_values'][0]['ttl']
+ }
+
+ @staticmethod
+ def _data_for_multiple(_type, data):
+ return {
+ 'type': _type,
+ 'values':
+ [raw_values['value'] for raw_values in data['raw_values']],
+ 'ttl':
+ max([raw_values['ttl'] for raw_values in data['raw_values']]),
+ }
+
+ @staticmethod
+ def _data_for_TXT(_type, data):
+ return {
+ 'type': _type,
+ 'values':
+ [
+ str(raw_values['value']).replace(';', '\\;')
+ for raw_values in data['raw_values']
+ ],
+ 'ttl':
+ max([raw_values['ttl'] for raw_values in data['raw_values']]),
+ }
+
+ @staticmethod
+ def _data_for_MX(_type, data):
+ ttl = max([raw_values['ttl'] for raw_values in data['raw_values']])
+ values = []
+
+ for raw_value in \
+ [raw_values['value'] for raw_values in data['raw_values']]:
+ match = MythicBeastsProvider.RE_MX.match(raw_value)
+
+ assert match is not None, 'Unable to parse MX data'
+
+ exchange = match.group('exchange')
+
+ if not exchange.endswith('.'):
+ exchange = '{}.{}'.format(exchange, data['zone'])
+
+ values.append({
+ 'preference': match.group('preference'),
+ 'exchange': exchange,
+ })
+
+ return {
+ 'type': _type,
+ 'values': values,
+ 'ttl': ttl,
+ }
+
+ @staticmethod
+ def _data_for_CNAME(_type, data):
+ ttl = data['raw_values'][0]['ttl']
+ value = data['raw_values'][0]['value']
+ if not value.endswith('.'):
+ value = '{}.{}'.format(value, data['zone'])
+
+ return MythicBeastsProvider._data_for_single(
+ _type,
+ {'raw_values': [
+ {'value': value, 'ttl': ttl}
+ ]})
+
+ @staticmethod
+ def _data_for_ANAME(_type, data):
+ ttl = data['raw_values'][0]['ttl']
+ value = data['raw_values'][0]['value']
+ return MythicBeastsProvider._data_for_single(
+ 'ALIAS',
+ {'raw_values': [
+ {'value': value, 'ttl': ttl}
+ ]})
+
+ @staticmethod
+ def _data_for_SRV(_type, data):
+ ttl = max([raw_values['ttl'] for raw_values in data['raw_values']])
+ values = []
+
+ for raw_value in \
+ [raw_values['value'] for raw_values in data['raw_values']]:
+
+ match = MythicBeastsProvider.RE_SRV.match(raw_value)
+
+ assert match is not None, 'Unable to parse SRV data'
+
+ target = match.group('target')
+ if not target.endswith('.'):
+ target = '{}.{}'.format(target, data['zone'])
+
+ values.append({
+ 'priority': match.group('priority'),
+ 'weight': match.group('weight'),
+ 'port': match.group('port'),
+ 'target': target,
+ })
+
+ return {
+ 'type': _type,
+ 'values': values,
+ 'ttl': ttl,
+ }
+
+ @staticmethod
+ def _data_for_SSHFP(_type, data):
+ ttl = max([raw_values['ttl'] for raw_values in data['raw_values']])
+ values = []
+
+ for raw_value in \
+ [raw_values['value'] for raw_values in data['raw_values']]:
+ match = MythicBeastsProvider.RE_SSHFP.match(raw_value)
+
+ assert match is not None, 'Unable to parse SSHFP data'
+
+ values.append({
+ 'algorithm': match.group('algorithm'),
+ 'fingerprint_type': match.group('fingerprint_type'),
+ 'fingerprint': match.group('fingerprint'),
+ })
+
+ return {
+ 'type': _type,
+ 'values': values,
+ 'ttl': ttl,
+ }
+
+ @staticmethod
+ def _data_for_CAA(_type, data):
+ ttl = data['raw_values'][0]['ttl']
+ raw_value = data['raw_values'][0]['value']
+
+ match = MythicBeastsProvider.RE_CAA.match(raw_value)
+
+ assert match is not None, 'Unable to parse CAA data'
+
+ value = {
+ 'flags': match.group('flags'),
+ 'tag': match.group('tag'),
+ 'value': match.group('value'),
+ }
+
+ return MythicBeastsProvider._data_for_single(
+ 'CAA',
+ {'raw_values': [{'value': value, 'ttl': ttl}]})
+
+ _data_for_NS = _data_for_multiple
+ _data_for_A = _data_for_multiple
+ _data_for_AAAA = _data_for_multiple
+
+ def populate(self, zone, target=False, lenient=False):
+ self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
+ target, lenient)
+
+ resp = self.records(zone.name)
+
+ before = len(zone.records)
+ exists = False
+ data = defaultdict(lambda: defaultdict(lambda: {
+ 'raw_values': [],
+ 'name': None,
+ 'zone': None,
+ }))
+
+ exists = True
+ for line in resp.content.splitlines():
+ match = MythicBeastsProvider.RE_POPLINE.match(line.decode("utf-8"))
+
+ if match is None:
+ self.log.debug('failed to match line: %s', line)
+ continue
+
+ if match.group(1) == '@':
+ _name = ''
+ else:
+ _name = match.group('name')
+
+ _type = match.group('type')
+ _ttl = int(match.group('ttl'))
+ _value = match.group('value').strip()
+
+ if hasattr(self, '_data_for_{}'.format(_type)):
+ if _name not in data[_type]:
+ data[_type][_name] = {
+ 'raw_values': [{'value': _value, 'ttl': _ttl}],
+ 'name': _name,
+ 'zone': zone.name,
+ }
+
+ else:
+ data[_type][_name].get('raw_values').append(
+ {'value': _value, 'ttl': _ttl}
+ )
+ else:
+ self.log.debug('skipping %s as not supported', _type)
+
+ for _type in data:
+ for _name in data[_type]:
+ data_for = getattr(self, '_data_for_{}'.format(_type))
+
+ record = Record.new(
+ zone,
+ _name,
+ data_for(_type, data[_type][_name]),
+ source=self
+ )
+ zone.add_record(record, lenient=lenient)
+
+ self.log.debug('populate: found %s records, exists=%s',
+ len(zone.records) - before, exists)
+
+ return exists
+
+ def _compile_commands(self, action, record):
+ commands = []
+
+ hostname = remove_trailing_dot(record.fqdn)
+ ttl = record.ttl
+ _type = record._type
+
+ if _type == 'ALIAS':
+ _type = 'ANAME'
+
+ if hasattr(record, 'values'):
+ values = record.values
+ else:
+ values = [record.value]
+
+ base = '{} {} {} {}'.format(action, hostname, ttl, _type)
+
+ # Unescape TXT records
+ if _type == 'TXT':
+ values = [value.replace('\\;', ';') for value in values]
+
+ # Handle specific types or default
+ if _type == 'SSHFP':
+ data = values[0].data
+ commands.append('{} {} {} {}'.format(
+ base,
+ data['algorithm'],
+ data['fingerprint_type'],
+ data['fingerprint']
+ ))
+
+ elif _type == 'SRV':
+ for value in values:
+ data = value.data
+ commands.append('{} {} {} {} {}'.format(
+ base,
+ data['priority'],
+ data['weight'],
+ data['port'],
+ data['target']))
+
+ elif _type == 'MX':
+ for value in values:
+ data = value.data
+ commands.append('{} {} {}'.format(
+ base,
+ data['preference'],
+ data['exchange']))
+
+ else:
+ if hasattr(self, '_data_for_{}'.format(_type)):
+ for value in values:
+ commands.append('{} {}'.format(base, value))
+ else:
+ self.log.debug('skipping %s as not supported', _type)
+
+ return commands
+
+ def _apply_Create(self, change):
+ zone = change.new.zone
+ commands = self._compile_commands('ADD', change.new)
+
+ for command in commands:
+ self._post({
+ 'domain': remove_trailing_dot(zone.name),
+ 'origin': '.',
+ 'password': self._passwords[zone.name],
+ 'command': command,
+ })
+ return True
+
+ def _apply_Update(self, change):
+ self._apply_Delete(change)
+ self._apply_Create(change)
+
+ def _apply_Delete(self, change):
+ zone = change.existing.zone
+ commands = self._compile_commands('DELETE', change.existing)
+
+ for command in commands:
+ self._post({
+ 'domain': remove_trailing_dot(zone.name),
+ 'origin': '.',
+ 'password': self._passwords[zone.name],
+ 'command': command,
+ })
+ return True
+
+ def _apply(self, plan):
+ desired = plan.desired
+ changes = plan.changes
+ self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
+ len(changes))
+
+ for change in changes:
+ class_name = change.__class__.__name__
+ getattr(self, '_apply_{}'.format(class_name))(change)
diff --git a/octodns/provider/ns1.py b/octodns/provider/ns1.py
index 5fdf5b0..0383dbf 100644
--- a/octodns/provider/ns1.py
+++ b/octodns/provider/ns1.py
@@ -8,20 +8,66 @@ from __future__ import absolute_import, division, print_function, \
from logging import getLogger
from itertools import chain
from collections import OrderedDict, defaultdict
-from nsone import NSONE
-from nsone.rest.errors import RateLimitException, ResourceException
-from incf.countryutils import transformations
+from ns1 import NS1
+from ns1.rest.errors import RateLimitException, ResourceException
+from pycountry_convert import country_alpha2_to_continent_code
from time import sleep
+from six import text_type
+
from ..record import Record
from .base import BaseProvider
+class Ns1Client(object):
+ log = getLogger('NS1Client')
+
+ def __init__(self, api_key, retry_count=4):
+ self.retry_count = retry_count
+
+ client = NS1(apiKey=api_key)
+ self._records = client.records()
+ self._zones = client.zones()
+
+ def _try(self, method, *args, **kwargs):
+ tries = self.retry_count
+ while True: # We'll raise to break after our tries expire
+ try:
+ return method(*args, **kwargs)
+ except RateLimitException as e:
+ if tries <= 1:
+ raise
+ period = float(e.period)
+ self.log.warn('rate limit encountered, pausing '
+ 'for %ds and trying again, %d remaining',
+ period, tries)
+ sleep(period)
+ tries -= 1
+
+ def zones_retrieve(self, name):
+ return self._try(self._zones.retrieve, name)
+
+ def zones_create(self, name):
+ return self._try(self._zones.create, name)
+
+ def records_retrieve(self, zone, domain, _type):
+ return self._try(self._records.retrieve, zone, domain, _type)
+
+ def records_create(self, zone, domain, _type, **params):
+ return self._try(self._records.create, zone, domain, _type, **params)
+
+ def records_update(self, zone, domain, _type, **params):
+ return self._try(self._records.update, zone, domain, _type, **params)
+
+ def records_delete(self, zone, domain, _type):
+ return self._try(self._records.delete, zone, domain, _type)
+
+
class Ns1Provider(BaseProvider):
'''
Ns1 provider
- nsone:
+ ns1:
class: octodns.provider.ns1.Ns1Provider
api_key: env/NS1_API_KEY
'''
@@ -32,11 +78,12 @@ class Ns1Provider(BaseProvider):
ZONE_NOT_FOUND_MESSAGE = 'server error: zone not found'
- def __init__(self, id, api_key, *args, **kwargs):
+ def __init__(self, id, api_key, retry_count=4, *args, **kwargs):
self.log = getLogger('Ns1Provider[{}]'.format(id))
- self.log.debug('__init__: id=%s, api_key=***', id)
+ self.log.debug('__init__: id=%s, api_key=***, retry_count=%d', id,
+ retry_count)
super(Ns1Provider, self).__init__(id, *args, **kwargs)
- self._client = NSONE(apiKey=api_key)
+ self._client = Ns1Client(api_key, retry_count)
def _data_for_A(self, _type, record):
# record meta (which would include geo information is only
@@ -60,8 +107,7 @@ class Ns1Provider(BaseProvider):
us_state = meta.get('us_state', [])
ca_province = meta.get('ca_province', [])
for cntry in country:
- cn = transformations.cc_to_cn(cntry)
- con = transformations.cn_to_ctca2(cn)
+ con = country_alpha2_to_continent_code(cntry)
key = '{}-{}'.format(con, cntry)
geo[key].extend(answer['answer'])
for state in us_state:
@@ -76,9 +122,9 @@ class Ns1Provider(BaseProvider):
else:
values.extend(answer['answer'])
codes.append([])
- values = [unicode(x) for x in values]
+ values = [text_type(x) for x in values]
geo = OrderedDict(
- {unicode(k): [unicode(x) for x in v] for k, v in geo.items()}
+ {text_type(k): [text_type(x) for x in v] for k, v in geo.items()}
)
data['values'] = values
data['geo'] = geo
@@ -188,18 +234,29 @@ class Ns1Provider(BaseProvider):
target, lenient)
try:
- nsone_zone = self._client.loadZone(zone.name[:-1])
- records = nsone_zone.data['records']
+ ns1_zone_name = zone.name[:-1]
+ ns1_zone = self._client.zones_retrieve(ns1_zone_name)
+
+ records = []
+ geo_records = []
# change answers for certain types to always be absolute
- for record in records:
+ for record in ns1_zone['records']:
if record['type'] in ['ALIAS', 'CNAME', 'MX', 'NS', 'PTR',
'SRV']:
for i, a in enumerate(record['short_answers']):
if not a.endswith('.'):
record['short_answers'][i] = '{}.'.format(a)
- geo_records = nsone_zone.search(has_geo=True)
+ if record.get('tier', 1) > 1:
+ # Need to get the full record data for geo records
+ record = self._client.records_retrieve(ns1_zone_name,
+ record['domain'],
+ record['type'])
+ geo_records.append(record)
+ else:
+ records.append(record)
+
exists = True
except ResourceException as e:
if e.message != self.ZONE_NOT_FOUND_MESSAGE:
@@ -298,53 +355,28 @@ class Ns1Provider(BaseProvider):
for v in record.values]
return {'answers': values, 'ttl': record.ttl}
- def _get_name(self, record):
- return record.fqdn[:-1] if record.name == '' else record.name
-
- def _apply_Create(self, nsone_zone, change):
+ def _apply_Create(self, ns1_zone, change):
new = change.new
- name = self._get_name(new)
+ zone = new.zone.name[:-1]
+ domain = new.fqdn[:-1]
_type = new._type
params = getattr(self, '_params_for_{}'.format(_type))(new)
- meth = getattr(nsone_zone, 'add_{}'.format(_type))
- try:
- meth(name, **params)
- except RateLimitException as e:
- period = float(e.period)
- self.log.warn('_apply_Create: rate limit encountered, pausing '
- 'for %ds and trying again', period)
- sleep(period)
- meth(name, **params)
+ self._client.records_create(zone, domain, _type, **params)
- def _apply_Update(self, nsone_zone, change):
- existing = change.existing
- name = self._get_name(existing)
- _type = existing._type
- record = nsone_zone.loadRecord(name, _type)
+ def _apply_Update(self, ns1_zone, change):
new = change.new
+ zone = new.zone.name[:-1]
+ domain = new.fqdn[:-1]
+ _type = new._type
params = getattr(self, '_params_for_{}'.format(_type))(new)
- try:
- record.update(**params)
- except RateLimitException as e:
- period = float(e.period)
- self.log.warn('_apply_Update: rate limit encountered, pausing '
- 'for %ds and trying again', period)
- sleep(period)
- record.update(**params)
+ self._client.records_update(zone, domain, _type, **params)
- def _apply_Delete(self, nsone_zone, change):
+ def _apply_Delete(self, ns1_zone, change):
existing = change.existing
- name = self._get_name(existing)
+ zone = existing.zone.name[:-1]
+ domain = existing.fqdn[:-1]
_type = existing._type
- record = nsone_zone.loadRecord(name, _type)
- try:
- record.delete()
- except RateLimitException as e:
- period = float(e.period)
- self.log.warn('_apply_Delete: rate limit encountered, pausing '
- 'for %ds and trying again', period)
- sleep(period)
- record.delete()
+ self._client.records_delete(zone, domain, _type)
def _apply(self, plan):
desired = plan.desired
@@ -354,14 +386,14 @@ class Ns1Provider(BaseProvider):
domain_name = desired.name[:-1]
try:
- nsone_zone = self._client.loadZone(domain_name)
+ ns1_zone = self._client.zones_retrieve(domain_name)
except ResourceException as e:
if e.message != self.ZONE_NOT_FOUND_MESSAGE:
raise
self.log.debug('_apply: no matching zone, creating')
- nsone_zone = self._client.createZone(domain_name)
+ ns1_zone = self._client.zones_create(domain_name)
for change in changes:
class_name = change.__class__.__name__
- getattr(self, '_apply_{}'.format(class_name))(nsone_zone,
+ getattr(self, '_apply_{}'.format(class_name))(ns1_zone,
change)
diff --git a/octodns/provider/ovh.py b/octodns/provider/ovh.py
index d968da4..17aff8d 100644
--- a/octodns/provider/ovh.py
+++ b/octodns/provider/ovh.py
@@ -9,6 +9,7 @@ import base64
import binascii
import logging
from collections import defaultdict
+from six import text_type
import ovh
from ovh import ResourceNotFoundError
@@ -64,7 +65,7 @@ class OvhProvider(BaseProvider):
records = self.get_records(zone_name=zone_name)
exists = True
except ResourceNotFoundError as e:
- if e.message != self.ZONE_NOT_FOUND_MESSAGE:
+ if text_type(e) != self.ZONE_NOT_FOUND_MESSAGE:
raise
exists = False
records = []
@@ -325,7 +326,7 @@ class OvhProvider(BaseProvider):
splitted = value.split('\\;')
found_key = False
for splitted_value in splitted:
- sub_split = map(lambda x: x.strip(), splitted_value.split("=", 1))
+ sub_split = [x.strip() for x in splitted_value.split("=", 1)]
if len(sub_split) < 2:
return False
key, value = sub_split[0], sub_split[1]
@@ -343,7 +344,7 @@ class OvhProvider(BaseProvider):
@staticmethod
def _is_valid_dkim_key(key):
try:
- base64.decodestring(key)
+ base64.decodestring(bytearray(key, 'utf-8'))
except binascii.Error:
return False
return True
diff --git a/octodns/provider/plan.py b/octodns/provider/plan.py
index bae244f..af6863a 100644
--- a/octodns/provider/plan.py
+++ b/octodns/provider/plan.py
@@ -5,10 +5,11 @@
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
+from six import StringIO, text_type
+
class UnsafePlan(Exception):
pass
@@ -26,7 +27,11 @@ class Plan(object):
delete_pcent_threshold=MAX_SAFE_DELETE_PCENT):
self.existing = existing
self.desired = desired
- self.changes = changes
+ # Sort changes to ensure we always have a consistent ordering for
+ # things that make assumptions about that. Many providers will do their
+ # own ordering to ensure things happen in a way that makes sense to
+ # them and/or is as safe as possible.
+ self.changes = sorted(changes)
self.exists = exists
self.update_pcent_threshold = update_pcent_threshold
self.delete_pcent_threshold = delete_pcent_threshold
@@ -122,7 +127,7 @@ class PlanLogger(_PlanOutput):
buf.write('* ')
buf.write(target.id)
buf.write(' (')
- buf.write(target)
+ buf.write(text_type(target))
buf.write(')\n* ')
if plan.exists is False:
@@ -135,7 +140,7 @@ class PlanLogger(_PlanOutput):
buf.write('\n* ')
buf.write('Summary: ')
- buf.write(plan)
+ buf.write(text_type(plan))
buf.write('\n')
else:
buf.write(hr)
@@ -147,11 +152,11 @@ class PlanLogger(_PlanOutput):
def _value_stringifier(record, sep):
try:
- values = [unicode(v) for v in record.values]
+ values = [text_type(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])
+ vs = ', '.join([text_type(v) for v in gv.values])
values.append('{}: {}'.format(code, vs))
return sep.join(values)
@@ -193,7 +198,7 @@ class PlanMarkdown(_PlanOutput):
fh.write(' | ')
# TTL
if existing:
- fh.write(unicode(existing.ttl))
+ fh.write(text_type(existing.ttl))
fh.write(' | ')
fh.write(_value_stringifier(existing, '; '))
fh.write(' | |\n')
@@ -201,7 +206,7 @@ class PlanMarkdown(_PlanOutput):
fh.write('| | | | ')
if new:
- fh.write(unicode(new.ttl))
+ fh.write(text_type(new.ttl))
fh.write(' | ')
fh.write(_value_stringifier(new, '; '))
fh.write(' | ')
@@ -210,7 +215,7 @@ class PlanMarkdown(_PlanOutput):
fh.write(' |\n')
fh.write('\nSummary: ')
- fh.write(unicode(plan))
+ fh.write(text_type(plan))
fh.write('\n\n')
else:
fh.write('## No changes were planned\n')
@@ -261,7 +266,7 @@ class PlanHtml(_PlanOutput):
# TTL
if existing:
fh.write(' | ')
- fh.write(unicode(existing.ttl))
+ fh.write(text_type(existing.ttl))
fh.write(' | \n ')
fh.write(_value_stringifier(existing, ' '))
fh.write(' | \n | \n \n')
@@ -270,7 +275,7 @@ class PlanHtml(_PlanOutput):
if new:
fh.write(' ')
- fh.write(unicode(new.ttl))
+ fh.write(text_type(new.ttl))
fh.write(' | \n ')
fh.write(_value_stringifier(new, ' '))
fh.write(' | \n ')
@@ -279,7 +284,7 @@ class PlanHtml(_PlanOutput):
fh.write(' | \n \n')
fh.write(' \n | Summary: ')
- fh.write(unicode(plan))
+ fh.write(text_type(plan))
fh.write(' | \n
\n\n')
else:
fh.write('No changes were planned')
diff --git a/octodns/provider/rackspace.py b/octodns/provider/rackspace.py
index 5038929..7fed05b 100644
--- a/octodns/provider/rackspace.py
+++ b/octodns/provider/rackspace.py
@@ -7,13 +7,16 @@ from __future__ import absolute_import, division, print_function, \
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 _value_keyer(v):
+ return (v.get('type', ''), v['name'], v.get('data', ''))
+
+
def add_trailing_dot(s):
assert s
assert s[-1] != '.'
@@ -28,12 +31,12 @@ def remove_trailing_dot(s):
def escape_semicolon(s):
assert s
- return string.replace(s, ';', '\\;')
+ return s.replace(';', '\\;')
def unescape_semicolon(s):
assert s
- return string.replace(s, '\\;', ';')
+ return s.replace('\\;', ';')
class RackspaceProvider(BaseProvider):
@@ -367,11 +370,9 @@ class RackspaceProvider(BaseProvider):
self._delete('domains/{}/records?{}'.format(domain_id, params))
if updates:
- data = {"records": sorted(updates, key=lambda v: v['name'])}
+ data = {"records": sorted(updates, key=_value_keyer)}
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', ''))}
+ data = {"records": sorted(creates, key=_value_keyer)}
self._post('domains/{}/records'.format(domain_id), data=data)
diff --git a/octodns/provider/route53.py b/octodns/provider/route53.py
index f5185b0..66da6b5 100644
--- a/octodns/provider/route53.py
+++ b/octodns/provider/route53.py
@@ -8,17 +8,19 @@ from __future__ import absolute_import, division, print_function, \
from boto3 import client
from botocore.config import Config
from collections import defaultdict
-from incf.countryutils.transformations import cca_to_ctca2
from ipaddress import AddressValueError, ip_address
+from pycountry_convert import country_alpha2_to_continent_code
from uuid import uuid4
import logging
import re
+from six import text_type
+
+from ..equality import EqualityTupleMixin
from ..record import Record, Update
from ..record.geo import GeoCodes
from .base import BaseProvider
-
octal_re = re.compile(r'\\(\d\d\d)')
@@ -28,7 +30,7 @@ def _octal_replace(s):
return octal_re.sub(lambda m: chr(int(m.group(1), 8)), s)
-class _Route53Record(object):
+class _Route53Record(EqualityTupleMixin):
@classmethod
def _new_dynamic(cls, provider, record, hosted_zone_id, creating):
@@ -136,7 +138,7 @@ class _Route53Record(object):
values_for = getattr(self, '_values_for_{}'.format(self._type))
self.values = values_for(record)
- def mod(self, action):
+ def mod(self, action, existing_rrsets):
return {
'Action': action,
'ResourceRecordSet': {
@@ -147,7 +149,7 @@ class _Route53Record(object):
}
}
- # NOTE: we're using __hash__ and __cmp__ methods that consider
+ # NOTE: we're using __hash__ and ordering methods that consider
# _Route53Records equivalent if they have the same class, fqdn, and _type.
# Values are ignored. This is useful when computing diffs/changes.
@@ -155,17 +157,10 @@ class _Route53Record(object):
'sub-classes should never use this method'
return '{}:{}'.format(self.fqdn, self._type).__hash__()
- def __cmp__(self, other):
- '''sub-classes should call up to this and return its value if non-zero.
- When it's zero they should compute their own __cmp__'''
- if self.__class__ != other.__class__:
- return cmp(self.__class__, other.__class__)
- elif self.fqdn != other.fqdn:
- return cmp(self.fqdn, other.fqdn)
- elif self._type != other._type:
- return cmp(self._type, other._type)
- # We're ignoring ttl, it's not an actual differentiator
- return 0
+ def _equality_tuple(self):
+ '''Sub-classes should call up to this and return its value and add
+ any additional fields they need to hav considered.'''
+ return (self.__class__.__name__, self.fqdn, self._type)
def __repr__(self):
return '_Route53Record<{} {} {} {}>'.format(self.fqdn, self._type,
@@ -268,7 +263,7 @@ class _Route53DynamicPool(_Route53Record):
self.target_name)
return '{}-{}'.format(self.pool_name, self.mode)
- def mod(self, action):
+ def mod(self, action, existing_rrsets):
return {
'Action': action,
'ResourceRecordSet': {
@@ -311,7 +306,7 @@ class _Route53DynamicRule(_Route53Record):
def identifer(self):
return '{}-{}-{}'.format(self.index, self.pool_name, self.geo)
- def mod(self, action):
+ def mod(self, action, existing_rrsets):
rrset = {
'AliasTarget': {
'DNSName': self.target_dns_name,
@@ -379,7 +374,21 @@ class _Route53DynamicValue(_Route53Record):
def identifer(self):
return '{}-{:03d}'.format(self.pool_name, self.index)
- def mod(self, action):
+ def mod(self, action, existing_rrsets):
+
+ if action == 'DELETE':
+ # When deleting records try and find the original rrset so that
+ # we're 100% sure to have the complete & accurate data (this mostly
+ # ensures we have the right health check id when there's multiple
+ # potential matches)
+ for existing in existing_rrsets:
+ if self.fqdn == existing.get('Name') and \
+ self.identifer == existing.get('SetIdentifier', None):
+ return {
+ 'Action': action,
+ 'ResourceRecordSet': existing,
+ }
+
return {
'Action': action,
'ResourceRecordSet': {
@@ -404,7 +413,7 @@ class _Route53DynamicValue(_Route53Record):
class _Route53GeoDefault(_Route53Record):
- def mod(self, action):
+ def mod(self, action, existing_rrsets):
return {
'Action': action,
'ResourceRecordSet': {
@@ -437,15 +446,31 @@ class _Route53GeoRecord(_Route53Record):
self.health_check_id = provider.get_health_check_id(record, value,
creating)
- def mod(self, action):
+ def mod(self, action, existing_rrsets):
geo = self.geo
+ set_identifier = geo.code
+ fqdn = self.fqdn
+
+ if action == 'DELETE':
+ # When deleting records try and find the original rrset so that
+ # we're 100% sure to have the complete & accurate data (this mostly
+ # ensures we have the right health check id when there's multiple
+ # potential matches)
+ for existing in existing_rrsets:
+ if fqdn == existing.get('Name') and \
+ set_identifier == existing.get('SetIdentifier', None):
+ return {
+ 'Action': action,
+ 'ResourceRecordSet': existing,
+ }
+
rrset = {
'Name': self.fqdn,
'GeoLocation': {
'CountryCode': '*'
},
'ResourceRecords': [{'Value': v} for v in geo.values],
- 'SetIdentifier': geo.code,
+ 'SetIdentifier': set_identifier,
'TTL': self.ttl,
'Type': self._type,
}
@@ -476,11 +501,9 @@ class _Route53GeoRecord(_Route53Record):
return '{}:{}:{}'.format(self.fqdn, self._type,
self.geo.code).__hash__()
- def __cmp__(self, other):
- ret = super(_Route53GeoRecord, self).__cmp__(other)
- if ret != 0:
- return ret
- return cmp(self.geo.code, other.geo.code)
+ def _equality_tuple(self):
+ return super(_Route53GeoRecord, self)._equality_tuple() + \
+ (self.geo.code,)
def __repr__(self):
return '_Route53GeoRecord<{} {} {} {} {}>'.format(self.fqdn,
@@ -489,37 +512,74 @@ class _Route53GeoRecord(_Route53Record):
self.values)
-_mod_keyer_action_order = {
- 'DELETE': 0, # Delete things first
- 'CREATE': 1, # Then Create things
- 'UPSERT': 2, # Upsert things last
-}
-
-
def _mod_keyer(mod):
rrset = mod['ResourceRecordSet']
- action_order = _mod_keyer_action_order[mod['Action']]
- # We're sorting by 3 "columns", the action, the rrset type, and finally the
- # name/id of the rrset. This ensures that Route53 won't see a RRSet that
- # targets another that hasn't been seen yet. I.e. targets must come before
- # things that target them. We sort on types of things rather than
- # explicitly looking for targeting relationships since that's sufficent and
- # easier to grok/do.
+ # Route53 requires that changes are ordered such that a target of an
+ # AliasTarget is created or upserted prior to the record that targets it.
+ # This is complicated by "UPSERT" appearing to be implemented as "DELETE"
+ # before all changes, followed by a "CREATE", internally in the AWS API.
+ # Because of this, we order changes as follows:
+ # - Delete any records that we wish to delete that are GEOS
+ # (because they are never targeted by anything)
+ # - Delete any records that we wish to delete that are SECONDARY
+ # (because they are no longer targeted by GEOS)
+ # - Delete any records that we wish to delete that are PRIMARY
+ # (because they are no longer targeted by SECONDARY)
+ # - Delete any records that we wish to delete that are VALUES
+ # (because they are no longer targeted by PRIMARY)
+ # - CREATE/UPSERT any records that are VALUES
+ # (because they don't depend on other records)
+ # - CREATE/UPSERT any records that are PRIMARY
+ # (because they always point to VALUES which now exist)
+ # - CREATE/UPSERT any records that are SECONDARY
+ # (because they now have PRIMARY records to target)
+ # - CREATE/UPSERT any records that are GEOS
+ # (because they now have all their PRIMARY pools to target)
+ # - :tada:
+ #
+ # In theory we could also do this based on actual target reference
+ # checking, but that's more complex. Since our rules have a known
+ # dependency order, we just rely on that.
+ # Get the unique ID from the name/id to get a consistent ordering.
if rrset.get('GeoLocation', False):
- return (action_order, 3, rrset['SetIdentifier'])
+ unique_id = rrset['SetIdentifier']
+ else:
+ if 'SetIdentifier' in rrset:
+ unique_id = '{}-{}'.format(rrset['Name'], rrset['SetIdentifier'])
+ else:
+ unique_id = rrset['Name']
+
+ # Prioritise within the action_priority, ensuring targets come first.
+ if rrset.get('GeoLocation', False):
+ # Geos reference pools, so they come last.
+ record_priority = 3
elif rrset.get('AliasTarget', False):
# We use an alias
if rrset.get('Failover', False) == 'SECONDARY':
- # We're a secondary we'll ref primaries
- return (action_order, 2, rrset['Name'])
+ # We're a secondary, which reference the primary (failover, P1).
+ record_priority = 2
else:
- # We're a primary we'll ref values
- return (action_order, 1, rrset['Name'])
+ # We're a primary, we reference values (P0).
+ record_priority = 1
+ else:
+ # We're just a plain value, has no dependencies so first.
+ record_priority = 0
- # We're just a plain value, these come first
- return (action_order, 0, rrset['Name'])
+ if mod['Action'] == 'DELETE':
+ # Delete things first, so we can never trounce our own additions
+ action_priority = 0
+ # Delete in the reverse order of priority, e.g. start with the deepest
+ # reference and work back to the values, rather than starting at the
+ # values (still ref'd).
+ record_priority = -record_priority
+ else:
+ # For CREATE and UPSERT, Route53 seems to treat them the same, so
+ # interleave these, keeping the reference order described above.
+ action_priority = 1
+
+ return (action_priority, record_priority, unique_id)
def _parse_pool_name(n):
@@ -636,7 +696,7 @@ class Route53Provider(BaseProvider):
if cc == '*':
# This is the default
return
- cn = cca_to_ctca2(cc)
+ cn = country_alpha2_to_continent_code(cc)
try:
return '{}-{}-{}'.format(cn, cc, loc['SubdivisionCode'])
except KeyError:
@@ -659,7 +719,7 @@ class Route53Provider(BaseProvider):
def _data_for_CAA(self, rrset):
values = []
for rr in rrset['ResourceRecords']:
- flags, tag, value = rr['Value'].split(' ')
+ flags, tag, value = rr['Value'].split()
values.append({
'flags': flags,
'tag': tag,
@@ -697,7 +757,7 @@ class Route53Provider(BaseProvider):
def _data_for_MX(self, rrset):
values = []
for rr in rrset['ResourceRecords']:
- preference, exchange = rr['Value'].split(' ')
+ preference, exchange = rr['Value'].split()
values.append({
'preference': preference,
'exchange': exchange,
@@ -712,7 +772,7 @@ class Route53Provider(BaseProvider):
values = []
for rr in rrset['ResourceRecords']:
order, preference, flags, service, regexp, replacement = \
- rr['Value'].split(' ')
+ rr['Value'].split()
flags = flags[1:-1]
service = service[1:-1]
regexp = regexp[1:-1]
@@ -740,7 +800,7 @@ class Route53Provider(BaseProvider):
def _data_for_SRV(self, rrset):
values = []
for rr in rrset['ResourceRecords']:
- priority, weight, port, target = rr['Value'].split(' ')
+ priority, weight, port, target = rr['Value'].split()
values.append({
'priority': priority,
'weight': weight,
@@ -927,11 +987,11 @@ class Route53Provider(BaseProvider):
len(zone.records) - before, exists)
return exists
- def _gen_mods(self, action, records):
+ def _gen_mods(self, action, records, existing_rrsets):
'''
Turns `_Route53*`s in to `change_resource_record_sets` `Changes`
'''
- return [r.mod(action) for r in records]
+ return [r.mod(action, existing_rrsets) for r in records]
@property
def health_checks(self):
@@ -964,7 +1024,7 @@ class Route53Provider(BaseProvider):
.get('healthcheck', {}) \
.get('measure_latency', True)
- def _health_check_equivilent(self, host, path, protocol, port,
+ def _health_check_equivalent(self, host, path, protocol, port,
measure_latency, health_check, value=None):
config = health_check['HealthCheckConfig']
@@ -973,8 +1033,8 @@ class Route53Provider(BaseProvider):
# ip_address's returned object for equivalence
# E.g 2001:4860:4860::8842 -> 2001:4860:4860:0:0:0:0:8842
if value:
- value = ip_address(unicode(value))
- config_ip_address = ip_address(unicode(config['IPAddress']))
+ value = ip_address(text_type(value))
+ config_ip_address = ip_address(text_type(config['IPAddress']))
else:
# No value so give this a None to match value's
config_ip_address = None
@@ -995,7 +1055,7 @@ class Route53Provider(BaseProvider):
fqdn, record._type, value)
try:
- ip_address(unicode(value))
+ ip_address(text_type(value))
# We're working with an IP, host is the Host header
healthcheck_host = record.healthcheck_host
except (AddressValueError, ValueError):
@@ -1016,7 +1076,7 @@ class Route53Provider(BaseProvider):
if not health_check['CallerReference'].startswith(expected_ref):
# not match, ignore
continue
- if self._health_check_equivilent(healthcheck_host,
+ if self._health_check_equivalent(healthcheck_host,
healthcheck_path,
healthcheck_protocol,
healthcheck_port,
@@ -1117,15 +1177,15 @@ class Route53Provider(BaseProvider):
'''
return _Route53Record.new(self, record, zone_id, creating)
- def _mod_Create(self, change, zone_id):
+ def _mod_Create(self, change, zone_id, existing_rrsets):
# New is the stuff that needs to be created
new_records = self._gen_records(change.new, zone_id, creating=True)
# Now is a good time to clear out any unused health checks since we
# know what we'll be using going forward
self._gc_health_checks(change.new, new_records)
- return self._gen_mods('CREATE', new_records)
+ return self._gen_mods('CREATE', new_records, existing_rrsets)
- def _mod_Update(self, change, zone_id):
+ def _mod_Update(self, change, zone_id, existing_rrsets):
# See comments in _Route53Record for how the set math is made to do our
# bidding here.
existing_records = self._gen_records(change.existing, zone_id,
@@ -1148,18 +1208,18 @@ class Route53Provider(BaseProvider):
if new_record in existing_records:
upserts.add(new_record)
- return self._gen_mods('DELETE', deletes) + \
- self._gen_mods('CREATE', creates) + \
- self._gen_mods('UPSERT', upserts)
+ return self._gen_mods('DELETE', deletes, existing_rrsets) + \
+ self._gen_mods('CREATE', creates, existing_rrsets) + \
+ self._gen_mods('UPSERT', upserts, existing_rrsets)
- def _mod_Delete(self, change, zone_id):
+ def _mod_Delete(self, change, zone_id, existing_rrsets):
# Existing is the thing that needs to be deleted
existing_records = self._gen_records(change.existing, zone_id,
creating=False)
# Now is a good time to clear out all the health checks since we know
# we're done with them
self._gc_health_checks(change.existing, [])
- return self._gen_mods('DELETE', existing_records)
+ return self._gen_mods('DELETE', existing_records, existing_rrsets)
def _extra_changes_update_needed(self, record, rrset):
healthcheck_host = record.healthcheck_host
@@ -1173,7 +1233,7 @@ class Route53Provider(BaseProvider):
health_check = self.health_checks[health_check_id]
caller_ref = health_check['CallerReference']
if caller_ref.startswith(self.HEALTH_CHECK_VERSION):
- if self._health_check_equivilent(healthcheck_host,
+ if self._health_check_equivalent(healthcheck_host,
healthcheck_path,
healthcheck_protocol,
healthcheck_port,
@@ -1220,15 +1280,27 @@ class Route53Provider(BaseProvider):
'%s', record.fqdn, record._type)
fqdn = record.fqdn
+ _type = record._type
# loop through all the r53 rrsets
for rrset in self._load_records(zone_id):
name = rrset['Name']
+ # Break off the first piece of the name, it'll let us figure out if
+ # this is an rrset we're interested in.
+ maybe_meta, rest = name.split('.', 1)
- if record._type == rrset['Type'] and name.endswith(fqdn) and \
- name.startswith('_octodns-') and '-value.' in name and \
- '-default-' not in name and \
- self._extra_changes_update_needed(record, rrset):
+ if not maybe_meta.startswith('_octodns-') or \
+ not maybe_meta.endswith('-value') or \
+ '-default-' in name:
+ # We're only interested in non-default dynamic value records,
+ # as that's where healthchecks live
+ continue
+
+ if rest != fqdn or _type != rrset['Type']:
+ # rrset isn't for the current record
+ continue
+
+ if self._extra_changes_update_needed(record, rrset):
# no good, doesn't have the right health check, needs an update
self.log.info('_extra_changes_dynamic_needs_update: '
'health-check caused update of %s:%s',
@@ -1271,10 +1343,11 @@ class Route53Provider(BaseProvider):
batch = []
batch_rs_count = 0
zone_id = self._get_zone_id(desired.name, True)
+ existing_rrsets = self._load_records(zone_id)
for c in changes:
# Generate the mods for this change
mod_type = getattr(self, '_mod_{}'.format(c.__class__.__name__))
- mods = mod_type(c, zone_id)
+ mods = mod_type(c, zone_id, existing_rrsets)
# Order our mods to make sure targets exist before alises point to
# them and we CRUD in the desired order
diff --git a/octodns/provider/selectel.py b/octodns/provider/selectel.py
new file mode 100644
index 0000000..072b8cf
--- /dev/null
+++ b/octodns/provider/selectel.py
@@ -0,0 +1,305 @@
+#
+#
+#
+
+from __future__ import absolute_import, division, print_function, \
+ unicode_literals
+
+from collections import defaultdict
+
+from logging import getLogger
+
+from requests import Session
+
+from ..record import Record, Update
+from .base import BaseProvider
+
+
+class SelectelAuthenticationRequired(Exception):
+ def __init__(self, msg):
+ message = 'Authorization failed. Invalid or empty token.'
+ super(SelectelAuthenticationRequired, self).__init__(message)
+
+
+class SelectelProvider(BaseProvider):
+ SUPPORTS_GEO = False
+
+ SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SPF', 'SRV'))
+
+ MIN_TTL = 60
+
+ PAGINATION_LIMIT = 50
+
+ API_URL = 'https://api.selectel.ru/domains/v1'
+
+ def __init__(self, id, token, *args, **kwargs):
+ self.log = getLogger('SelectelProvider[{}]'.format(id))
+ self.log.debug('__init__: id=%s', id)
+ super(SelectelProvider, self).__init__(id, *args, **kwargs)
+
+ self._sess = Session()
+ self._sess.headers.update({
+ 'X-Token': token,
+ 'Content-Type': 'application/json',
+ })
+ self._zone_records = {}
+ self._domain_list = self.domain_list()
+ self._zones = None
+
+ def _request(self, method, path, params=None, data=None):
+ self.log.debug('_request: method=%s, path=%s', method, path)
+
+ url = '{}{}'.format(self.API_URL, path)
+ resp = self._sess.request(method, url, params=params, json=data)
+
+ self.log.debug('_request: status=%s', resp.status_code)
+ if resp.status_code == 401:
+ raise SelectelAuthenticationRequired(resp.text)
+ elif resp.status_code == 404:
+ return {}
+ resp.raise_for_status()
+ if method == 'DELETE':
+ return {}
+ return resp.json()
+
+ def _get_total_count(self, path):
+ url = '{}{}'.format(self.API_URL, path)
+ resp = self._sess.request('HEAD', url)
+ return int(resp.headers['X-Total-Count'])
+
+ def _request_with_pagination(self, path, total_count):
+ result = []
+ for offset in range(0, total_count, self.PAGINATION_LIMIT):
+ result += self._request('GET', path,
+ params={'limit': self.PAGINATION_LIMIT,
+ 'offset': offset})
+ return result
+
+ def _include_change(self, change):
+ if isinstance(change, Update):
+ existing = change.existing.data
+ new = change.new.data
+ new['ttl'] = max(self.MIN_TTL, new['ttl'])
+ if new == existing:
+ self.log.debug('_include_changes: new=%s, found existing=%s',
+ new, existing)
+ return False
+ return True
+
+ def _apply(self, plan):
+ desired = plan.desired
+ changes = plan.changes
+ self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
+ len(changes))
+
+ zone_name = desired.name[:-1]
+ for change in changes:
+ class_name = change.__class__.__name__
+ getattr(self, '_apply_{}'.format(class_name).lower())(zone_name,
+ change)
+
+ def _apply_create(self, zone_name, change):
+ new = change.new
+ params_for = getattr(self, '_params_for_{}'.format(new._type))
+ for params in params_for(new):
+ self.create_record(zone_name, params)
+
+ def _apply_update(self, zone_name, change):
+ self._apply_delete(zone_name, change)
+ self._apply_create(zone_name, change)
+
+ def _apply_delete(self, zone_name, change):
+ existing = change.existing
+ self.delete_record(zone_name, existing._type, existing.name)
+
+ def _params_for_multiple(self, record):
+ for value in record.values:
+ yield {
+ 'content': value,
+ 'name': record.fqdn,
+ 'ttl': max(self.MIN_TTL, record.ttl),
+ 'type': record._type,
+ }
+
+ def _params_for_single(self, record):
+ yield {
+ 'content': record.value,
+ 'name': record.fqdn,
+ 'ttl': max(self.MIN_TTL, record.ttl),
+ 'type': record._type
+ }
+
+ def _params_for_MX(self, record):
+ for value in record.values:
+ yield {
+ 'content': value.exchange,
+ 'name': record.fqdn,
+ 'ttl': max(self.MIN_TTL, record.ttl),
+ 'type': record._type,
+ 'priority': value.preference
+ }
+
+ def _params_for_SRV(self, record):
+ for value in record.values:
+ yield {
+ 'name': record.fqdn,
+ 'target': value.target,
+ 'ttl': max(self.MIN_TTL, record.ttl),
+ 'type': record._type,
+ 'port': value.port,
+ 'weight': value.weight,
+ 'priority': value.priority
+ }
+
+ _params_for_A = _params_for_multiple
+ _params_for_AAAA = _params_for_multiple
+ _params_for_NS = _params_for_multiple
+ _params_for_TXT = _params_for_multiple
+ _params_for_SPF = _params_for_multiple
+
+ _params_for_CNAME = _params_for_single
+
+ def _data_for_A(self, _type, records):
+ return {
+ 'ttl': records[0]['ttl'],
+ 'type': _type,
+ 'values': [r['content'] for r in records],
+ }
+
+ _data_for_AAAA = _data_for_A
+
+ def _data_for_NS(self, _type, records):
+ return {
+ 'ttl': records[0]['ttl'],
+ 'type': _type,
+ 'values': ['{}.'.format(r['content']) for r in records],
+ }
+
+ def _data_for_MX(self, _type, records):
+ values = []
+ for record in records:
+ values.append({
+ 'preference': record['priority'],
+ 'exchange': '{}.'.format(record['content']),
+ })
+ return {
+ 'ttl': records[0]['ttl'],
+ 'type': _type,
+ 'values': values,
+ }
+
+ def _data_for_CNAME(self, _type, records):
+ only = records[0]
+ return {
+ 'ttl': only['ttl'],
+ 'type': _type,
+ 'value': '{}.'.format(only['content'])
+ }
+
+ def _data_for_TXT(self, _type, records):
+ return {
+ 'ttl': records[0]['ttl'],
+ 'type': _type,
+ 'values': [r['content'] for r in records],
+ }
+
+ def _data_for_SRV(self, _type, records):
+ values = []
+ for record in records:
+ values.append({
+ 'priority': record['priority'],
+ 'weight': record['weight'],
+ 'port': record['port'],
+ 'target': '{}.'.format(record['target']),
+ })
+
+ return {
+ 'type': _type,
+ 'ttl': records[0]['ttl'],
+ 'values': values,
+ }
+
+ def populate(self, zone, target=False, lenient=False):
+ self.log.debug('populate: name=%s, target=%s, lenient=%s',
+ zone.name, target, lenient)
+ before = len(zone.records)
+ records = self.zone_records(zone)
+ if records:
+ values = defaultdict(lambda: defaultdict(list))
+ for record in records:
+ name = zone.hostname_from_fqdn(record['name'])
+ _type = record['type']
+ if _type in self.SUPPORTS:
+ values[name][record['type']].append(record)
+ for name, types in values.items():
+ for _type, records in types.items():
+ 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)
+ self.log.info('populate: found %s records',
+ len(zone.records) - before)
+
+ def domain_list(self):
+ path = '/'
+ domains = {}
+ domains_list = []
+
+ total_count = self._get_total_count(path)
+ domains_list = self._request_with_pagination(path, total_count)
+
+ for domain in domains_list:
+ domains[domain['name']] = domain
+ return domains
+
+ def zone_records(self, zone):
+ path = '/{}/records/'.format(zone.name[:-1])
+ zone_records = []
+
+ total_count = self._get_total_count(path)
+ zone_records = self._request_with_pagination(path, total_count)
+
+ self._zone_records[zone.name] = zone_records
+ return self._zone_records[zone.name]
+
+ def create_domain(self, name, zone=""):
+ path = '/'
+
+ data = {
+ 'name': name,
+ 'bind_zone': zone,
+ }
+
+ resp = self._request('POST', path, data=data)
+ self._domain_list[name] = resp
+ return resp
+
+ def create_record(self, zone_name, data):
+ self.log.debug('Create record. Zone: %s, data %s', zone_name, data)
+ if zone_name in self._domain_list.keys():
+ domain_id = self._domain_list[zone_name]['id']
+ else:
+ domain_id = self.create_domain(zone_name)['id']
+
+ path = '/{}/records/'.format(domain_id)
+ return self._request('POST', path, data=data)
+
+ def delete_record(self, domain, _type, zone):
+ self.log.debug('Delete record. Domain: %s, Type: %s', domain, _type)
+
+ domain_id = self._domain_list[domain]['id']
+ records = self._zone_records.get('{}.'.format(domain), False)
+ if not records:
+ path = '/{}/records/'.format(domain_id)
+ records = self._request('GET', path)
+
+ for record in records:
+ full_domain = domain
+ if zone:
+ full_domain = '{}{}'.format(zone, domain)
+ if record['type'] == _type and record['name'] == full_domain:
+ path = '/{}/records/{}'.format(domain_id, record['id'])
+ return self._request('DELETE', path)
+
+ self.log.debug('Delete record failed (Record not found)')
diff --git a/octodns/provider/transip.py b/octodns/provider/transip.py
new file mode 100644
index 0000000..7458e36
--- /dev/null
+++ b/octodns/provider/transip.py
@@ -0,0 +1,353 @@
+#
+#
+#
+
+from __future__ import absolute_import, division, print_function, \
+ unicode_literals
+
+from suds import WebFault
+
+from collections import defaultdict
+from .base import BaseProvider
+from logging import getLogger
+from ..record import Record
+from transip.service.domain import DomainService
+from transip.service.objects import DnsEntry
+
+
+class TransipException(Exception):
+ pass
+
+
+class TransipConfigException(TransipException):
+ pass
+
+
+class TransipNewZoneException(TransipException):
+ pass
+
+
+class TransipProvider(BaseProvider):
+ '''
+ Transip DNS provider
+
+ transip:
+ class: octodns.provider.transip.TransipProvider
+ # Your Transip account name (required)
+ account: yourname
+ # Path to a private key file (required if key is not used)
+ key_file: /path/to/file
+ # The api key as string (required if key_file is not used)
+ key: |
+ \'''
+ -----BEGIN PRIVATE KEY-----
+ ...
+ -----END PRIVATE KEY-----
+ \'''
+ # if both `key_file` and `key` are presented `key_file` is used
+
+ '''
+ SUPPORTS_GEO = False
+ SUPPORTS_DYNAMIC = False
+ SUPPORTS = set(
+ ('A', 'AAAA', 'CNAME', 'MX', 'SRV', 'SPF', 'TXT', 'SSHFP', 'CAA'))
+ # unsupported by OctoDNS: 'TLSA'
+ MIN_TTL = 120
+ TIMEOUT = 15
+ ROOT_RECORD = '@'
+
+ def __init__(self, id, account, key=None, key_file=None, *args, **kwargs):
+ self.log = getLogger('TransipProvider[{}]'.format(id))
+ self.log.debug('__init__: id=%s, account=%s, token=***', id,
+ account)
+ super(TransipProvider, self).__init__(id, *args, **kwargs)
+
+ if key_file is not None:
+ self._client = DomainService(account, private_key_file=key_file)
+ elif key is not None:
+ self._client = DomainService(account, private_key=key)
+ else:
+ raise TransipConfigException(
+ 'Missing `key` of `key_file` parameter in config'
+ )
+
+ self.account = account
+ self.key = key
+
+ self._currentZone = {}
+
+ def populate(self, zone, target=False, lenient=False):
+
+ exists = False
+ self._currentZone = zone
+ self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
+ target, lenient)
+
+ before = len(zone.records)
+ try:
+ zoneInfo = self._client.get_info(zone.name[:-1])
+ except WebFault as e:
+ if e.fault.faultcode == '102' and target is False:
+ # Zone not found in account, and not a target so just
+ # leave an empty zone.
+ return exists
+ elif e.fault.faultcode == '102' and target is True:
+ self.log.warning('populate: Transip can\'t create new zones')
+ raise TransipNewZoneException(
+ ('populate: ({}) Transip used ' +
+ 'as target for non-existing zone: {}').format(
+ e.fault.faultcode, zone.name))
+ else:
+ self.log.error('populate: (%s) %s ', e.fault.faultcode,
+ e.fault.faultstring)
+ raise e
+
+ self.log.debug('populate: found %s records for zone %s',
+ len(zoneInfo.dnsEntries), zone.name)
+ exists = True
+ if zoneInfo.dnsEntries:
+ values = defaultdict(lambda: defaultdict(list))
+ for record in zoneInfo.dnsEntries:
+ name = zone.hostname_from_fqdn(record['name'])
+ if name == self.ROOT_RECORD:
+ name = ''
+
+ if record['type'] in self.SUPPORTS:
+ values[name][record['type']].append(record)
+
+ for name, types in values.items():
+ for _type, records in types.items():
+ data_for = getattr(self, '_data_for_{}'.format(_type))
+ record = Record.new(zone, name, data_for(_type, records),
+ source=self, lenient=lenient)
+ zone.add_record(record, lenient=lenient)
+ self.log.info('populate: found %s records, exists = %s',
+ len(zone.records) - before, exists)
+
+ self._currentZone = {}
+ return exists
+
+ def _apply(self, plan):
+ desired = plan.desired
+ changes = plan.changes
+ self.log.debug('apply: zone=%s, changes=%d', desired.name,
+ len(changes))
+
+ self._currentZone = plan.desired
+ try:
+ self._client.get_info(plan.desired.name[:-1])
+ except WebFault as e:
+ self.log.exception('_apply: get_info failed')
+ raise e
+
+ _dns_entries = []
+ for record in plan.desired.records:
+ if record._type in self.SUPPORTS:
+ entries_for = getattr(self,
+ '_entries_for_{}'.format(record._type))
+
+ # Root records have '@' as name
+ name = record.name
+ if name == '':
+ name = self.ROOT_RECORD
+
+ _dns_entries.extend(entries_for(name, record))
+
+ try:
+ self._client.set_dns_entries(plan.desired.name[:-1], _dns_entries)
+ except WebFault as e:
+ self.log.warning(('_apply: Set DNS returned ' +
+ 'one or more errors: {}').format(
+ e.fault.faultstring))
+ raise TransipException(200, e.fault.faultstring)
+
+ self._currentZone = {}
+
+ def _entries_for_multiple(self, name, record):
+ _entries = []
+
+ for value in record.values:
+ _entries.append(DnsEntry(name, record.ttl, record._type, value))
+
+ return _entries
+
+ def _entries_for_single(self, name, record):
+
+ return [DnsEntry(name, record.ttl, record._type, record.value)]
+
+ _entries_for_A = _entries_for_multiple
+ _entries_for_AAAA = _entries_for_multiple
+ _entries_for_NS = _entries_for_multiple
+ _entries_for_SPF = _entries_for_multiple
+ _entries_for_CNAME = _entries_for_single
+
+ def _entries_for_MX(self, name, record):
+ _entries = []
+
+ for value in record.values:
+ content = "{} {}".format(value.preference, value.exchange)
+ _entries.append(DnsEntry(name, record.ttl, record._type, content))
+
+ return _entries
+
+ def _entries_for_SRV(self, name, record):
+ _entries = []
+
+ for value in record.values:
+ content = "{} {} {} {}".format(value.priority, value.weight,
+ value.port, value.target)
+ _entries.append(DnsEntry(name, record.ttl, record._type, content))
+
+ return _entries
+
+ def _entries_for_SSHFP(self, name, record):
+ _entries = []
+
+ for value in record.values:
+ content = "{} {} {}".format(value.algorithm,
+ value.fingerprint_type,
+ value.fingerprint)
+ _entries.append(DnsEntry(name, record.ttl, record._type, content))
+
+ return _entries
+
+ def _entries_for_CAA(self, name, record):
+ _entries = []
+
+ for value in record.values:
+ content = "{} {} {}".format(value.flags, value.tag,
+ value.value)
+ _entries.append(DnsEntry(name, record.ttl, record._type, content))
+
+ return _entries
+
+ def _entries_for_TXT(self, name, record):
+ _entries = []
+
+ for value in record.values:
+ value = value.replace('\\;', ';')
+ _entries.append(DnsEntry(name, record.ttl, record._type, value))
+
+ return _entries
+
+ def _parse_to_fqdn(self, value):
+
+ # Enforce switch from suds.sax.text.Text to string
+ value = str(value)
+
+ # TransIP allows '@' as value to alias the root record.
+ # this provider won't set an '@' value, but can be an existing record
+ if value == self.ROOT_RECORD:
+ value = self._currentZone.name
+
+ if value[-1] != '.':
+ self.log.debug('parseToFQDN: changed %s to %s', value,
+ '{}.{}'.format(value, self._currentZone.name))
+ value = '{}.{}'.format(value, self._currentZone.name)
+
+ return value
+
+ def _get_lowest_ttl(self, records):
+ _ttl = 100000
+ for record in records:
+ _ttl = min(_ttl, record['expire'])
+ return _ttl
+
+ def _data_for_multiple(self, _type, records):
+
+ _values = []
+ for record in records:
+ # Enforce switch from suds.sax.text.Text to string
+ _values.append(str(record['content']))
+
+ return {
+ 'ttl': self._get_lowest_ttl(records),
+ 'type': _type,
+ 'values': _values
+ }
+
+ _data_for_A = _data_for_multiple
+ _data_for_AAAA = _data_for_multiple
+ _data_for_NS = _data_for_multiple
+ _data_for_SPF = _data_for_multiple
+
+ def _data_for_CNAME(self, _type, records):
+ return {
+ 'ttl': records[0]['expire'],
+ 'type': _type,
+ 'value': self._parse_to_fqdn(records[0]['content'])
+ }
+
+ def _data_for_MX(self, _type, records):
+ _values = []
+ for record in records:
+ preference, exchange = record['content'].split(" ", 1)
+ _values.append({
+ 'preference': preference,
+ 'exchange': self._parse_to_fqdn(exchange)
+ })
+ return {
+ 'ttl': self._get_lowest_ttl(records),
+ 'type': _type,
+ 'values': _values
+ }
+
+ def _data_for_SRV(self, _type, records):
+ _values = []
+ for record in records:
+ priority, weight, port, target = record['content'].split(' ', 3)
+ _values.append({
+ 'port': port,
+ 'priority': priority,
+ 'target': self._parse_to_fqdn(target),
+ 'weight': weight
+ })
+
+ return {
+ 'type': _type,
+ 'ttl': self._get_lowest_ttl(records),
+ 'values': _values
+ }
+
+ def _data_for_SSHFP(self, _type, records):
+ _values = []
+ for record in records:
+ algorithm, fp_type, fingerprint = record['content'].split(' ', 2)
+ _values.append({
+ 'algorithm': algorithm,
+ 'fingerprint': fingerprint.lower(),
+ 'fingerprint_type': fp_type
+ })
+
+ return {
+ 'type': _type,
+ 'ttl': self._get_lowest_ttl(records),
+ 'values': _values
+ }
+
+ def _data_for_CAA(self, _type, records):
+ _values = []
+ for record in records:
+ flags, tag, value = record['content'].split(' ', 2)
+ _values.append({
+ 'flags': flags,
+ 'tag': tag,
+ 'value': value
+ })
+
+ return {
+ 'type': _type,
+ 'ttl': self._get_lowest_ttl(records),
+ 'values': _values
+ }
+
+ def _data_for_TXT(self, _type, records):
+ _values = []
+ for record in records:
+ _values.append(record['content'].replace(';', '\\;'))
+
+ return {
+ 'type': _type,
+ 'ttl': self._get_lowest_ttl(records),
+ 'values': _values
+ }
diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py
index dca6100..98c1836 100644
--- a/octodns/record/__init__.py
+++ b/octodns/record/__init__.py
@@ -9,6 +9,9 @@ from ipaddress import IPv4Address, IPv6Address
from logging import getLogger
import re
+from six import string_types, text_type
+
+from ..equality import EqualityTupleMixin
from .geo import GeoCodes
@@ -23,6 +26,12 @@ class Change(object):
'Returns new if we have one, existing otherwise'
return self.new or self.existing
+ def __lt__(self, other):
+ self_record = self.record
+ other_record = other.record
+ return ((self_record.name, self_record._type) <
+ (other_record.name, other_record._type))
+
class Create(Change):
@@ -68,11 +77,12 @@ class ValidationError(Exception):
self.reasons = reasons
-class Record(object):
+class Record(EqualityTupleMixin):
log = getLogger('Record')
@classmethod
def new(cls, zone, name, data, source=None, lenient=False):
+ name = text_type(name)
fqdn = '{}.{}'.format(name, zone.name) if name else zone.name
try:
_type = data['type']
@@ -96,7 +106,7 @@ class Record(object):
}[_type]
except KeyError:
raise Exception('Unknown record type: "{}"'.format(_type))
- reasons = _class.validate(name, data)
+ reasons = _class.validate(name, fqdn, data)
try:
lenient |= data['octodns']['lenient']
except KeyError:
@@ -109,8 +119,16 @@ class Record(object):
return _class(zone, name, data, source=source)
@classmethod
- def validate(cls, name, data):
+ def validate(cls, name, fqdn, data):
reasons = []
+ n = len(fqdn)
+ if n > 253:
+ reasons.append('invalid fqdn, "{}" is too long at {} chars, max '
+ 'is 253'.format(fqdn, n))
+ n = len(name)
+ if n > 63:
+ reasons.append('invalid name, "{}" is too long at {} chars, max '
+ 'is 63'.format(name, n))
try:
ttl = int(data['ttl'])
if ttl < 0:
@@ -130,7 +148,7 @@ class Record(object):
self.__class__.__name__, name)
self.zone = zone
# force everything lower-case just to be safe
- self.name = unicode(name).lower() if name else name
+ self.name = text_type(name).lower() if name else name
self.source = source
self.ttl = int(data['ttl'])
@@ -194,24 +212,22 @@ class Record(object):
if self.ttl != other.ttl:
return Update(self, other)
- # NOTE: we're using __hash__ and __cmp__ methods that consider Records
+ # NOTE: we're using __hash__ and ordering methods that consider Records
# equivalent if they have the same name & _type. Values are ignored. This
# is useful when computing diffs/changes.
def __hash__(self):
return '{}:{}'.format(self.name, self._type).__hash__()
- def __cmp__(self, other):
- a = '{}:{}'.format(self.name, self._type)
- b = '{}:{}'.format(other.name, other._type)
- return cmp(a, b)
+ def _equality_tuple(self):
+ return (self.name, self._type)
def __repr__(self):
# Make sure this is always overridden
raise NotImplementedError('Abstract base class, __repr__ required')
-class GeoValue(object):
+class GeoValue(EqualityTupleMixin):
geo_re = re.compile(r'^(?P\w\w)(-(?P\w\w)'
r'(-(?P\w\w))?)?$')
@@ -238,11 +254,9 @@ class GeoValue(object):
yield '-'.join(bits)
bits.pop()
- def __cmp__(self, other):
- return 0 if (self.continent_code == other.continent_code and
- self.country_code == other.country_code and
- self.subdivision_code == other.subdivision_code and
- self.values == other.values) else 1
+ def _equality_tuple(self):
+ return (self.continent_code, self.country_code, self.subdivision_code,
+ self.values)
def __repr__(self):
return "'Geo {} {} {} {}'".format(self.continent_code,
@@ -253,8 +267,8 @@ class GeoValue(object):
class _ValuesMixin(object):
@classmethod
- def validate(cls, name, data):
- reasons = super(_ValuesMixin, cls).validate(name, data)
+ def validate(cls, name, fqdn, data):
+ reasons = super(_ValuesMixin, cls).validate(name, fqdn, data)
values = data.get('values', data.get('value', []))
@@ -268,7 +282,6 @@ class _ValuesMixin(object):
values = data['values']
except KeyError:
values = [data['value']]
- # TODO: should we natsort values?
self.values = sorted(self._value_type.process(values))
def changes(self, other, target):
@@ -292,7 +305,7 @@ class _ValuesMixin(object):
return ret
def __repr__(self):
- values = "['{}']".format("', '".join([unicode(v)
+ values = "['{}']".format("', '".join([text_type(v)
for v in self.values]))
return '<{} {} {}, {}, {}>'.format(self.__class__.__name__,
self._type, self.ttl,
@@ -307,8 +320,8 @@ class _GeoMixin(_ValuesMixin):
'''
@classmethod
- def validate(cls, name, data):
- reasons = super(_GeoMixin, cls).validate(name, data)
+ def validate(cls, name, fqdn, data):
+ reasons = super(_GeoMixin, cls).validate(name, fqdn, data)
try:
geo = dict(data['geo'])
for code, values in geo.items():
@@ -354,8 +367,8 @@ class _GeoMixin(_ValuesMixin):
class _ValueMixin(object):
@classmethod
- def validate(cls, name, data):
- reasons = super(_ValueMixin, cls).validate(name, data)
+ def validate(cls, name, fqdn, data):
+ reasons = super(_ValueMixin, cls).validate(name, fqdn, data)
reasons.extend(cls._value_type.validate(data.get('value', None),
cls._type))
return reasons
@@ -481,8 +494,8 @@ class _DynamicMixin(object):
r'(-(?P\w\w))?)?$')
@classmethod
- def validate(cls, name, data):
- reasons = super(_DynamicMixin, cls).validate(name, data)
+ def validate(cls, name, fqdn, data):
+ reasons = super(_DynamicMixin, cls).validate(name, fqdn, data)
if 'dynamic' not in data:
return reasons
@@ -514,7 +527,7 @@ class _DynamicMixin(object):
try:
weight = value['weight']
weight = int(weight)
- if weight < 1 or weight > 255:
+ if weight < 1 or weight > 15:
reasons.append('invalid weight "{}" in pool "{}" '
'value {}'.format(weight, _id,
value_num))
@@ -574,7 +587,7 @@ class _DynamicMixin(object):
reasons.append('rule {} missing pool'.format(rule_num))
continue
- if not isinstance(pool, basestring):
+ if not isinstance(pool, string_types):
reasons.append('rule {} invalid pool "{}"'
.format(rule_num, pool))
elif pool not in pools:
@@ -671,13 +684,13 @@ class _IpList(object):
return ['missing value(s)']
reasons = []
for value in data:
- if value is '':
+ if value == '':
reasons.append('empty value')
elif value is None:
reasons.append('missing value(s)')
else:
try:
- cls._address_type(unicode(value))
+ cls._address_type(text_type(value))
except Exception:
reasons.append('invalid {} address "{}"'
.format(cls._address_name, value))
@@ -685,7 +698,8 @@ class _IpList(object):
@classmethod
def process(cls, values):
- return values
+ # Translating None into '' so that the list will be sortable in python3
+ return [v if v is not None else '' for v in values]
class Ipv4List(_IpList):
@@ -742,7 +756,7 @@ class AliasRecord(_ValueMixin, Record):
_value_type = AliasValue
-class CaaValue(object):
+class CaaValue(EqualityTupleMixin):
# https://tools.ietf.org/html/rfc6844#page-5
@classmethod
@@ -781,12 +795,8 @@ class CaaValue(object):
'value': self.value,
}
- def __cmp__(self, other):
- if self.flags == other.flags:
- if self.tag == other.tag:
- return cmp(self.value, other.value)
- return cmp(self.tag, other.tag)
- return cmp(self.flags, other.flags)
+ def _equality_tuple(self):
+ return (self.flags, self.tag, self.value)
def __repr__(self):
return '{} {} "{}"'.format(self.flags, self.tag, self.value)
@@ -802,15 +812,15 @@ class CnameRecord(_DynamicMixin, _ValueMixin, Record):
_value_type = CnameValue
@classmethod
- def validate(cls, name, data):
+ def validate(cls, name, fqdn, data):
reasons = []
if name == '':
reasons.append('root CNAME not allowed')
- reasons.extend(super(CnameRecord, cls).validate(name, data))
+ reasons.extend(super(CnameRecord, cls).validate(name, fqdn, data))
return reasons
-class MxValue(object):
+class MxValue(EqualityTupleMixin):
@classmethod
def validate(cls, data, _type):
@@ -863,10 +873,11 @@ class MxValue(object):
'exchange': self.exchange,
}
- def __cmp__(self, other):
- if self.preference == other.preference:
- return cmp(self.exchange, other.exchange)
- return cmp(self.preference, other.preference)
+ def __hash__(self):
+ return hash((self.preference, self.exchange))
+
+ def _equality_tuple(self):
+ return (self.preference, self.exchange)
def __repr__(self):
return "'{} {}'".format(self.preference, self.exchange)
@@ -877,7 +888,7 @@ class MxRecord(_ValuesMixin, Record):
_value_type = MxValue
-class NaptrValue(object):
+class NaptrValue(EqualityTupleMixin):
VALID_FLAGS = ('S', 'A', 'U', 'P')
@classmethod
@@ -936,18 +947,12 @@ class NaptrValue(object):
'replacement': self.replacement,
}
- def __cmp__(self, other):
- if self.order != other.order:
- return cmp(self.order, other.order)
- elif self.preference != other.preference:
- return cmp(self.preference, other.preference)
- elif self.flags != other.flags:
- return cmp(self.flags, other.flags)
- elif self.service != other.service:
- return cmp(self.service, other.service)
- elif self.regexp != other.regexp:
- return cmp(self.regexp, other.regexp)
- return cmp(self.replacement, other.replacement)
+ def __hash__(self):
+ return hash(self.__repr__())
+
+ def _equality_tuple(self):
+ return (self.order, self.preference, self.flags, self.service,
+ self.regexp, self.replacement)
def __repr__(self):
flags = self.flags if self.flags is not None else ''
@@ -997,7 +1002,7 @@ class PtrRecord(_ValueMixin, Record):
_value_type = PtrValue
-class SshfpValue(object):
+class SshfpValue(EqualityTupleMixin):
VALID_ALGORITHMS = (1, 2, 3, 4)
VALID_FINGERPRINT_TYPES = (1, 2)
@@ -1048,12 +1053,11 @@ class SshfpValue(object):
'fingerprint': self.fingerprint,
}
- def __cmp__(self, other):
- if self.algorithm != other.algorithm:
- return cmp(self.algorithm, other.algorithm)
- elif self.fingerprint_type != other.fingerprint_type:
- return cmp(self.fingerprint_type, other.fingerprint_type)
- return cmp(self.fingerprint, other.fingerprint)
+ def __hash__(self):
+ return hash(self.__repr__())
+
+ def _equality_tuple(self):
+ return (self.algorithm, self.fingerprint_type, self.fingerprint)
def __repr__(self):
return "'{} {} {}'".format(self.algorithm, self.fingerprint_type,
@@ -1114,7 +1118,7 @@ class SpfRecord(_ChunkedValuesMixin, Record):
_value_type = _ChunkedValue
-class SrvValue(object):
+class SrvValue(EqualityTupleMixin):
@classmethod
def validate(cls, data, _type):
@@ -1169,14 +1173,11 @@ class SrvValue(object):
'target': self.target,
}
- def __cmp__(self, other):
- if self.priority != other.priority:
- return cmp(self.priority, other.priority)
- elif self.weight != other.weight:
- return cmp(self.weight, other.weight)
- elif self.port != other.port:
- return cmp(self.port, other.port)
- return cmp(self.target, other.target)
+ def __hash__(self):
+ return hash(self.__repr__())
+
+ def _equality_tuple(self):
+ return (self.priority, self.weight, self.port, self.target)
def __repr__(self):
return "'{} {} {} {}'".format(self.priority, self.weight, self.port,
@@ -1189,11 +1190,11 @@ class SrvRecord(_ValuesMixin, Record):
_name_re = re.compile(r'^_[^\.]+\.[^\.]+')
@classmethod
- def validate(cls, name, data):
+ def validate(cls, name, fqdn, data):
reasons = []
if not cls._name_re.match(name):
reasons.append('invalid name')
- reasons.extend(super(SrvRecord, cls).validate(name, data))
+ reasons.extend(super(SrvRecord, cls).validate(name, fqdn, data))
return reasons
diff --git a/octodns/source/axfr.py b/octodns/source/axfr.py
index f35c4b3..be2acf5 100644
--- a/octodns/source/axfr.py
+++ b/octodns/source/axfr.py
@@ -15,6 +15,7 @@ from dns.exception import DNSException
from collections import defaultdict
from os import listdir
from os.path import join
+from six import text_type
import logging
from ..record import Record
@@ -179,8 +180,7 @@ class ZoneFileSourceNotFound(ZoneFileSourceException):
class ZoneFileSourceLoadFailure(ZoneFileSourceException):
def __init__(self, error):
- super(ZoneFileSourceLoadFailure, self).__init__(
- error.message)
+ super(ZoneFileSourceLoadFailure, self).__init__(text_type(error))
class ZoneFileSource(AxfrBaseSource):
diff --git a/octodns/source/tinydns.py b/octodns/source/tinydns.py
index dc2bc1b..9c44ed8 100755
--- a/octodns/source/tinydns.py
+++ b/octodns/source/tinydns.py
@@ -67,7 +67,8 @@ class TinyDnsBaseSource(BaseSource):
values = []
for record in records:
- new_value = record[0].decode('unicode-escape').replace(";", "\\;")
+ new_value = record[0].encode('latin1').decode('unicode-escape') \
+ .replace(";", "\\;")
values.append(new_value)
try:
@@ -252,7 +253,7 @@ class TinyDnsFileSource(TinyDnsBaseSource):
# Ignore hidden files
continue
with open(join(self.directory, filename), 'r') as fh:
- lines += filter(lambda l: l, fh.read().split('\n'))
+ lines += [l for l in fh.read().split('\n') if l]
self._cache = lines
diff --git a/octodns/yaml.py b/octodns/yaml.py
index 98bafdb..4187199 100644
--- a/octodns/yaml.py
+++ b/octodns/yaml.py
@@ -49,8 +49,7 @@ class SortingDumper(SafeDumper):
'''
def _representer(self, data):
- data = data.items()
- data.sort(key=lambda d: _natsort_key(d[0]))
+ data = sorted(data.items(), key=lambda d: _natsort_key(d[0]))
return self.represent_mapping(self.DEFAULT_MAPPING_TAG, data)
diff --git a/octodns/zone.py b/octodns/zone.py
index 916f81b..5f099ac 100644
--- a/octodns/zone.py
+++ b/octodns/zone.py
@@ -9,6 +9,8 @@ from collections import defaultdict
from logging import getLogger
import re
+from six import text_type
+
from .record import Create, Delete
@@ -38,7 +40,7 @@ class Zone(object):
raise Exception('Invalid zone name {}, missing ending dot'
.format(name))
# Force everything to lowercase just to be safe
- self.name = unicode(name).lower() if name else name
+ self.name = text_type(name).lower() if name else name
self.sub_zones = sub_zones
# We're grouping by node, it allows us to efficiently search for
# duplicates and detect when CNAMEs co-exist with other records
@@ -82,8 +84,8 @@ class Zone(object):
raise DuplicateRecordException('Duplicate record {}, type {}'
.format(record.fqdn,
record._type))
- elif not lenient and (((record._type == 'CNAME' and len(node) > 0) or
- ('CNAME' in map(lambda r: r._type, node)))):
+ elif not lenient and ((record._type == 'CNAME' and len(node) > 0) or
+ ('CNAME' in [r._type for r in node])):
# We're adding a CNAME to existing records or adding to an existing
# CNAME
raise InvalidNodeException('Invalid state, CNAME at {} cannot '
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 1afee06..d9888b8 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -2,8 +2,7 @@ coverage
mock
nose
pycodestyle==2.4.0
-pycountry>=18.12.8
-pycountry_convert>=0.7.2
pyflakes==1.6.0
+readme_renderer[md]==24.0
requests_mock
-twine==1.11.0
+twine==1.13.0
diff --git a/requirements.txt b/requirements.txt
index d100c96..6a26ad3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,23 +1,26 @@
PyYaml==4.2b1
-azure-common==1.1.18
-azure-mgmt-dns==2.1.0
+azure-common==1.1.23
+azure-mgmt-dns==3.0.0
boto3==1.7.5
botocore==1.10.5
dnspython==1.15.0
docutils==0.14
dyn==1.8.1
-futures==3.2.0
+edgegrid-python==1.1.1
+futures==3.2.0; python_version < '3.0'
google-cloud-core==0.28.1
google-cloud-dns==0.29.0
-incf.countryutils==1.0
ipaddress==1.0.22
jmespath==0.9.3
-msrestazure==0.6.0
+msrestazure==0.6.2
natsort==5.5.0
-nsone==0.9.100
+ns1-python==0.13.0
ovh==0.4.8
+pycountry-convert==0.7.2
+pycountry==19.8.18
python-dateutil==2.6.1
-requests==2.20.0
+requests==2.22.0
s3transfer==0.1.13
-six==1.11.0
-setuptools==38.5.2
+setuptools==40.3.0
+six==1.12.0
+transip==2.0.0
diff --git a/script/cibuild b/script/cibuild
index d048e8e..a2dc527 100755
--- a/script/cibuild
+++ b/script/cibuild
@@ -27,4 +27,6 @@ echo "## lint ##################################################################
script/lint
echo "## tests/coverage ##############################################################"
script/coverage
+echo "## validate setup.py build #####################################################"
+python setup.py build
echo "## complete ####################################################################"
diff --git a/script/coverage b/script/coverage
index 8552eba..ad8189e 100755
--- a/script/coverage
+++ b/script/coverage
@@ -29,7 +29,7 @@ export GOOGLE_APPLICATION_CREDENTIALS=
coverage run --branch --source=octodns --omit=octodns/cmds/* "$(command -v nosetests)" --with-xunit "$@"
coverage html
coverage xml
-coverage report
+coverage report --show-missing
coverage report | grep ^TOTAL | grep -qv 100% && {
echo "Incomplete code coverage" >&2
exit 1
diff --git a/script/release b/script/release
index dd3e1b1..f2c90bf 100755
--- a/script/release
+++ b/script/release
@@ -22,5 +22,6 @@ git tag -s "v$VERSION" -m "Release $VERSION"
git push origin "v$VERSION"
echo "Tagged and pushed v$VERSION"
python setup.py sdist
+twine check dist/*$VERSION.tar.gz
twine upload dist/*$VERSION.tar.gz
echo "Uploaded $VERSION"
diff --git a/setup.py b/setup.py
index 7a9348e..4f28232 100644
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,9 @@
#!/usr/bin/env python
+try:
+ from StringIO import StringIO
+except ImportError:
+ from io import StringIO
from os.path import dirname, join
import octodns
@@ -21,6 +25,39 @@ console_scripts = {
for name in cmds
}
+
+def long_description():
+ buf = StringIO()
+ yaml_block = False
+ supported_providers = False
+ with open('README.md') as fh:
+ for line in fh:
+ if line == '```yaml\n':
+ yaml_block = True
+ continue
+ elif yaml_block and line == '---\n':
+ # skip the line
+ continue
+ elif yaml_block and line == '```\n':
+ yaml_block = False
+ continue
+ elif supported_providers:
+ if line.startswith('## '):
+ supported_providers = False
+ # write this line out, no continue
+ else:
+ # We're ignoring this one
+ continue
+ elif line == '## Supported providers\n':
+ supported_providers = True
+ continue
+ buf.write(line)
+ buf = buf.getvalue()
+ with open('/tmp/mod', 'w') as fh:
+ fh.write(buf)
+ return buf
+
+
setup(
author='Ross McFarland',
author_email='rwmcfa1@gmail.com',
@@ -31,16 +68,18 @@ setup(
install_requires=[
'PyYaml>=4.2b1',
'dnspython>=1.15.0',
- 'futures>=3.2.0',
- 'incf.countryutils>=1.0',
+ 'futures>=3.2.0; python_version<"3.2"',
'ipaddress>=1.0.22',
'natsort>=5.5.0',
+ 'pycountry>=19.8.18',
+ 'pycountry-convert>=0.7.2',
# botocore doesn't like >=2.7.0 for some reason
'python-dateutil>=2.6.0,<2.7.0',
'requests>=2.20.0'
],
license='MIT',
- long_description=open('README.md').read(),
+ long_description=long_description(),
+ long_description_content_type='text/markdown',
name='octodns',
packages=find_packages(),
url='https://github.com/github/octodns',
diff --git a/tests/config/dynamic.tests.yaml b/tests/config/dynamic.tests.yaml
index 3d806f9..f826880 100644
--- a/tests/config/dynamic.tests.yaml
+++ b/tests/config/dynamic.tests.yaml
@@ -19,7 +19,7 @@ a:
- value: 6.6.6.6
weight: 10
- value: 5.5.5.5
- weight: 25
+ weight: 15
rules:
- geos:
- EU-GB
@@ -90,9 +90,9 @@ cname:
sea:
values:
- value: target-sea-1.unit.tests.
- weight: 100
+ weight: 10
- value: target-sea-2.unit.tests.
- weight: 175
+ weight: 14
rules:
- geos:
- EU-GB
diff --git a/tests/config/provider-problems.yaml b/tests/config/provider-problems.yaml
new file mode 100644
index 0000000..9071046
--- /dev/null
+++ b/tests/config/provider-problems.yaml
@@ -0,0 +1,28 @@
+providers:
+ yaml:
+ class: octodns.provider.yaml.YamlProvider
+ directory: ./config
+ simple_source:
+ class: helpers.SimpleSource
+zones:
+ missing.sources.:
+ targets:
+ - yaml
+ missing.targets.:
+ sources:
+ - yaml
+ unknown.source.:
+ sources:
+ - not-there
+ targets:
+ - yaml
+ unknown.target.:
+ sources:
+ - yaml
+ targets:
+ - not-there-either
+ not.targetable.:
+ sources:
+ - yaml
+ targets:
+ - simple_source
diff --git a/tests/config/split/dynamic.tests./a.yaml b/tests/config/split/dynamic.tests./a.yaml
index fd748b4..f182df6 100644
--- a/tests/config/split/dynamic.tests./a.yaml
+++ b/tests/config/split/dynamic.tests./a.yaml
@@ -23,7 +23,7 @@ a:
fallback: null
values:
- value: 5.5.5.5
- weight: 25
+ weight: 15
- value: 6.6.6.6
weight: 10
rules:
diff --git a/tests/config/split/dynamic.tests./cname.yaml b/tests/config/split/dynamic.tests./cname.yaml
index a84c202..ff85955 100644
--- a/tests/config/split/dynamic.tests./cname.yaml
+++ b/tests/config/split/dynamic.tests./cname.yaml
@@ -21,9 +21,9 @@ cname:
fallback: null
values:
- value: target-sea-1.unit.tests.
- weight: 100
+ weight: 10
- value: target-sea-2.unit.tests.
- weight: 175
+ weight: 14
rules:
- geos:
- EU-GB
diff --git a/tests/config/unknown-provider.yaml b/tests/config/unknown-provider.yaml
index 9071046..a0e9f55 100644
--- a/tests/config/unknown-provider.yaml
+++ b/tests/config/unknown-provider.yaml
@@ -5,24 +5,8 @@ providers:
simple_source:
class: helpers.SimpleSource
zones:
- missing.sources.:
- targets:
- - yaml
- missing.targets.:
- sources:
- - yaml
unknown.source.:
sources:
- not-there
targets:
- yaml
- unknown.target.:
- sources:
- - yaml
- targets:
- - not-there-either
- not.targetable.:
- sources:
- - yaml
- targets:
- - simple_source
diff --git a/tests/fixtures/constellix-domains.json b/tests/fixtures/constellix-domains.json
new file mode 100644
index 0000000..4b6392d
--- /dev/null
+++ b/tests/fixtures/constellix-domains.json
@@ -0,0 +1,28 @@
+[{
+ "id": 123123,
+ "name": "unit.tests",
+ "soa": {
+ "primaryNameserver": "ns11.constellix.com.",
+ "email": "dns.constellix.com.",
+ "ttl": 86400,
+ "serial": 2015010102,
+ "refresh": 43200,
+ "retry": 3600,
+ "expire": 1209600,
+ "negCache": 180
+ },
+ "createdTs": "2019-08-07T03:36:02Z",
+ "modifiedTs": "2019-08-07T03:36:02Z",
+ "typeId": 1,
+ "domainTags": [],
+ "folder": null,
+ "hasGtdRegions": false,
+ "hasGeoIP": false,
+ "nameserverGroup": 1,
+ "nameservers": ["ns11.constellix.com.", "ns21.constellix.com.", "ns31.constellix.com.", "ns41.constellix.net.", "ns51.constellix.net.", "ns61.constellix.net."],
+ "note": "",
+ "version": 0,
+ "status": "ACTIVE",
+ "tags": [],
+ "contactIds": []
+}]
diff --git a/tests/fixtures/constellix-records.json b/tests/fixtures/constellix-records.json
new file mode 100644
index 0000000..c1f1fb4
--- /dev/null
+++ b/tests/fixtures/constellix-records.json
@@ -0,0 +1,598 @@
+[{
+ "id": 1808529,
+ "type": "CAA",
+ "recordType": "caa",
+ "name": "",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 3600,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565149569216,
+ "value": [{
+ "flag": 0,
+ "tag": "issue",
+ "data": "ca.unit.tests",
+ "caaProviderId": 1,
+ "disableFlag": false
+ }],
+ "roundRobin": [{
+ "flag": 0,
+ "tag": "issue",
+ "data": "ca.unit.tests",
+ "caaProviderId": 1,
+ "disableFlag": false
+ }]
+}, {
+ "id": 1808516,
+ "type": "A",
+ "recordType": "a",
+ "name": "",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 300,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565149623640,
+ "value": ["1.2.3.4", "1.2.3.5"],
+ "roundRobin": [{
+ "value": "1.2.3.4",
+ "disableFlag": false
+ }, {
+ "value": "1.2.3.5",
+ "disableFlag": false
+ }],
+ "geolocation": null,
+ "recordFailover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "failover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "roundRobinFailover": [],
+ "pools": [],
+ "poolsDetail": []
+}, {
+ "id": 1808527,
+ "type": "SRV",
+ "recordType": "srv",
+ "name": "_srv._tcp",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 600,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565149714387,
+ "value": [{
+ "value": "foo-1.unit.tests.",
+ "priority": 10,
+ "weight": 20,
+ "port": 30,
+ "disableFlag": false
+ }, {
+ "value": "foo-2.unit.tests.",
+ "priority": 12,
+ "weight": 20,
+ "port": 30,
+ "disableFlag": false
+ }],
+ "roundRobin": [{
+ "value": "foo-1.unit.tests.",
+ "priority": 10,
+ "weight": 20,
+ "port": 30,
+ "disableFlag": false
+ }, {
+ "value": "foo-2.unit.tests.",
+ "priority": 12,
+ "weight": 20,
+ "port": 30,
+ "disableFlag": false
+ }]
+}, {
+ "id": 1808515,
+ "type": "AAAA",
+ "recordType": "aaaa",
+ "name": "aaaa",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 600,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565149739464,
+ "value": ["2601:644:500:e210:62f8:1dff:feb8:947a"],
+ "roundRobin": [{
+ "value": "2601:644:500:e210:62f8:1dff:feb8:947a",
+ "disableFlag": false
+ }],
+ "geolocation": null,
+ "recordFailover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "failover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "pools": [],
+ "poolsDetail": [],
+ "roundRobinFailover": []
+}, {
+ "id": 1808530,
+ "type": "ANAME",
+ "recordType": "aname",
+ "name": "",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 1800,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565150251379,
+ "value": [{
+ "value": "aname.unit.tests.",
+ "disableFlag": false
+ }],
+ "roundRobin": [{
+ "value": "aname.unit.tests.",
+ "disableFlag": false
+ }],
+ "geolocation": null,
+ "recordFailover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "failover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "pools": [],
+ "poolsDetail": []
+}, {
+ "id": 1808521,
+ "type": "CNAME",
+ "recordType": "cname",
+ "name": "cname",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 300,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565152113825,
+ "value": "",
+ "roundRobin": [{
+ "value": "",
+ "disableFlag": false
+ }],
+ "recordFailover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": [{
+ "id": null,
+ "value": "",
+ "disableFlag": false,
+ "failedFlag": false,
+ "status": "N/A",
+ "sortOrder": 1,
+ "markedActive": false
+ }, {
+ "id": null,
+ "value": "",
+ "disableFlag": false,
+ "failedFlag": false,
+ "status": "N/A",
+ "sortOrder": 2,
+ "markedActive": false
+ }]
+ },
+ "failover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": [{
+ "id": null,
+ "value": "",
+ "disableFlag": false,
+ "failedFlag": false,
+ "status": "N/A",
+ "sortOrder": 1,
+ "markedActive": false
+ }, {
+ "id": null,
+ "value": "",
+ "disableFlag": false,
+ "failedFlag": false,
+ "status": "N/A",
+ "sortOrder": 2,
+ "markedActive": false
+ }]
+ },
+ "pools": [],
+ "poolsDetail": [],
+ "geolocation": null,
+ "host": ""
+}, {
+ "id": 1808522,
+ "type": "CNAME",
+ "recordType": "cname",
+ "name": "included",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 3600,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565152119137,
+ "value": "",
+ "roundRobin": [{
+ "value": "",
+ "disableFlag": false
+ }],
+ "recordFailover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": [{
+ "id": null,
+ "value": "",
+ "disableFlag": false,
+ "failedFlag": false,
+ "status": "N/A",
+ "sortOrder": 1,
+ "markedActive": false
+ }, {
+ "id": null,
+ "value": "",
+ "disableFlag": false,
+ "failedFlag": false,
+ "status": "N/A",
+ "sortOrder": 2,
+ "markedActive": false
+ }]
+ },
+ "failover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": [{
+ "id": null,
+ "value": "",
+ "disableFlag": false,
+ "failedFlag": false,
+ "status": "N/A",
+ "sortOrder": 1,
+ "markedActive": false
+ }, {
+ "id": null,
+ "value": "",
+ "disableFlag": false,
+ "failedFlag": false,
+ "status": "N/A",
+ "sortOrder": 2,
+ "markedActive": false
+ }]
+ },
+ "pools": [],
+ "poolsDetail": [],
+ "geolocation": null,
+ "host": ""
+}, {
+ "id": 1808523,
+ "type": "MX",
+ "recordType": "mx",
+ "name": "mx",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 300,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565149879856,
+ "value": [{
+ "value": "smtp-3.unit.tests.",
+ "level": 30,
+ "disableFlag": false
+ }, {
+ "value": "smtp-2.unit.tests.",
+ "level": 20,
+ "disableFlag": false
+ }, {
+ "value": "smtp-4.unit.tests.",
+ "level": 10,
+ "disableFlag": false
+ }, {
+ "value": "smtp-1.unit.tests.",
+ "level": 40,
+ "disableFlag": false
+ }],
+ "roundRobin": [{
+ "value": "smtp-3.unit.tests.",
+ "level": 30,
+ "disableFlag": false
+ }, {
+ "value": "smtp-2.unit.tests.",
+ "level": 20,
+ "disableFlag": false
+ }, {
+ "value": "smtp-4.unit.tests.",
+ "level": 10,
+ "disableFlag": false
+ }, {
+ "value": "smtp-1.unit.tests.",
+ "level": 40,
+ "disableFlag": false
+ }]
+}, {
+ "id": 1808525,
+ "type": "PTR",
+ "recordType": "ptr",
+ "name": "ptr",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 300,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565150115139,
+ "value": [{
+ "value": "foo.bar.com.",
+ "disableFlag": false
+ }],
+ "roundRobin": [{
+ "value": "foo.bar.com.",
+ "disableFlag": false
+ }]
+}, {
+ "id": 1808526,
+ "type": "SPF",
+ "recordType": "spf",
+ "name": "spf",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 600,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565149916132,
+ "value": [{
+ "value": "\"v=spf1 ip4:192.168.0.1/16-all\"",
+ "disableFlag": false
+ }],
+ "roundRobin": [{
+ "value": "\"v=spf1 ip4:192.168.0.1/16-all\"",
+ "disableFlag": false
+ }]
+}, {
+ "id": 1808528,
+ "type": "TXT",
+ "recordType": "txt",
+ "name": "txt",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 600,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565149966915,
+ "value": [{
+ "value": "\"Bah bah black sheep\"",
+ "disableFlag": false
+ }, {
+ "value": "\"have you any wool.\"",
+ "disableFlag": false
+ }, {
+ "value": "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"",
+ "disableFlag": false
+ }],
+ "roundRobin": [{
+ "value": "\"Bah bah black sheep\"",
+ "disableFlag": false
+ }, {
+ "value": "\"have you any wool.\"",
+ "disableFlag": false
+ }, {
+ "value": "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"",
+ "disableFlag": false
+ }]
+}, {
+ "id": 1808524,
+ "type": "NS",
+ "recordType": "ns",
+ "name": "under",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 3600,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565150062850,
+ "value": [{
+ "value": "ns1.unit.tests.",
+ "disableFlag": false
+ }, {
+ "value": "ns2",
+ "disableFlag": false
+ }],
+ "roundRobin": [{
+ "value": "ns1.unit.tests.",
+ "disableFlag": false
+ }, {
+ "value": "ns2",
+ "disableFlag": false
+ }]
+}, {
+ "id": 1808531,
+ "type": "HTTPRedirection",
+ "recordType": "httpredirection",
+ "name": "unsupported",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 300,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565150348154,
+ "value": "https://redirect.unit.tests",
+ "roundRobin": [{
+ "value": "https://redirect.unit.tests"
+ }],
+ "title": "Unsupported Record",
+ "keywords": "unsupported",
+ "description": "unsupported record",
+ "hardlinkFlag": false,
+ "redirectTypeId": 1,
+ "url": "https://redirect.unit.tests"
+}, {
+ "id": 1808519,
+ "type": "A",
+ "recordType": "a",
+ "name": "www",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 300,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565150079027,
+ "value": ["2.2.3.6"],
+ "roundRobin": [{
+ "value": "2.2.3.6",
+ "disableFlag": false
+ }],
+ "geolocation": null,
+ "recordFailover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "failover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "roundRobinFailover": [],
+ "pools": [],
+ "poolsDetail": []
+}, {
+ "id": 1808603,
+ "type": "ANAME",
+ "recordType": "aname",
+ "name": "sub",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 1800,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565153387855,
+ "value": [{
+ "value": "aname.unit.tests.",
+ "disableFlag": false
+ }],
+ "roundRobin": [{
+ "value": "aname.unit.tests.",
+ "disableFlag": false
+ }],
+ "geolocation": null,
+ "recordFailover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "failover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "pools": [],
+ "poolsDetail": []
+}, {
+ "id": 1808520,
+ "type": "A",
+ "recordType": "a",
+ "name": "www.sub",
+ "recordOption": "roundRobin",
+ "noAnswer": false,
+ "note": "",
+ "ttl": 300,
+ "gtdRegion": 1,
+ "parentId": 123123,
+ "parent": "domain",
+ "source": "Domain",
+ "modifiedTs": 1565150090588,
+ "value": ["2.2.3.6"],
+ "roundRobin": [{
+ "value": "2.2.3.6",
+ "disableFlag": false
+ }],
+ "geolocation": null,
+ "recordFailover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "failover": {
+ "disabled": false,
+ "failoverType": 1,
+ "failoverTypeStr": "Normal (always lowest level)",
+ "values": []
+ },
+ "roundRobinFailover": [],
+ "pools": [],
+ "poolsDetail": []
+}]
diff --git a/tests/fixtures/digitalocean-page-1.json b/tests/fixtures/digitalocean-page-1.json
index db231ba..c931411 100644
--- a/tests/fixtures/digitalocean-page-1.json
+++ b/tests/fixtures/digitalocean-page-1.json
@@ -1,5 +1,16 @@
{
"domain_records": [{
+ "id": null,
+ "type": "SOA",
+ "name": "@",
+ "data": null,
+ "priority": null,
+ "port": null,
+ "ttl": null,
+ "weight": null,
+ "flags": null,
+ "tag": null
+ }, {
"id": 11189874,
"type": "NS",
"name": "@",
diff --git a/tests/fixtures/fastdns-invalid-content.json b/tests/fixtures/fastdns-invalid-content.json
new file mode 100644
index 0000000..8932f66
--- /dev/null
+++ b/tests/fixtures/fastdns-invalid-content.json
@@ -0,0 +1,35 @@
+{
+ "recordsets": [
+ {
+ "rdata": [
+ "",
+ "12 20 foo-2.unit.tests."
+ ],
+ "type": "SRV",
+ "name": "_srv._tcp.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "",
+ "1 1"
+ ],
+ "type": "SSHFP",
+ "name": "unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "",
+ "100 \"U\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" ."
+ ],
+ "type": "NAPTR",
+ "name": "naptr.unit.tests",
+ "ttl": 600
+ }
+ ],
+ "metadata": {
+ "totalElements": 3,
+ "showAll": true
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/fastdns-records-prev-other.json b/tests/fixtures/fastdns-records-prev-other.json
new file mode 100644
index 0000000..acae3ec
--- /dev/null
+++ b/tests/fixtures/fastdns-records-prev-other.json
@@ -0,0 +1,166 @@
+{
+ "recordsets": [
+ {
+ "rdata": [
+ "10 20 30 foo-1.other.tests.",
+ "12 20 30 foo-2.other.tests."
+ ],
+ "type": "SRV",
+ "name": "_srv._tcp.old.other.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "10 20 30 foo-1.other.tests.",
+ "12 20 30 foo-2.other.tests."
+ ],
+ "type": "SRV",
+ "name": "_srv._tcp.old.other.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "2601:644:500:e210:62f8:1dff:feb8:9471"
+ ],
+ "type": "AAAA",
+ "name": "aaaa.old.other.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "ns1.akam.net.",
+ "ns2.akam.net.",
+ "ns3.akam.net.",
+ "ns4.akam.net."
+ ],
+ "type": "NS",
+ "name": "old.other.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "1.2.3.4",
+ "1.2.3.5"
+ ],
+ "type": "A",
+ "name": "old.other.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "ns1.akam.net hostmaster.akamai.com 1489074932 86400 7200 604800 300"
+ ],
+ "type": "SOA",
+ "name": "other.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49",
+ "1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73"
+ ],
+ "type": "SSHFP",
+ "name": "old.other.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "other.tests."
+ ],
+ "type": "CNAME",
+ "name": "old.cname.other.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "other.tests."
+ ],
+ "type": "CNAME",
+ "name": "excluded.old.other.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "other.tests."
+ ],
+ "type": "CNAME",
+ "name": "included.old.other.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "10 smtp-4.other.tests.",
+ "20 smtp-2.other.tests.",
+ "30 smtp-3.other.tests.",
+ "40 smtp-1.other.tests."
+ ],
+ "type": "MX",
+ "name": "mx.old.other.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "10 100 \"S\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" .",
+ "100 100 \"U\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" ."
+ ],
+ "type": "NAPTR",
+ "name": "naptr.old.other.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "foo.bar.com."
+ ],
+ "type": "PTR",
+ "name": "ptr.old.other.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "\"v=spf1 ip4:192.168.0.1/16-all\""
+ ],
+ "type": "SPF",
+ "name": "spf.old.other.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "ns1.other.tests.",
+ "ns2.other.tests."
+ ],
+ "type": "NS",
+ "name": "under.old.other.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "\"Bah bah black sheep\"",
+ "\"have you any wool.\"",
+ "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\""
+ ],
+ "type": "TXT",
+ "name": "txt.old.other.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "2.2.3.7"
+ ],
+ "type": "A",
+ "name": "www.other.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "2.2.3.6"
+ ],
+ "type": "A",
+ "name": "www.sub.old.other.tests",
+ "ttl": 300
+ }
+ ],
+ "metadata": {
+ "totalElements": 16,
+ "showAll": true
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/fastdns-records-prev.json b/tests/fixtures/fastdns-records-prev.json
new file mode 100644
index 0000000..b07c63f
--- /dev/null
+++ b/tests/fixtures/fastdns-records-prev.json
@@ -0,0 +1,166 @@
+{
+ "recordsets": [
+ {
+ "rdata": [
+ "10 20 30 foo-1.unit.tests.",
+ "12 20 30 foo-2.unit.tests."
+ ],
+ "type": "SRV",
+ "name": "_srv._tcp.old.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "10 20 30 foo-1.unit.tests.",
+ "12 20 30 foo-2.unit.tests."
+ ],
+ "type": "SRV",
+ "name": "_srv._tcp.old.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "2601:644:500:e210:62f8:1dff:feb8:9471"
+ ],
+ "type": "AAAA",
+ "name": "aaaa.old.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "ns1.akam.net.",
+ "ns2.akam.net.",
+ "ns3.akam.net.",
+ "ns4.akam.net."
+ ],
+ "type": "NS",
+ "name": "old.unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "1.2.3.4",
+ "1.2.3.5"
+ ],
+ "type": "A",
+ "name": "old.unit.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "ns1.akam.net hostmaster.akamai.com 1489074932 86400 7200 604800 300"
+ ],
+ "type": "SOA",
+ "name": "unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49",
+ "1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73"
+ ],
+ "type": "SSHFP",
+ "name": "old.unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "unit.tests"
+ ],
+ "type": "CNAME",
+ "name": "old.cname.unit.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "unit.tests."
+ ],
+ "type": "CNAME",
+ "name": "excluded.old.unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "unit.tests."
+ ],
+ "type": "CNAME",
+ "name": "included.old.unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "10 smtp-4.unit.tests.",
+ "20 smtp-2.unit.tests.",
+ "30 smtp-3.unit.tests.",
+ "40 smtp-1.unit.tests."
+ ],
+ "type": "MX",
+ "name": "mx.old.unit.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "10 100 \"S\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" .",
+ "100 100 \"U\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" ."
+ ],
+ "type": "NAPTR",
+ "name": "naptr.old.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "foo.bar.com."
+ ],
+ "type": "PTR",
+ "name": "ptr.old.unit.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "\"v=spf1 ip4:192.168.0.1/16-all\""
+ ],
+ "type": "SPF",
+ "name": "spf.old.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "ns1.unit.tests.",
+ "ns2.unit.tests."
+ ],
+ "type": "NS",
+ "name": "under.old.unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "\"Bah bah black sheep\"",
+ "\"have you any wool.\"",
+ "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\""
+ ],
+ "type": "TXT",
+ "name": "txt.old.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "2.2.3.7"
+ ],
+ "type": "A",
+ "name": "www.unit.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "2.2.3.6"
+ ],
+ "type": "A",
+ "name": "www.sub.old.unit.tests",
+ "ttl": 300
+ }
+ ],
+ "metadata": {
+ "totalElements": 16,
+ "showAll": true
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/fastdns-records.json b/tests/fixtures/fastdns-records.json
new file mode 100644
index 0000000..4693eb1
--- /dev/null
+++ b/tests/fixtures/fastdns-records.json
@@ -0,0 +1,157 @@
+{
+ "recordsets": [
+ {
+ "rdata": [
+ "10 20 30 foo-1.unit.tests.",
+ "12 20 30 foo-2.unit.tests."
+ ],
+ "type": "SRV",
+ "name": "_srv._tcp.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "2601:644:500:e210:62f8:1dff:feb8:947a"
+ ],
+ "type": "AAAA",
+ "name": "aaaa.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "ns1.akam.net.",
+ "ns2.akam.net.",
+ "ns3.akam.net.",
+ "ns4.akam.net."
+ ],
+ "type": "NS",
+ "name": "unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "1.2.3.4",
+ "1.2.3.5"
+ ],
+ "type": "A",
+ "name": "unit.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "ns1.akam.net hostmaster.akamai.com 1489074932 86400 7200 604800 300"
+ ],
+ "type": "SOA",
+ "name": "unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49",
+ "1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73"
+ ],
+ "type": "SSHFP",
+ "name": "unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "unit.tests."
+ ],
+ "type": "CNAME",
+ "name": "cname.unit.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "unit.tests."
+ ],
+ "type": "CNAME",
+ "name": "excluded.unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "unit.tests."
+ ],
+ "type": "CNAME",
+ "name": "included.unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "10 smtp-4.unit.tests.",
+ "20 smtp-2.unit.tests.",
+ "30 smtp-3.unit.tests.",
+ "40 smtp-1.unit.tests."
+ ],
+ "type": "MX",
+ "name": "mx.unit.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "10 100 \"S\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" .",
+ "100 100 \"U\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" ."
+ ],
+ "type": "NAPTR",
+ "name": "naptr.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "foo.bar.com."
+ ],
+ "type": "PTR",
+ "name": "ptr.unit.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "\"v=spf1 ip4:192.168.0.1/16-all\""
+ ],
+ "type": "SPF",
+ "name": "spf.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "ns1.unit.tests.",
+ "ns2.unit.tests."
+ ],
+ "type": "NS",
+ "name": "under.unit.tests",
+ "ttl": 3600
+ },
+ {
+ "rdata": [
+ "\"Bah bah black sheep\"",
+ "\"have you any wool.\"",
+ "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\""
+ ],
+ "type": "TXT",
+ "name": "txt.unit.tests",
+ "ttl": 600
+ },
+ {
+ "rdata": [
+ "2.2.3.6"
+ ],
+ "type": "A",
+ "name": "www.unit.tests",
+ "ttl": 300
+ },
+ {
+ "rdata": [
+ "2.2.3.6"
+ ],
+ "type": "A",
+ "name": "www.sub.unit.tests",
+ "ttl": 300
+ }
+ ],
+ "metadata": {
+ "totalElements": 16,
+ "showAll": true
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/mythicbeasts-list.txt b/tests/fixtures/mythicbeasts-list.txt
new file mode 100644
index 0000000..ed4ea4c
--- /dev/null
+++ b/tests/fixtures/mythicbeasts-list.txt
@@ -0,0 +1,25 @@
+@ 3600 NS 6.2.3.4.
+@ 3600 NS 7.2.3.4.
+@ 300 A 1.2.3.4
+@ 300 A 1.2.3.5
+@ 3600 SSHFP 1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73
+@ 3600 SSHFP 1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49
+@ 3600 CAA 0 issue ca.unit.tests
+_srv._tcp 600 SRV 10 20 30 foo-1.unit.tests.
+_srv._tcp 600 SRV 12 20 30 foo-2.unit.tests.
+aaaa 600 AAAA 2601:644:500:e210:62f8:1dff:feb8:947a
+cname 300 CNAME unit.tests.
+excluded 300 CNAME unit.tests.
+ignored 300 A 9.9.9.9
+included 3600 CNAME unit.tests.
+mx 300 MX 10 smtp-4.unit.tests.
+mx 300 MX 20 smtp-2.unit.tests.
+mx 300 MX 30 smtp-3.unit.tests.
+mx 300 MX 40 smtp-1.unit.tests.
+sub 3600 NS 6.2.3.4.
+sub 3600 NS 7.2.3.4.
+txt 600 TXT "Bah bah black sheep"
+txt 600 TXT "have you any wool."
+txt 600 TXT "v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs"
+www 300 A 2.2.3.6
+www.sub 300 A 2.2.3.6
diff --git a/tests/test_octodns_equality.py b/tests/test_octodns_equality.py
new file mode 100644
index 0000000..dcdc460
--- /dev/null
+++ b/tests/test_octodns_equality.py
@@ -0,0 +1,68 @@
+#
+#
+#
+
+from __future__ import absolute_import, division, print_function, \
+ unicode_literals
+
+from unittest import TestCase
+
+from octodns.equality import EqualityTupleMixin
+
+
+class TestEqualityTupleMixin(TestCase):
+
+ def test_basics(self):
+
+ class Simple(EqualityTupleMixin):
+
+ def __init__(self, a, b, c):
+ self.a = a
+ self.b = b
+ self.c = c
+
+ def _equality_tuple(self):
+ return (self.a, self.b)
+
+ one = Simple(1, 2, 3)
+ same = Simple(1, 2, 3)
+ matches = Simple(1, 2, 'ignored')
+ doesnt = Simple(2, 3, 4)
+
+ # equality
+ self.assertEquals(one, one)
+ self.assertEquals(one, same)
+ self.assertEquals(same, one)
+ # only a & c are considered
+ self.assertEquals(one, matches)
+ self.assertEquals(matches, one)
+ self.assertNotEquals(one, doesnt)
+ self.assertNotEquals(doesnt, one)
+
+ # lt
+ self.assertTrue(one < doesnt)
+ self.assertFalse(doesnt < one)
+ self.assertFalse(one < same)
+
+ # le
+ self.assertTrue(one <= doesnt)
+ self.assertFalse(doesnt <= one)
+ self.assertTrue(one <= same)
+
+ # gt
+ self.assertFalse(one > doesnt)
+ self.assertTrue(doesnt > one)
+ self.assertFalse(one > same)
+
+ # ge
+ self.assertFalse(one >= doesnt)
+ self.assertTrue(doesnt >= one)
+ self.assertTrue(one >= same)
+
+ def test_not_implemented(self):
+
+ class MissingMethod(EqualityTupleMixin):
+ pass
+
+ with self.assertRaises(NotImplementedError):
+ MissingMethod() == MissingMethod()
diff --git a/tests/test_octodns_manager.py b/tests/test_octodns_manager.py
index 0dd3514..13eea95 100644
--- a/tests/test_octodns_manager.py
+++ b/tests/test_octodns_manager.py
@@ -7,10 +7,12 @@ from __future__ import absolute_import, division, print_function, \
from os import environ
from os.path import dirname, join
+from six import text_type
from unittest import TestCase
from octodns.record import Record
-from octodns.manager import _AggregateTarget, MainThreadExecutor, Manager
+from octodns.manager import _AggregateTarget, MainThreadExecutor, Manager, \
+ ManagerException
from octodns.yaml import safe_load
from octodns.zone import Zone
@@ -27,80 +29,81 @@ def get_config_filename(which):
class TestManager(TestCase):
def test_missing_provider_class(self):
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('missing-provider-class.yaml')).sync()
- self.assertTrue('missing class' in ctx.exception.message)
+ self.assertTrue('missing class' in text_type(ctx.exception))
def test_bad_provider_class(self):
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('bad-provider-class.yaml')).sync()
- self.assertTrue('Unknown provider class' in ctx.exception.message)
+ self.assertTrue('Unknown provider class' in text_type(ctx.exception))
def test_bad_provider_class_module(self):
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('bad-provider-class-module.yaml')) \
.sync()
- self.assertTrue('Unknown provider class' in ctx.exception.message)
+ self.assertTrue('Unknown provider class' in text_type(ctx.exception))
def test_bad_provider_class_no_module(self):
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('bad-provider-class-no-module.yaml')) \
.sync()
- self.assertTrue('Unknown provider class' in ctx.exception.message)
+ self.assertTrue('Unknown provider class' in text_type(ctx.exception))
def test_missing_provider_config(self):
# Missing provider config
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('missing-provider-config.yaml')).sync()
- self.assertTrue('provider config' in ctx.exception.message)
+ self.assertTrue('provider config' in text_type(ctx.exception))
def test_missing_env_config(self):
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('missing-provider-env.yaml')).sync()
- self.assertTrue('missing env var' in ctx.exception.message)
+ self.assertTrue('missing env var' in text_type(ctx.exception))
def test_missing_source(self):
- with self.assertRaises(Exception) as ctx:
- Manager(get_config_filename('unknown-provider.yaml')) \
+ with self.assertRaises(ManagerException) as ctx:
+ Manager(get_config_filename('provider-problems.yaml')) \
.sync(['missing.sources.'])
- self.assertTrue('missing sources' in ctx.exception.message)
+ self.assertTrue('missing sources' in text_type(ctx.exception))
def test_missing_targets(self):
- with self.assertRaises(Exception) as ctx:
- Manager(get_config_filename('unknown-provider.yaml')) \
+ with self.assertRaises(ManagerException) as ctx:
+ Manager(get_config_filename('provider-problems.yaml')) \
.sync(['missing.targets.'])
- self.assertTrue('missing targets' in ctx.exception.message)
+ self.assertTrue('missing targets' in text_type(ctx.exception))
def test_unknown_source(self):
- with self.assertRaises(Exception) as ctx:
- Manager(get_config_filename('unknown-provider.yaml')) \
+ with self.assertRaises(ManagerException) as ctx:
+ Manager(get_config_filename('provider-problems.yaml')) \
.sync(['unknown.source.'])
- self.assertTrue('unknown source' in ctx.exception.message)
+ self.assertTrue('unknown source' in text_type(ctx.exception))
def test_unknown_target(self):
- with self.assertRaises(Exception) as ctx:
- Manager(get_config_filename('unknown-provider.yaml')) \
+ with self.assertRaises(ManagerException) as ctx:
+ Manager(get_config_filename('provider-problems.yaml')) \
.sync(['unknown.target.'])
- self.assertTrue('unknown target' in ctx.exception.message)
+ self.assertTrue('unknown target' in text_type(ctx.exception))
def test_bad_plan_output_class(self):
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
name = 'bad-plan-output-missing-class.yaml'
Manager(get_config_filename(name)).sync()
self.assertEquals('plan_output bad is missing class',
- ctx.exception.message)
+ text_type(ctx.exception))
def test_bad_plan_output_config(self):
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('bad-plan-output-config.yaml')).sync()
self.assertEqual('Incorrect plan_output config for bad',
- ctx.exception.message)
+ text_type(ctx.exception))
def test_source_only_as_a_target(self):
- with self.assertRaises(Exception) as ctx:
- Manager(get_config_filename('unknown-provider.yaml')) \
+ with self.assertRaises(ManagerException) as ctx:
+ Manager(get_config_filename('provider-problems.yaml')) \
.sync(['not.targetable.'])
- self.assertTrue('does not support targeting' in ctx.exception.message)
+ self.assertTrue('does not support targeting' in
+ text_type(ctx.exception))
def test_always_dry_run(self):
with TemporaryDirectory() as tmpdir:
@@ -180,9 +183,9 @@ class TestManager(TestCase):
'unit.tests.')
self.assertEquals(14, len(changes))
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
manager.compare(['nope'], ['dump'], 'unit.tests.')
- self.assertEquals('Unknown source: nope', ctx.exception.message)
+ self.assertEquals('Unknown source: nope', text_type(ctx.exception))
def test_aggregate_target(self):
simple = SimpleProvider()
@@ -220,10 +223,10 @@ class TestManager(TestCase):
environ['YAML_TMP_DIR'] = tmpdir.dirname
manager = Manager(get_config_filename('simple.yaml'))
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
manager.dump('unit.tests.', tmpdir.dirname, False, False,
'nope')
- self.assertEquals('Unknown source: nope', ctx.exception.message)
+ self.assertEquals('Unknown source: nope', text_type(ctx.exception))
manager.dump('unit.tests.', tmpdir.dirname, False, False, 'in')
@@ -249,10 +252,10 @@ class TestManager(TestCase):
environ['YAML_TMP_DIR'] = tmpdir.dirname
manager = Manager(get_config_filename('simple-split.yaml'))
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
manager.dump('unit.tests.', tmpdir.dirname, False, True,
'nope')
- self.assertEquals('Unknown source: nope', ctx.exception.message)
+ self.assertEquals('Unknown source: nope', text_type(ctx.exception))
manager.dump('unit.tests.', tmpdir.dirname, False, True, 'in')
@@ -265,15 +268,15 @@ class TestManager(TestCase):
def test_validate_configs(self):
Manager(get_config_filename('simple-validate.yaml')).validate_configs()
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('missing-sources.yaml')) \
.validate_configs()
- self.assertTrue('missing sources' in ctx.exception.message)
+ self.assertTrue('missing sources' in text_type(ctx.exception))
- with self.assertRaises(Exception) as ctx:
+ with self.assertRaises(ManagerException) as ctx:
Manager(get_config_filename('unknown-provider.yaml')) \
.validate_configs()
- self.assertTrue('unknown source' in ctx.exception.message)
+ self.assertTrue('unknown source' in text_type(ctx.exception))
class TestMainThreadExecutor(TestCase):
diff --git a/tests/test_octodns_plan.py b/tests/test_octodns_plan.py
index 7d849be..9cf812d 100644
--- a/tests/test_octodns_plan.py
+++ b/tests/test_octodns_plan.py
@@ -5,8 +5,8 @@
from __future__ import absolute_import, division, print_function, \
unicode_literals
-from StringIO import StringIO
from logging import getLogger
+from six import StringIO, text_type
from unittest import TestCase
from octodns.provider.plan import Plan, PlanHtml, PlanLogger, PlanMarkdown
@@ -59,7 +59,7 @@ class TestPlanLogger(TestCase):
with self.assertRaises(Exception) as ctx:
PlanLogger('invalid', 'not-a-level')
self.assertEquals('Unsupported level: not-a-level',
- ctx.exception.message)
+ text_type(ctx.exception))
def test_create(self):
diff --git a/tests/test_octodns_provider_base.py b/tests/test_octodns_provider_base.py
index e28850a..f33db0f 100644
--- a/tests/test_octodns_provider_base.py
+++ b/tests/test_octodns_provider_base.py
@@ -6,6 +6,7 @@ from __future__ import absolute_import, division, print_function, \
unicode_literals
from logging import getLogger
+from six import text_type
from unittest import TestCase
from octodns.record import Create, Delete, Record, Update
@@ -48,7 +49,7 @@ class TestBaseProvider(TestCase):
with self.assertRaises(NotImplementedError) as ctx:
BaseProvider('base')
self.assertEquals('Abstract base class, log property missing',
- ctx.exception.message)
+ text_type(ctx.exception))
class HasLog(BaseProvider):
log = getLogger('HasLog')
@@ -56,7 +57,7 @@ class TestBaseProvider(TestCase):
with self.assertRaises(NotImplementedError) as ctx:
HasLog('haslog')
self.assertEquals('Abstract base class, SUPPORTS_GEO property missing',
- ctx.exception.message)
+ text_type(ctx.exception))
class HasSupportsGeo(HasLog):
SUPPORTS_GEO = False
@@ -65,14 +66,14 @@ class TestBaseProvider(TestCase):
with self.assertRaises(NotImplementedError) as ctx:
HasSupportsGeo('hassupportsgeo').populate(zone)
self.assertEquals('Abstract base class, SUPPORTS property missing',
- ctx.exception.message)
+ text_type(ctx.exception))
class HasSupports(HasSupportsGeo):
SUPPORTS = set(('A',))
with self.assertRaises(NotImplementedError) as ctx:
HasSupports('hassupports').populate(zone)
self.assertEquals('Abstract base class, populate method missing',
- ctx.exception.message)
+ text_type(ctx.exception))
# SUPPORTS_DYNAMIC has a default/fallback
self.assertFalse(HasSupports('hassupports').SUPPORTS_DYNAMIC)
@@ -118,7 +119,7 @@ class TestBaseProvider(TestCase):
with self.assertRaises(NotImplementedError) as ctx:
HasPopulate('haspopulate').apply(plan)
self.assertEquals('Abstract base class, _apply method missing',
- ctx.exception.message)
+ text_type(ctx.exception))
def test_plan(self):
ignored = Zone('unit.tests.', [])
@@ -193,7 +194,7 @@ class TestBaseProvider(TestCase):
})
for i in range(int(Plan.MIN_EXISTING_RECORDS)):
- zone.add_record(Record.new(zone, unicode(i), {
+ zone.add_record(Record.new(zone, text_type(i), {
'ttl': 60,
'type': 'A',
'value': '2.3.4.5'
@@ -225,7 +226,7 @@ class TestBaseProvider(TestCase):
})
for i in range(int(Plan.MIN_EXISTING_RECORDS)):
- zone.add_record(Record.new(zone, unicode(i), {
+ zone.add_record(Record.new(zone, text_type(i), {
'ttl': 60,
'type': 'A',
'value': '2.3.4.5'
@@ -238,7 +239,7 @@ class TestBaseProvider(TestCase):
with self.assertRaises(UnsafePlan) as ctx:
Plan(zone, zone, changes, True).raise_if_unsafe()
- self.assertTrue('Too many updates' in ctx.exception.message)
+ self.assertTrue('Too many updates' in text_type(ctx.exception))
def test_safe_updates_min_existing_pcent(self):
# MAX_SAFE_UPDATE_PCENT is safe when more
@@ -251,7 +252,7 @@ class TestBaseProvider(TestCase):
})
for i in range(int(Plan.MIN_EXISTING_RECORDS)):
- zone.add_record(Record.new(zone, unicode(i), {
+ zone.add_record(Record.new(zone, text_type(i), {
'ttl': 60,
'type': 'A',
'value': '2.3.4.5'
@@ -273,7 +274,7 @@ class TestBaseProvider(TestCase):
})
for i in range(int(Plan.MIN_EXISTING_RECORDS)):
- zone.add_record(Record.new(zone, unicode(i), {
+ zone.add_record(Record.new(zone, text_type(i), {
'ttl': 60,
'type': 'A',
'value': '2.3.4.5'
@@ -286,7 +287,7 @@ class TestBaseProvider(TestCase):
with self.assertRaises(UnsafePlan) as ctx:
Plan(zone, zone, changes, True).raise_if_unsafe()
- self.assertTrue('Too many deletes' in ctx.exception.message)
+ self.assertTrue('Too many deletes' in text_type(ctx.exception))
def test_safe_deletes_min_existing_pcent(self):
# MAX_SAFE_DELETE_PCENT is safe when more
@@ -299,7 +300,7 @@ class TestBaseProvider(TestCase):
})
for i in range(int(Plan.MIN_EXISTING_RECORDS)):
- zone.add_record(Record.new(zone, unicode(i), {
+ zone.add_record(Record.new(zone, text_type(i), {
'ttl': 60,
'type': 'A',
'value': '2.3.4.5'
@@ -322,7 +323,7 @@ class TestBaseProvider(TestCase):
})
for i in range(int(Plan.MIN_EXISTING_RECORDS)):
- zone.add_record(Record.new(zone, unicode(i), {
+ zone.add_record(Record.new(zone, text_type(i), {
'ttl': 60,
'type': 'A',
'value': '2.3.4.5'
@@ -336,7 +337,7 @@ class TestBaseProvider(TestCase):
Plan(zone, zone, changes, True,
update_pcent_threshold=safe_pcent).raise_if_unsafe()
- self.assertTrue('Too many updates' in ctx.exception.message)
+ self.assertTrue('Too many updates' in text_type(ctx.exception))
def test_safe_deletes_min_existing_override(self):
safe_pcent = .4
@@ -350,7 +351,7 @@ class TestBaseProvider(TestCase):
})
for i in range(int(Plan.MIN_EXISTING_RECORDS)):
- zone.add_record(Record.new(zone, unicode(i), {
+ zone.add_record(Record.new(zone, text_type(i), {
'ttl': 60,
'type': 'A',
'value': '2.3.4.5'
@@ -364,4 +365,4 @@ class TestBaseProvider(TestCase):
Plan(zone, zone, changes, True,
delete_pcent_threshold=safe_pcent).raise_if_unsafe()
- self.assertTrue('Too many deletes' in ctx.exception.message)
+ self.assertTrue('Too many deletes' in text_type(ctx.exception))
diff --git a/tests/test_octodns_provider_cloudflare.py b/tests/test_octodns_provider_cloudflare.py
index 25f2b58..3581033 100644
--- a/tests/test_octodns_provider_cloudflare.py
+++ b/tests/test_octodns_provider_cloudflare.py
@@ -9,6 +9,7 @@ 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 six import text_type
from unittest import TestCase
from octodns.record import Record, Update
@@ -65,7 +66,7 @@ class TestCloudflareProvider(TestCase):
provider.populate(zone)
self.assertEquals('CloudflareError', type(ctx.exception).__name__)
- self.assertEquals('request was invalid', ctx.exception.message)
+ self.assertEquals('request was invalid', text_type(ctx.exception))
# Bad auth
with requests_mock() as mock:
@@ -80,7 +81,7 @@ class TestCloudflareProvider(TestCase):
self.assertEquals('CloudflareAuthenticationError',
type(ctx.exception).__name__)
self.assertEquals('Unknown X-Auth-Key or X-Auth-Email',
- ctx.exception.message)
+ text_type(ctx.exception))
# Bad auth, unknown resp
with requests_mock() as mock:
@@ -91,7 +92,7 @@ class TestCloudflareProvider(TestCase):
provider.populate(zone)
self.assertEquals('CloudflareAuthenticationError',
type(ctx.exception).__name__)
- self.assertEquals('Cloudflare error', ctx.exception.message)
+ self.assertEquals('Cloudflare error', text_type(ctx.exception))
# General error
with requests_mock() as mock:
@@ -102,7 +103,7 @@ class TestCloudflareProvider(TestCase):
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
- # Non-existant zone doesn't populate anything
+ # Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=200, json=self.empty)
@@ -110,7 +111,7 @@ class TestCloudflareProvider(TestCase):
provider.populate(zone)
self.assertEquals(set(), zone.records)
- # re-populating the same non-existant zone uses cache and makes no
+ # re-populating the same non-existent zone uses cache and makes no
# calls
again = Zone('unit.tests.', [])
provider.populate(again)
@@ -173,7 +174,7 @@ class TestCloudflareProvider(TestCase):
}, # zone create
] + [None] * 20 # individual record creates
- # non-existant zone, create everything
+ # non-existent zone, create everything
plan = provider.plan(self.expected)
self.assertEquals(12, len(plan.changes))
self.assertEquals(12, provider.apply(plan))
@@ -742,23 +743,25 @@ class TestCloudflareProvider(TestCase):
# 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)
+ ordered = sorted(zone.records, key=lambda r: r.name)
- record = list(zone.records)[1]
+ record = ordered[0]
+ 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)
+
+ record = ordered[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)
+ record = ordered[2]
+ self.assertEquals('multi', record.name)
+ self.assertEquals('multi.unit.tests.', record.fqdn)
self.assertEquals('CNAME', record._type)
- self.assertEquals('a.unit.tests.cdn.cloudflare.net.', record.value)
+ self.assertEquals('multi.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.
@@ -950,7 +953,7 @@ class TestCloudflareProvider(TestCase):
'value': 'ns1.unit.tests.'
})
- data = provider._gen_data(record).next()
+ data = next(provider._gen_data(record))
self.assertFalse('proxied' in data)
@@ -965,7 +968,7 @@ class TestCloudflareProvider(TestCase):
}), False
)
- data = provider._gen_data(record).next()
+ data = next(provider._gen_data(record))
self.assertFalse(data['proxied'])
@@ -980,7 +983,7 @@ class TestCloudflareProvider(TestCase):
}), True
)
- data = provider._gen_data(record).next()
+ data = next(provider._gen_data(record))
self.assertTrue(data['proxied'])
diff --git a/tests/test_octodns_provider_constellix.py b/tests/test_octodns_provider_constellix.py
new file mode 100644
index 0000000..2c5cf26
--- /dev/null
+++ b/tests/test_octodns_provider_constellix.py
@@ -0,0 +1,233 @@
+#
+#
+#
+
+
+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 six import text_type
+from unittest import TestCase
+
+from octodns.record import Record
+from octodns.provider.constellix import ConstellixClientNotFound, \
+ ConstellixProvider
+from octodns.provider.yaml import YamlProvider
+from octodns.zone import Zone
+
+import json
+
+
+class TestConstellixProvider(TestCase):
+ expected = Zone('unit.tests.', [])
+ source = YamlProvider('test', join(dirname(__file__), 'config'))
+ source.populate(expected)
+
+ # Our test suite differs a bit, add our NS and remove the simple one
+ expected.add_record(Record.new(expected, 'under', {
+ 'ttl': 3600,
+ 'type': 'NS',
+ 'values': [
+ 'ns1.unit.tests.',
+ 'ns2.unit.tests.',
+ ]
+ }))
+
+ # Add some ALIAS records
+ expected.add_record(Record.new(expected, '', {
+ 'ttl': 1800,
+ 'type': 'ALIAS',
+ 'value': 'aname.unit.tests.'
+ }))
+
+ expected.add_record(Record.new(expected, 'sub', {
+ 'ttl': 1800,
+ 'type': 'ALIAS',
+ 'value': 'aname.unit.tests.'
+ }))
+
+ for record in list(expected.records):
+ if record.name == 'sub' and record._type == 'NS':
+ expected._remove_record(record)
+ break
+
+ def test_populate(self):
+ provider = ConstellixProvider('test', 'api', 'secret')
+
+ # Bad auth
+ with requests_mock() as mock:
+ mock.get(ANY, status_code=401,
+ text='{"errors": ["Unable to authenticate token"]}')
+
+ with self.assertRaises(Exception) as ctx:
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals('Unauthorized', text_type(ctx.exception))
+
+ # Bad request
+ with requests_mock() as mock:
+ mock.get(ANY, status_code=400,
+ text='{"errors": ["\\"unittests\\" is not '
+ 'a valid domain name"]}')
+
+ with self.assertRaises(Exception) as ctx:
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals('\n - "unittests" is not a valid domain name',
+ text_type(ctx.exception))
+
+ # General error
+ with requests_mock() as mock:
+ mock.get(ANY, status_code=502, text='Things caught fire')
+
+ with self.assertRaises(HTTPError) as ctx:
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals(502, ctx.exception.response.status_code)
+
+ # Non-existent zone doesn't populate anything
+ with requests_mock() as mock:
+ mock.get(ANY, status_code=404,
+ text='')
+
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals(set(), zone.records)
+
+ # No diffs == no changes
+ with requests_mock() as mock:
+ base = 'https://api.dns.constellix.com/v1/domains'
+ with open('tests/fixtures/constellix-domains.json') as fh:
+ mock.get('{}{}'.format(base, '/'), text=fh.read())
+ with open('tests/fixtures/constellix-records.json') as fh:
+ mock.get('{}{}'.format(base, '/123123/records'),
+ text=fh.read())
+
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals(15, len(zone.records))
+ changes = self.expected.changes(zone, provider)
+ self.assertEquals(0, len(changes))
+
+ # 2nd populate makes no network calls/all from cache
+ again = Zone('unit.tests.', [])
+ provider.populate(again)
+ self.assertEquals(15, len(again.records))
+
+ # bust the cache
+ del provider._zone_records[zone.name]
+
+ def test_apply(self):
+ provider = ConstellixProvider('test', 'api', 'secret')
+
+ resp = Mock()
+ resp.json = Mock()
+ provider._client._request = Mock(return_value=resp)
+
+ with open('tests/fixtures/constellix-domains.json') as fh:
+ domains = json.load(fh)
+
+ # non-existent domain, create everything
+ resp.json.side_effect = [
+ ConstellixClientNotFound, # no zone in populate
+ ConstellixClientNotFound, # no domain during apply
+ domains
+ ]
+ plan = provider.plan(self.expected)
+
+ # No root NS, no ignored, no excluded, no unsupported
+ n = len(self.expected.records) - 5
+ self.assertEquals(n, len(plan.changes))
+ self.assertEquals(n, provider.apply(plan))
+
+ provider._client._request.assert_has_calls([
+ # created the domain
+ call('POST', '/', data={'names': ['unit.tests']}),
+ # get all domains to build the cache
+ call('GET', '/'),
+ ])
+ # These two checks are broken up so that ordering doesn't break things.
+ # Python3 doesn't make the calls in a consistent order so different
+ # things follow the GET / on different runs
+ provider._client._request.assert_has_calls([
+ call('POST', '/123123/records/SRV', data={
+ 'roundRobin': [{
+ 'priority': 10,
+ 'weight': 20,
+ 'value': 'foo-1.unit.tests.',
+ 'port': 30
+ }, {
+ 'priority': 12,
+ 'weight': 20,
+ 'value': 'foo-2.unit.tests.',
+ 'port': 30
+ }],
+ 'name': '_srv._tcp',
+ 'ttl': 600,
+ }),
+ ])
+
+ self.assertEquals(20, provider._client._request.call_count)
+
+ provider._client._request.reset_mock()
+
+ provider._client.records = Mock(return_value=[
+ {
+ 'id': 11189897,
+ 'type': 'A',
+ 'name': 'www',
+ 'ttl': 300,
+ 'value': [
+ '1.2.3.4',
+ '2.2.3.4',
+ ]
+ }, {
+ 'id': 11189898,
+ 'type': 'A',
+ 'name': 'ttl',
+ 'ttl': 600,
+ 'value': [
+ '3.2.3.4'
+ ]
+ }, {
+ 'id': 11189899,
+ 'type': 'ALIAS',
+ 'name': 'alias',
+ 'ttl': 600,
+ 'value': [{
+ 'value': 'aname.unit.tests.'
+ }]
+ }
+ ])
+
+ # 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(3, len(plan.changes))
+ self.assertEquals(3, provider.apply(plan))
+
+ # recreate for update, and deletes for the 2 parts of the other
+ provider._client._request.assert_has_calls([
+ call('POST', '/123123/records/A', data={
+ 'roundRobin': [{
+ 'value': '3.2.3.4'
+ }],
+ 'name': 'ttl',
+ 'ttl': 300
+ }),
+ call('DELETE', '/123123/records/A/11189897'),
+ call('DELETE', '/123123/records/A/11189898'),
+ call('DELETE', '/123123/records/ANAME/11189899')
+ ], any_order=True)
diff --git a/tests/test_octodns_provider_digitalocean.py b/tests/test_octodns_provider_digitalocean.py
index ddc6bc2..ebb5319 100644
--- a/tests/test_octodns_provider_digitalocean.py
+++ b/tests/test_octodns_provider_digitalocean.py
@@ -10,6 +10,7 @@ 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 six import text_type
from unittest import TestCase
from octodns.record import Record
@@ -50,7 +51,7 @@ class TestDigitalOceanProvider(TestCase):
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
- self.assertEquals('Unauthorized', ctx.exception.message)
+ self.assertEquals('Unauthorized', text_type(ctx.exception))
# General error
with requests_mock() as mock:
@@ -61,7 +62,7 @@ class TestDigitalOceanProvider(TestCase):
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
- # Non-existant zone doesn't populate anything
+ # Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='{"id":"not_found","message":"The resource you '
@@ -153,7 +154,7 @@ class TestDigitalOceanProvider(TestCase):
}
}
- # non-existant domain, create everything
+ # non-existent domain, create everything
resp.json.side_effect = [
DigitalOceanClientNotFound, # no zone in populate
DigitalOceanClientNotFound, # no domain during apply
@@ -175,7 +176,20 @@ class TestDigitalOceanProvider(TestCase):
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
+ # created at least some of the record with expected data
+ call('POST', '/domains/unit.tests/records', data={
+ 'data': '1.2.3.4',
+ 'name': '@',
+ 'ttl': 300, 'type': 'A'}),
+ call('POST', '/domains/unit.tests/records', data={
+ 'data': '1.2.3.5',
+ 'name': '@',
+ 'ttl': 300, 'type': 'A'}),
+ call('POST', '/domains/unit.tests/records', data={
+ 'data': 'ca.unit.tests.',
+ 'flags': 0, 'name': '@',
+ 'tag': 'issue',
+ 'ttl': 3600, 'type': 'CAA'}),
call('POST', '/domains/unit.tests/records', data={
'name': '_srv._tcp',
'weight': 20,
diff --git a/tests/test_octodns_provider_dnsimple.py b/tests/test_octodns_provider_dnsimple.py
index 896425e..e3a9b8d 100644
--- a/tests/test_octodns_provider_dnsimple.py
+++ b/tests/test_octodns_provider_dnsimple.py
@@ -9,6 +9,7 @@ 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 six import text_type
from unittest import TestCase
from octodns.record import Record
@@ -47,7 +48,7 @@ class TestDnsimpleProvider(TestCase):
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
- self.assertEquals('Unauthorized', ctx.exception.message)
+ self.assertEquals('Unauthorized', text_type(ctx.exception))
# General error
with requests_mock() as mock:
@@ -58,7 +59,7 @@ class TestDnsimpleProvider(TestCase):
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
- # Non-existant zone doesn't populate anything
+ # Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='{"message": "Domain `foo.bar` not found"}')
@@ -122,7 +123,7 @@ class TestDnsimpleProvider(TestCase):
resp.json = Mock()
provider._client._request = Mock(return_value=resp)
- # non-existant domain, create everything
+ # non-existent domain, create everything
resp.json.side_effect = [
DnsimpleClientNotFound, # no zone in populate
DnsimpleClientNotFound, # no domain during apply
@@ -138,7 +139,32 @@ class TestDnsimpleProvider(TestCase):
provider._client._request.assert_has_calls([
# created the domain
call('POST', '/domains', data={'name': 'unit.tests'}),
- # created at least one of the record with expected data
+ # created at least some of the record with expected data
+ call('POST', '/zones/unit.tests/records', data={
+ 'content': '1.2.3.4',
+ 'type': 'A',
+ 'name': '',
+ 'ttl': 300}),
+ call('POST', '/zones/unit.tests/records', data={
+ 'content': '1.2.3.5',
+ 'type': 'A',
+ 'name': '',
+ 'ttl': 300}),
+ call('POST', '/zones/unit.tests/records', data={
+ 'content': '0 issue "ca.unit.tests"',
+ 'type': 'CAA',
+ 'name': '',
+ 'ttl': 3600}),
+ call('POST', '/zones/unit.tests/records', data={
+ 'content': '1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49',
+ 'type': 'SSHFP',
+ 'name': '',
+ 'ttl': 3600}),
+ call('POST', '/zones/unit.tests/records', data={
+ 'content': '1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73',
+ 'type': 'SSHFP',
+ 'name': '',
+ 'ttl': 3600}),
call('POST', '/zones/unit.tests/records', data={
'content': '20 30 foo-1.unit.tests.',
'priority': 10,
diff --git a/tests/test_octodns_provider_dnsmadeeasy.py b/tests/test_octodns_provider_dnsmadeeasy.py
index 04cf0ee..ba61b94 100644
--- a/tests/test_octodns_provider_dnsmadeeasy.py
+++ b/tests/test_octodns_provider_dnsmadeeasy.py
@@ -10,6 +10,7 @@ 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 six import text_type
from unittest import TestCase
from octodns.record import Record
@@ -65,7 +66,7 @@ class TestDnsMadeEasyProvider(TestCase):
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
- self.assertEquals('Unauthorized', ctx.exception.message)
+ self.assertEquals('Unauthorized', text_type(ctx.exception))
# Bad request
with requests_mock() as mock:
@@ -76,7 +77,7 @@ class TestDnsMadeEasyProvider(TestCase):
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals('\n - Rate limit exceeded',
- ctx.exception.message)
+ text_type(ctx.exception))
# General error
with requests_mock() as mock:
@@ -87,7 +88,7 @@ class TestDnsMadeEasyProvider(TestCase):
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
- # Non-existant zone doesn't populate anything
+ # Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='')
@@ -130,7 +131,7 @@ class TestDnsMadeEasyProvider(TestCase):
with open('tests/fixtures/dnsmadeeasy-domains.json') as fh:
domains = json.load(fh)
- # non-existant domain, create everything
+ # non-existent domain, create everything
resp.json.side_effect = [
DnsMadeEasyClientNotFound, # no zone in populate
DnsMadeEasyClientNotFound, # no domain during apply
@@ -148,7 +149,27 @@ class TestDnsMadeEasyProvider(TestCase):
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
+ # created at least some of the record with expected data
+ call('POST', '/123123/records', data={
+ 'type': 'A',
+ 'name': '',
+ 'value': '1.2.3.4',
+ 'ttl': 300}),
+ call('POST', '/123123/records', data={
+ 'type': 'A',
+ 'name': '',
+ 'value': '1.2.3.5',
+ 'ttl': 300}),
+ call('POST', '/123123/records', data={
+ 'type': 'ANAME',
+ 'name': '',
+ 'value': 'aname.unit.tests.',
+ 'ttl': 1800}),
+ call('POST', '/123123/records', data={
+ 'name': '',
+ 'value': 'ca.unit.tests',
+ 'issuerCritical': 0, 'caaType': 'issue',
+ 'ttl': 3600, 'type': 'CAA'}),
call('POST', '/123123/records', data={
'name': '_srv._tcp',
'weight': 20,
diff --git a/tests/test_octodns_provider_dyn.py b/tests/test_octodns_provider_dyn.py
index 4f224fc..7c023fd 100644
--- a/tests/test_octodns_provider_dyn.py
+++ b/tests/test_octodns_provider_dyn.py
@@ -670,8 +670,8 @@ class TestDynProviderGeo(TestCase):
tds = provider.traffic_directors
self.assertEquals(set(['unit.tests.', 'geo.unit.tests.']),
set(tds.keys()))
- self.assertEquals(['A'], tds['unit.tests.'].keys())
- self.assertEquals(['A'], tds['geo.unit.tests.'].keys())
+ self.assertEquals(['A'], list(tds['unit.tests.'].keys()))
+ self.assertEquals(['A'], list(tds['geo.unit.tests.'].keys()))
provider.log.warn.assert_called_with("Unsupported TrafficDirector "
"'%s'", 'something else')
diff --git a/tests/test_octodns_provider_fastdns.py b/tests/test_octodns_provider_fastdns.py
new file mode 100644
index 0000000..a8bed74
--- /dev/null
+++ b/tests/test_octodns_provider_fastdns.py
@@ -0,0 +1,151 @@
+#
+#
+#
+
+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 six import text_type
+from unittest import TestCase
+
+from octodns.record import Record
+from octodns.provider.fastdns import AkamaiProvider
+from octodns.provider.yaml import YamlProvider
+from octodns.zone import Zone
+
+
+class TestFastdnsProvider(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 = AkamaiProvider("test", "secret", "akam.com", "atok", "ctok")
+
+ # Bad Auth
+ with requests_mock() as mock:
+ mock.get(ANY, status_code=401, text='{"message": "Unauthorized"}')
+
+ with self.assertRaises(Exception) as ctx:
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+
+ self.assertEquals(401, ctx.exception.response.status_code)
+
+ # 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='{"message": "Domain `foo.bar` not found"}')
+
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals(set(), zone.records)
+
+ # No diffs == no changes
+ with requests_mock() as mock:
+
+ with open('tests/fixtures/fastdns-records.json') as fh:
+ mock.get(ANY, text=fh.read())
+
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals(16, 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(16, len(again.records))
+
+ # bust the cache
+ del provider._zone_records[zone.name]
+
+ def test_apply(self):
+ provider = AkamaiProvider("test", "s", "akam.com", "atok", "ctok",
+ "cid", "gid")
+
+ # tests create update delete through previous state config json
+ with requests_mock() as mock:
+
+ with open('tests/fixtures/fastdns-records-prev.json') as fh:
+ mock.get(ANY, text=fh.read())
+
+ plan = provider.plan(self.expected)
+ mock.post(ANY, status_code=201)
+ mock.put(ANY, status_code=200)
+ mock.delete(ANY, status_code=204)
+
+ changes = provider.apply(plan)
+ self.assertEquals(29, changes)
+
+ # Test against a zone that doesn't exist yet
+ with requests_mock() as mock:
+ with open('tests/fixtures/fastdns-records-prev-other.json') as fh:
+ mock.get(ANY, status_code=404)
+
+ plan = provider.plan(self.expected)
+ mock.post(ANY, status_code=201)
+ mock.put(ANY, status_code=200)
+ mock.delete(ANY, status_code=204)
+
+ changes = provider.apply(plan)
+ self.assertEquals(14, changes)
+
+ # Test against a zone that doesn't exist yet, but gid not provided
+ with requests_mock() as mock:
+ with open('tests/fixtures/fastdns-records-prev-other.json') as fh:
+ mock.get(ANY, status_code=404)
+ provider = AkamaiProvider("test", "s", "akam.com", "atok", "ctok",
+ "cid")
+ plan = provider.plan(self.expected)
+ mock.post(ANY, status_code=201)
+ mock.put(ANY, status_code=200)
+ mock.delete(ANY, status_code=204)
+
+ changes = provider.apply(plan)
+ self.assertEquals(14, changes)
+
+ # Test against a zone that doesn't exist, but cid not provided
+
+ with requests_mock() as mock:
+ mock.get(ANY, status_code=404)
+
+ provider = AkamaiProvider("test", "s", "akam.com", "atok", "ctok")
+ plan = provider.plan(self.expected)
+ mock.post(ANY, status_code=201)
+ mock.put(ANY, status_code=200)
+ mock.delete(ANY, status_code=204)
+
+ try:
+ changes = provider.apply(plan)
+ except NameError as e:
+ expected = "contractId not specified to create zone"
+ self.assertEquals(text_type(e), expected)
diff --git a/tests/test_octodns_provider_googlecloud.py b/tests/test_octodns_provider_googlecloud.py
index 3a3e600..e642668 100644
--- a/tests/test_octodns_provider_googlecloud.py
+++ b/tests/test_octodns_provider_googlecloud.py
@@ -193,8 +193,13 @@ class DummyIterator:
def __iter__(self):
return self
+ # python2
def next(self):
- return self.iterable.next()
+ return next(self.iterable)
+
+ # python3
+ def __next__(self):
+ return next(self.iterable)
class TestGoogleCloudProvider(TestCase):
@@ -247,7 +252,7 @@ class TestGoogleCloudProvider(TestCase):
return_values_for_status = iter(
["pending"] * 11 + ['done', 'done'])
type(status_mock).status = PropertyMock(
- side_effect=return_values_for_status.next)
+ side_effect=lambda: next(return_values_for_status))
gcloud_zone_mock.changes = Mock(return_value=status_mock)
provider = self._get_provider()
diff --git a/tests/test_octodns_provider_mythicbeasts.py b/tests/test_octodns_provider_mythicbeasts.py
new file mode 100644
index 0000000..960bd65
--- /dev/null
+++ b/tests/test_octodns_provider_mythicbeasts.py
@@ -0,0 +1,451 @@
+#
+#
+#
+
+from __future__ import absolute_import, division, print_function, \
+ unicode_literals
+
+from os.path import dirname, join
+
+from requests_mock import ANY, mock as requests_mock
+from six import text_type
+from unittest import TestCase
+
+from octodns.provider.mythicbeasts import MythicBeastsProvider, \
+ add_trailing_dot, remove_trailing_dot
+from octodns.provider.yaml import YamlProvider
+from octodns.zone import Zone
+from octodns.record import Create, Update, Delete, Record
+
+
+class TestMythicBeastsProvider(TestCase):
+ expected = Zone('unit.tests.', [])
+ source = YamlProvider('test_expected', join(dirname(__file__), 'config'))
+ source.populate(expected)
+
+ # Dump anything we don't support from expected
+ for record in list(expected.records):
+ if record._type not in MythicBeastsProvider.SUPPORTS:
+ expected._remove_record(record)
+
+ def test_trailing_dot(self):
+ with self.assertRaises(AssertionError) as err:
+ add_trailing_dot('unit.tests.')
+ self.assertEquals('Value already has trailing dot',
+ text_type(err.exception))
+
+ with self.assertRaises(AssertionError) as err:
+ remove_trailing_dot('unit.tests')
+ self.assertEquals('Value already missing trailing dot',
+ text_type(err.exception))
+
+ self.assertEquals(add_trailing_dot('unit.tests'), 'unit.tests.')
+ self.assertEquals(remove_trailing_dot('unit.tests.'), 'unit.tests')
+
+ def test_data_for_single(self):
+ test_data = {
+ 'raw_values': [{'value': 'a:a::c', 'ttl': 0}],
+ 'zone': 'unit.tests.',
+ }
+ test_single = MythicBeastsProvider._data_for_single('', test_data)
+ self.assertTrue(isinstance(test_single, dict))
+ self.assertEquals('a:a::c', test_single['value'])
+
+ def test_data_for_multiple(self):
+ test_data = {
+ 'raw_values': [
+ {'value': 'b:b::d', 'ttl': 60},
+ {'value': 'a:a::c', 'ttl': 60}],
+ 'zone': 'unit.tests.',
+ }
+ test_multiple = MythicBeastsProvider._data_for_multiple('', test_data)
+ self.assertTrue(isinstance(test_multiple, dict))
+ self.assertEquals(2, len(test_multiple['values']))
+
+ def test_data_for_txt(self):
+ test_data = {
+ 'raw_values': [
+ {'value': 'v=DKIM1; k=rsa; p=prawf', 'ttl': 60},
+ {'value': 'prawf prawf dyma prawf', 'ttl': 300}],
+ 'zone': 'unit.tests.',
+ }
+ test_txt = MythicBeastsProvider._data_for_TXT('', test_data)
+ self.assertTrue(isinstance(test_txt, dict))
+ self.assertEquals(2, len(test_txt['values']))
+ self.assertEquals('v=DKIM1\\; k=rsa\\; p=prawf', test_txt['values'][0])
+
+ def test_data_for_MX(self):
+ test_data = {
+ 'raw_values': [
+ {'value': '10 un.unit', 'ttl': 60},
+ {'value': '20 dau.unit', 'ttl': 60},
+ {'value': '30 tri.unit', 'ttl': 60}],
+ 'zone': 'unit.tests.',
+ }
+ test_MX = MythicBeastsProvider._data_for_MX('', test_data)
+ self.assertTrue(isinstance(test_MX, dict))
+ self.assertEquals(3, len(test_MX['values']))
+
+ with self.assertRaises(AssertionError) as err:
+ test_MX = MythicBeastsProvider._data_for_MX(
+ '',
+ {'raw_values': [{'value': '', 'ttl': 0}]}
+ )
+ self.assertEquals('Unable to parse MX data',
+ text_type(err.exception))
+
+ def test_data_for_CNAME(self):
+ test_data = {
+ 'raw_values': [{'value': 'cname', 'ttl': 60}],
+ 'zone': 'unit.tests.',
+ }
+ test_cname = MythicBeastsProvider._data_for_CNAME('', test_data)
+ self.assertTrue(isinstance(test_cname, dict))
+ self.assertEquals('cname.unit.tests.', test_cname['value'])
+
+ def test_data_for_ANAME(self):
+ test_data = {
+ 'raw_values': [{'value': 'aname', 'ttl': 60}],
+ 'zone': 'unit.tests.',
+ }
+ test_aname = MythicBeastsProvider._data_for_ANAME('', test_data)
+ self.assertTrue(isinstance(test_aname, dict))
+ self.assertEquals('aname', test_aname['value'])
+
+ def test_data_for_SRV(self):
+ test_data = {
+ 'raw_values': [
+ {'value': '10 20 30 un.srv.unit', 'ttl': 60},
+ {'value': '20 30 40 dau.srv.unit', 'ttl': 60},
+ {'value': '30 30 50 tri.srv.unit', 'ttl': 60}],
+ 'zone': 'unit.tests.',
+ }
+ test_SRV = MythicBeastsProvider._data_for_SRV('', test_data)
+ self.assertTrue(isinstance(test_SRV, dict))
+ self.assertEquals(3, len(test_SRV['values']))
+
+ with self.assertRaises(AssertionError) as err:
+ test_SRV = MythicBeastsProvider._data_for_SRV(
+ '',
+ {'raw_values': [{'value': '', 'ttl': 0}]}
+ )
+ self.assertEquals('Unable to parse SRV data',
+ text_type(err.exception))
+
+ def test_data_for_SSHFP(self):
+ test_data = {
+ 'raw_values': [
+ {'value': '1 1 0123456789abcdef', 'ttl': 60},
+ {'value': '1 2 0123456789abcdef', 'ttl': 60},
+ {'value': '2 3 0123456789abcdef', 'ttl': 60}],
+ 'zone': 'unit.tests.',
+ }
+ test_SSHFP = MythicBeastsProvider._data_for_SSHFP('', test_data)
+ self.assertTrue(isinstance(test_SSHFP, dict))
+ self.assertEquals(3, len(test_SSHFP['values']))
+
+ with self.assertRaises(AssertionError) as err:
+ test_SSHFP = MythicBeastsProvider._data_for_SSHFP(
+ '',
+ {'raw_values': [{'value': '', 'ttl': 0}]}
+ )
+ self.assertEquals('Unable to parse SSHFP data',
+ text_type(err.exception))
+
+ def test_data_for_CAA(self):
+ test_data = {
+ 'raw_values': [{'value': '1 issue letsencrypt.org', 'ttl': 60}],
+ 'zone': 'unit.tests.',
+ }
+ test_CAA = MythicBeastsProvider._data_for_CAA('', test_data)
+ self.assertTrue(isinstance(test_CAA, dict))
+ self.assertEquals(3, len(test_CAA['value']))
+
+ with self.assertRaises(AssertionError) as err:
+ test_CAA = MythicBeastsProvider._data_for_CAA(
+ '',
+ {'raw_values': [{'value': '', 'ttl': 0}]}
+ )
+ self.assertEquals('Unable to parse CAA data',
+ text_type(err.exception))
+
+ def test_command_generation(self):
+ zone = Zone('unit.tests.', [])
+ zone.add_record(Record.new(zone, 'prawf-alias', {
+ 'ttl': 60,
+ 'type': 'ALIAS',
+ 'value': 'alias.unit.tests.',
+ }))
+ zone.add_record(Record.new(zone, 'prawf-ns', {
+ 'ttl': 300,
+ 'type': 'NS',
+ 'values': [
+ 'alias.unit.tests.',
+ 'alias2.unit.tests.',
+ ],
+ }))
+ zone.add_record(Record.new(zone, 'prawf-a', {
+ 'ttl': 60,
+ 'type': 'A',
+ 'values': [
+ '1.2.3.4',
+ '5.6.7.8',
+ ],
+ }))
+ zone.add_record(Record.new(zone, 'prawf-aaaa', {
+ 'ttl': 60,
+ 'type': 'AAAA',
+ 'values': [
+ 'a:a::a',
+ 'b:b::b',
+ 'c:c::c:c',
+ ],
+ }))
+ zone.add_record(Record.new(zone, 'prawf-txt', {
+ 'ttl': 60,
+ 'type': 'TXT',
+ 'value': 'prawf prawf dyma prawf',
+ }))
+ zone.add_record(Record.new(zone, 'prawf-txt2', {
+ 'ttl': 60,
+ 'type': 'TXT',
+ 'value': 'v=DKIM1\\; k=rsa\\; p=prawf',
+ }))
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=200, text='')
+
+ provider = MythicBeastsProvider('test', {
+ 'unit.tests.': 'mypassword'
+ })
+
+ plan = provider.plan(zone)
+ changes = plan.changes
+ generated_commands = []
+
+ for change in changes:
+ generated_commands.extend(
+ provider._compile_commands('ADD', change.new)
+ )
+
+ expected_commands = [
+ 'ADD prawf-alias.unit.tests 60 ANAME alias.unit.tests.',
+ 'ADD prawf-ns.unit.tests 300 NS alias.unit.tests.',
+ 'ADD prawf-ns.unit.tests 300 NS alias2.unit.tests.',
+ 'ADD prawf-a.unit.tests 60 A 1.2.3.4',
+ 'ADD prawf-a.unit.tests 60 A 5.6.7.8',
+ 'ADD prawf-aaaa.unit.tests 60 AAAA a:a::a',
+ 'ADD prawf-aaaa.unit.tests 60 AAAA b:b::b',
+ 'ADD prawf-aaaa.unit.tests 60 AAAA c:c::c:c',
+ 'ADD prawf-txt.unit.tests 60 TXT prawf prawf dyma prawf',
+ 'ADD prawf-txt2.unit.tests 60 TXT v=DKIM1; k=rsa; p=prawf',
+ ]
+
+ generated_commands.sort()
+ expected_commands.sort()
+
+ self.assertEquals(
+ generated_commands,
+ expected_commands
+ )
+
+ # Now test deletion
+ existing = 'prawf-txt 300 TXT prawf prawf dyma prawf\n' \
+ 'prawf-txt2 300 TXT v=DKIM1; k=rsa; p=prawf\n' \
+ 'prawf-a 60 A 1.2.3.4'
+
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=200, text=existing)
+ wanted = Zone('unit.tests.', [])
+
+ plan = provider.plan(wanted)
+ changes = plan.changes
+ generated_commands = []
+
+ for change in changes:
+ generated_commands.extend(
+ provider._compile_commands('DELETE', change.existing)
+ )
+
+ expected_commands = [
+ 'DELETE prawf-a.unit.tests 60 A 1.2.3.4',
+ 'DELETE prawf-txt.unit.tests 300 TXT prawf prawf dyma prawf',
+ 'DELETE prawf-txt2.unit.tests 300 TXT v=DKIM1; k=rsa; p=prawf',
+ ]
+
+ generated_commands.sort()
+ expected_commands.sort()
+
+ self.assertEquals(
+ generated_commands,
+ expected_commands
+ )
+
+ def test_fake_command_generation(self):
+ class FakeChangeRecord(object):
+ def __init__(self):
+ self.__fqdn = 'prawf.unit.tests.'
+ self._type = 'NOOP'
+ self.value = 'prawf'
+ self.ttl = 60
+
+ @property
+ def record(self):
+ return self
+
+ @property
+ def fqdn(self):
+ return self.__fqdn
+
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=200, text='')
+
+ provider = MythicBeastsProvider('test', {
+ 'unit.tests.': 'mypassword'
+ })
+ record = FakeChangeRecord()
+ command = provider._compile_commands('ADD', record)
+ self.assertEquals([], command)
+
+ def test_populate(self):
+ provider = None
+
+ # Null passwords dict
+ with self.assertRaises(AssertionError) as err:
+ provider = MythicBeastsProvider('test', None)
+ self.assertEquals('Passwords must be a dictionary',
+ text_type(err.exception))
+
+ # Missing password
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=401, text='ERR Not authenticated')
+
+ with self.assertRaises(AssertionError) as err:
+ provider = MythicBeastsProvider('test', dict())
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals(
+ 'Missing password for domain: unit.tests',
+ text_type(err.exception))
+
+ # Failed authentication
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=401, text='ERR Not authenticated')
+
+ with self.assertRaises(Exception) as err:
+ provider = MythicBeastsProvider('test', {
+ 'unit.tests.': 'mypassword'
+ })
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals(
+ 'Mythic Beasts unauthorized for zone: unit.tests',
+ err.exception.message)
+
+ # Check unmatched lines are ignored
+ test_data = 'This should not match'
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=200, text=test_data)
+
+ provider = MythicBeastsProvider('test', {
+ 'unit.tests.': 'mypassword'
+ })
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals(0, len(zone.records))
+
+ # Check unsupported records are skipped
+ test_data = '@ 60 NOOP prawf\n@ 60 SPF prawf prawf prawf'
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=200, text=test_data)
+
+ provider = MythicBeastsProvider('test', {
+ 'unit.tests.': 'mypassword'
+ })
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+ self.assertEquals(0, len(zone.records))
+
+ # Check no changes between what we support and what's parsed
+ # from the unit.tests. config YAML. Also make sure we see the same
+ # for both after we've thrown away records we don't support
+ with requests_mock() as mock:
+ with open('tests/fixtures/mythicbeasts-list.txt') as file_handle:
+ mock.post(ANY, status_code=200, text=file_handle.read())
+
+ provider = MythicBeastsProvider('test', {
+ 'unit.tests.': 'mypassword'
+ })
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone)
+
+ self.assertEquals(15, len(zone.records))
+ self.assertEquals(15, len(self.expected.records))
+ changes = self.expected.changes(zone, provider)
+ self.assertEquals(0, len(changes))
+
+ def test_apply(self):
+ provider = MythicBeastsProvider('test', {
+ 'unit.tests.': 'mypassword'
+ })
+ zone = Zone('unit.tests.', [])
+
+ # Create blank zone
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=200, text='')
+ provider.populate(zone)
+
+ self.assertEquals(0, len(zone.records))
+
+ # Record change failed
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=200, text='')
+ provider.populate(zone)
+ zone.add_record(Record.new(zone, 'prawf', {
+ 'ttl': 300,
+ 'type': 'TXT',
+ 'value': 'prawf',
+ }))
+ plan = provider.plan(zone)
+
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=400, text='NADD 300 TXT prawf')
+
+ with self.assertRaises(Exception) as err:
+ provider.apply(plan)
+ self.assertEquals(
+ 'Mythic Beasts could not action command: unit.tests '
+ 'ADD prawf.unit.tests 300 TXT prawf', err.exception.message)
+
+ # Check deleting and adding/changing test record
+ existing = 'prawf 300 TXT prawf prawf prawf\ndileu 300 TXT dileu'
+
+ with requests_mock() as mock:
+ mock.post(ANY, status_code=200, text=existing)
+
+ # Mash up a new zone with records so a plan
+ # is generated with changes and applied. For some reason
+ # passing self.expected, or just changing each record's zone
+ # doesn't work. Nor does this without a single add_record after
+ wanted = Zone('unit.tests.', [])
+ for record in list(self.expected.records):
+ data = {'type': record._type}
+ data.update(record.data)
+ wanted.add_record(Record.new(wanted, record.name, data))
+
+ wanted.add_record(Record.new(wanted, 'prawf', {
+ 'ttl': 60,
+ 'type': 'TXT',
+ 'value': 'prawf yw e',
+ }))
+
+ plan = provider.plan(wanted)
+
+ # Octo ignores NS records (15-1)
+ self.assertEquals(1, len([c for c in plan.changes
+ if isinstance(c, Update)]))
+ self.assertEquals(1, len([c for c in plan.changes
+ if isinstance(c, Delete)]))
+ self.assertEquals(14, len([c for c in plan.changes
+ if isinstance(c, Create)]))
+ self.assertEquals(16, provider.apply(plan))
+ self.assertTrue(plan.exists)
diff --git a/tests/test_octodns_provider_ns1.py b/tests/test_octodns_provider_ns1.py
index 8530b62..0743943 100644
--- a/tests/test_octodns_provider_ns1.py
+++ b/tests/test_octodns_provider_ns1.py
@@ -5,24 +5,18 @@
from __future__ import absolute_import, division, print_function, \
unicode_literals
-from mock import Mock, call, patch
-from nsone.rest.errors import AuthException, RateLimitException, \
+from collections import defaultdict
+from mock import call, patch
+from ns1.rest.errors import AuthException, RateLimitException, \
ResourceException
+from six import text_type
from unittest import TestCase
from octodns.record import Delete, Record, Update
-from octodns.provider.ns1 import Ns1Provider
+from octodns.provider.ns1 import Ns1Client, Ns1Provider
from octodns.zone import Zone
-class DummyZone(object):
-
- def __init__(self, records):
- self.data = {
- 'records': records
- }
-
-
class TestNs1Provider(TestCase):
zone = Zone('unit.tests.', [])
expected = set()
@@ -115,7 +109,7 @@ class TestNs1Provider(TestCase):
},
}))
- nsone_records = [{
+ ns1_records = [{
'type': 'A',
'ttl': 32,
'short_answers': ['1.2.3.4'],
@@ -171,43 +165,40 @@ class TestNs1Provider(TestCase):
'domain': 'unit.tests.',
}]
- @patch('nsone.NSONE.loadZone')
- def test_populate(self, load_mock):
+ @patch('ns1.rest.zones.Zones.retrieve')
+ def test_populate(self, zone_retrieve_mock):
provider = Ns1Provider('test', 'api-key')
# Bad auth
- load_mock.side_effect = AuthException('unauthorized')
+ zone_retrieve_mock.side_effect = AuthException('unauthorized')
zone = Zone('unit.tests.', [])
with self.assertRaises(AuthException) as ctx:
provider.populate(zone)
- self.assertEquals(load_mock.side_effect, ctx.exception)
+ self.assertEquals(zone_retrieve_mock.side_effect, ctx.exception)
# General error
- load_mock.reset_mock()
- load_mock.side_effect = ResourceException('boom')
+ zone_retrieve_mock.reset_mock()
+ zone_retrieve_mock.side_effect = ResourceException('boom')
zone = Zone('unit.tests.', [])
with self.assertRaises(ResourceException) as ctx:
provider.populate(zone)
- self.assertEquals(load_mock.side_effect, ctx.exception)
- self.assertEquals(('unit.tests',), load_mock.call_args[0])
+ self.assertEquals(zone_retrieve_mock.side_effect, ctx.exception)
+ self.assertEquals(('unit.tests',), zone_retrieve_mock.call_args[0])
- # Non-existant zone doesn't populate anything
- load_mock.reset_mock()
- load_mock.side_effect = \
+ # Non-existent zone doesn't populate anything
+ zone_retrieve_mock.reset_mock()
+ zone_retrieve_mock.side_effect = \
ResourceException('server error: zone not found')
zone = Zone('unit.tests.', [])
exists = provider.populate(zone)
self.assertEquals(set(), zone.records)
- self.assertEquals(('unit.tests',), load_mock.call_args[0])
+ self.assertEquals(('unit.tests',), zone_retrieve_mock.call_args[0])
self.assertFalse(exists)
# Existing zone w/o records
- load_mock.reset_mock()
- nsone_zone = DummyZone([])
- load_mock.side_effect = [nsone_zone]
- zone_search = Mock()
- zone_search.return_value = [
- {
+ zone_retrieve_mock.reset_mock()
+ ns1_zone = {
+ 'records': [{
"domain": "geo.unit.tests",
"zone": "unit.tests",
"type": "A",
@@ -221,21 +212,18 @@ class TestNs1Provider(TestCase):
'meta': {'iso_region_code': ['NA-US-WA']}},
],
'ttl': 34,
- },
- ]
- nsone_zone.search = zone_search
+ }],
+ }
+ zone_retrieve_mock.side_effect = [ns1_zone]
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(1, len(zone.records))
- self.assertEquals(('unit.tests',), load_mock.call_args[0])
+ self.assertEquals(('unit.tests',), zone_retrieve_mock.call_args[0])
# Existing zone w/records
- load_mock.reset_mock()
- nsone_zone = DummyZone(self.nsone_records)
- load_mock.side_effect = [nsone_zone]
- zone_search = Mock()
- zone_search.return_value = [
- {
+ zone_retrieve_mock.reset_mock()
+ ns1_zone = {
+ 'records': self.ns1_records + [{
"domain": "geo.unit.tests",
"zone": "unit.tests",
"type": "A",
@@ -249,26 +237,23 @@ class TestNs1Provider(TestCase):
'meta': {'iso_region_code': ['NA-US-WA']}},
],
'ttl': 34,
- },
- ]
- nsone_zone.search = zone_search
+ }],
+ }
+ zone_retrieve_mock.side_effect = [ns1_zone]
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(self.expected, zone.records)
- self.assertEquals(('unit.tests',), load_mock.call_args[0])
+ self.assertEquals(('unit.tests',), zone_retrieve_mock.call_args[0])
# Test skipping unsupported record type
- load_mock.reset_mock()
- nsone_zone = DummyZone(self.nsone_records + [{
- 'type': 'UNSUPPORTED',
- 'ttl': 42,
- 'short_answers': ['unsupported'],
- 'domain': 'unsupported.unit.tests.',
- }])
- load_mock.side_effect = [nsone_zone]
- zone_search = Mock()
- zone_search.return_value = [
- {
+ zone_retrieve_mock.reset_mock()
+ ns1_zone = {
+ 'records': self.ns1_records + [{
+ 'type': 'UNSUPPORTED',
+ 'ttl': 42,
+ 'short_answers': ['unsupported'],
+ 'domain': 'unsupported.unit.tests.',
+ }, {
"domain": "geo.unit.tests",
"zone": "unit.tests",
"type": "A",
@@ -282,17 +267,23 @@ class TestNs1Provider(TestCase):
'meta': {'iso_region_code': ['NA-US-WA']}},
],
'ttl': 34,
- },
- ]
- nsone_zone.search = zone_search
+ }],
+ }
+ zone_retrieve_mock.side_effect = [ns1_zone]
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(self.expected, zone.records)
- self.assertEquals(('unit.tests',), load_mock.call_args[0])
+ self.assertEquals(('unit.tests',), zone_retrieve_mock.call_args[0])
- @patch('nsone.NSONE.createZone')
- @patch('nsone.NSONE.loadZone')
- def test_sync(self, load_mock, create_mock):
+ @patch('ns1.rest.records.Records.delete')
+ @patch('ns1.rest.records.Records.update')
+ @patch('ns1.rest.records.Records.create')
+ @patch('ns1.rest.records.Records.retrieve')
+ @patch('ns1.rest.zones.Zones.create')
+ @patch('ns1.rest.zones.Zones.retrieve')
+ def test_sync(self, zone_retrieve_mock, zone_create_mock,
+ record_retrieve_mock, record_create_mock,
+ record_update_mock, record_delete_mock):
provider = Ns1Provider('test', 'api-key')
desired = Zone('unit.tests.', [])
@@ -306,124 +297,142 @@ class TestNs1Provider(TestCase):
self.assertTrue(plan.exists)
# Fails, general error
- load_mock.reset_mock()
- create_mock.reset_mock()
- load_mock.side_effect = ResourceException('boom')
+ zone_retrieve_mock.reset_mock()
+ zone_create_mock.reset_mock()
+ zone_retrieve_mock.side_effect = ResourceException('boom')
with self.assertRaises(ResourceException) as ctx:
provider.apply(plan)
- self.assertEquals(load_mock.side_effect, ctx.exception)
+ self.assertEquals(zone_retrieve_mock.side_effect, ctx.exception)
# Fails, bad auth
- load_mock.reset_mock()
- create_mock.reset_mock()
- load_mock.side_effect = \
+ zone_retrieve_mock.reset_mock()
+ zone_create_mock.reset_mock()
+ zone_retrieve_mock.side_effect = \
ResourceException('server error: zone not found')
- create_mock.side_effect = AuthException('unauthorized')
+ zone_create_mock.side_effect = AuthException('unauthorized')
with self.assertRaises(AuthException) as ctx:
provider.apply(plan)
- self.assertEquals(create_mock.side_effect, ctx.exception)
+ self.assertEquals(zone_create_mock.side_effect, ctx.exception)
- # non-existant zone, create
- load_mock.reset_mock()
- create_mock.reset_mock()
- load_mock.side_effect = \
+ # non-existent zone, create
+ zone_retrieve_mock.reset_mock()
+ zone_create_mock.reset_mock()
+ zone_retrieve_mock.side_effect = \
ResourceException('server error: zone not found')
- # ugh, need a mock zone with a mock prop since we're using getattr, we
- # can actually control side effects on `meth` with that.
- mock_zone = Mock()
- mock_zone.add_SRV = Mock()
- mock_zone.add_SRV.side_effect = [
+
+ zone_create_mock.side_effect = ['foo']
+ # Test out the create rate-limit handling, then 9 successes
+ record_create_mock.side_effect = [
RateLimitException('boo', period=0),
- None,
- ]
- create_mock.side_effect = [mock_zone]
+ ] + ([None] * 9)
+
got_n = provider.apply(plan)
self.assertEquals(expected_n, got_n)
+ # Zone was created
+ zone_create_mock.assert_has_calls([call('unit.tests')])
+ # Checking that we got some of the expected records too
+ record_create_mock.assert_has_calls([
+ call('unit.tests', 'unit.tests', 'A', answers=[
+ {'answer': ['1.2.3.4'], 'meta': {}}
+ ], filters=[], ttl=32),
+ call('unit.tests', 'unit.tests', 'CAA', answers=[
+ (0, 'issue', 'ca.unit.tests')
+ ], ttl=40),
+ call('unit.tests', 'unit.tests', 'MX', answers=[
+ (10, 'mx1.unit.tests.'), (20, 'mx2.unit.tests.')
+ ], ttl=35),
+ ])
+
# Update & delete
- load_mock.reset_mock()
- create_mock.reset_mock()
- nsone_zone = DummyZone(self.nsone_records + [{
- 'type': 'A',
- 'ttl': 42,
- 'short_answers': ['9.9.9.9'],
- 'domain': 'delete-me.unit.tests.',
- }])
- nsone_zone.data['records'][0]['short_answers'][0] = '2.2.2.2'
- nsone_zone.loadRecord = Mock()
- zone_search = Mock()
- zone_search.return_value = [
- {
+ zone_retrieve_mock.reset_mock()
+ zone_create_mock.reset_mock()
+
+ ns1_zone = {
+ 'records': self.ns1_records + [{
+ 'type': 'A',
+ 'ttl': 42,
+ 'short_answers': ['9.9.9.9'],
+ 'domain': 'delete-me.unit.tests.',
+ }, {
"domain": "geo.unit.tests",
"zone": "unit.tests",
"type": "A",
- "answers": [
- {'answer': ['1.1.1.1'], 'meta': {}},
- {'answer': ['1.2.3.4'],
- 'meta': {'ca_province': ['ON']}},
- {'answer': ['2.3.4.5'], 'meta': {'us_state': ['NY']}},
- {'answer': ['3.4.5.6'], 'meta': {'country': ['US']}},
- {'answer': ['4.5.6.7'],
- 'meta': {'iso_region_code': ['NA-US-WA']}},
+ "short_answers": [
+ '1.1.1.1',
+ '1.2.3.4',
+ '2.3.4.5',
+ '3.4.5.6',
+ '4.5.6.7',
],
+ 'tier': 3, # This flags it as advacned, full load required
'ttl': 34,
- },
- ]
- nsone_zone.search = zone_search
- load_mock.side_effect = [nsone_zone, nsone_zone]
+ }],
+ }
+ ns1_zone['records'][0]['short_answers'][0] = '2.2.2.2'
+
+ record_retrieve_mock.side_effect = [{
+ "domain": "geo.unit.tests",
+ "zone": "unit.tests",
+ "type": "A",
+ "answers": [
+ {'answer': ['1.1.1.1'], 'meta': {}},
+ {'answer': ['1.2.3.4'],
+ 'meta': {'ca_province': ['ON']}},
+ {'answer': ['2.3.4.5'], 'meta': {'us_state': ['NY']}},
+ {'answer': ['3.4.5.6'], 'meta': {'country': ['US']}},
+ {'answer': ['4.5.6.7'],
+ 'meta': {'iso_region_code': ['NA-US-WA']}},
+ ],
+ 'tier': 3,
+ 'ttl': 34,
+ }]
+
+ zone_retrieve_mock.side_effect = [ns1_zone, ns1_zone]
plan = provider.plan(desired)
self.assertEquals(3, len(plan.changes))
- self.assertIsInstance(plan.changes[0], Update)
- self.assertIsInstance(plan.changes[2], Delete)
- # ugh, we need a mock record that can be returned from loadRecord for
- # the update and delete targets, we can add our side effects to that to
- # trigger rate limit handling
- mock_record = Mock()
- mock_record.update.side_effect = [
+ # Shouldn't rely on order so just count classes
+ classes = defaultdict(lambda: 0)
+ for change in plan.changes:
+ classes[change.__class__] += 1
+ self.assertEquals(1, classes[Delete])
+ self.assertEquals(2, classes[Update])
+
+ record_update_mock.side_effect = [
RateLimitException('one', period=0),
None,
None,
]
- mock_record.delete.side_effect = [
+ record_delete_mock.side_effect = [
RateLimitException('two', period=0),
None,
None,
]
- nsone_zone.loadRecord.side_effect = [mock_record, mock_record,
- mock_record]
+
got_n = provider.apply(plan)
self.assertEquals(3, got_n)
- nsone_zone.loadRecord.assert_has_calls([
- call('unit.tests', u'A'),
- call('geo', u'A'),
- call('delete-me', u'A'),
- ])
- mock_record.assert_has_calls([
- call.update(answers=[{'answer': [u'1.2.3.4'], 'meta': {}}],
- filters=[],
- ttl=32),
- call.update(answers=[{u'answer': [u'1.2.3.4'], u'meta': {}}],
- filters=[],
- ttl=32),
- call.update(
- answers=[
- {u'answer': [u'101.102.103.104'], u'meta': {}},
- {u'answer': [u'101.102.103.105'], u'meta': {}},
- {
- u'answer': [u'201.202.203.204'],
- u'meta': {
- u'iso_region_code': [u'NA-US-NY']
- },
- },
- ],
+
+ record_update_mock.assert_has_calls([
+ call('unit.tests', 'unit.tests', 'A', answers=[
+ {'answer': ['1.2.3.4'], 'meta': {}}],
+ filters=[],
+ ttl=32),
+ call('unit.tests', 'unit.tests', 'A', answers=[
+ {'answer': ['1.2.3.4'], 'meta': {}}],
+ filters=[],
+ ttl=32),
+ call('unit.tests', 'geo.unit.tests', 'A', answers=[
+ {'answer': ['101.102.103.104'], 'meta': {}},
+ {'answer': ['101.102.103.105'], 'meta': {}},
+ {
+ 'answer': ['201.202.203.204'],
+ 'meta': {'iso_region_code': ['NA-US-NY']}
+ }],
filters=[
- {u'filter': u'shuffle', u'config': {}},
- {u'filter': u'geotarget_country', u'config': {}},
- {u'filter': u'select_first_n', u'config': {u'N': 1}},
- ],
- ttl=34),
- call.delete(),
- call.delete()
+ {'filter': 'shuffle', 'config': {}},
+ {'filter': 'geotarget_country', 'config': {}},
+ {'filter': 'select_first_n', 'config': {'N': 1}}],
+ ttl=34)
])
def test_escaping(self):
@@ -462,7 +471,7 @@ class TestNs1Provider(TestCase):
def test_data_for_CNAME(self):
provider = Ns1Provider('test', 'api-key')
- # answers from nsone
+ # answers from ns1
a_record = {
'ttl': 31,
'type': 'CNAME',
@@ -476,7 +485,7 @@ class TestNs1Provider(TestCase):
self.assertEqual(a_expected,
provider._data_for_CNAME(a_record['type'], a_record))
- # no answers from nsone
+ # no answers from ns1
b_record = {
'ttl': 32,
'type': 'CNAME',
@@ -489,3 +498,46 @@ class TestNs1Provider(TestCase):
}
self.assertEqual(b_expected,
provider._data_for_CNAME(b_record['type'], b_record))
+
+
+class TestNs1Client(TestCase):
+
+ @patch('ns1.rest.zones.Zones.retrieve')
+ def test_retry_behavior(self, zone_retrieve_mock):
+ client = Ns1Client('dummy-key')
+
+ # No retry required, just calls and is returned
+ zone_retrieve_mock.reset_mock()
+ zone_retrieve_mock.side_effect = ['foo']
+ self.assertEquals('foo', client.zones_retrieve('unit.tests'))
+ zone_retrieve_mock.assert_has_calls([call('unit.tests')])
+
+ # One retry required
+ zone_retrieve_mock.reset_mock()
+ zone_retrieve_mock.side_effect = [
+ RateLimitException('boo', period=0),
+ 'foo'
+ ]
+ self.assertEquals('foo', client.zones_retrieve('unit.tests'))
+ zone_retrieve_mock.assert_has_calls([call('unit.tests')])
+
+ # Two retries required
+ zone_retrieve_mock.reset_mock()
+ zone_retrieve_mock.side_effect = [
+ RateLimitException('boo', period=0),
+ 'foo'
+ ]
+ self.assertEquals('foo', client.zones_retrieve('unit.tests'))
+ zone_retrieve_mock.assert_has_calls([call('unit.tests')])
+
+ # Exhaust our retries
+ zone_retrieve_mock.reset_mock()
+ zone_retrieve_mock.side_effect = [
+ RateLimitException('first', period=0),
+ RateLimitException('boo', period=0),
+ RateLimitException('boo', period=0),
+ RateLimitException('last', period=0),
+ ]
+ with self.assertRaises(RateLimitException) as ctx:
+ client.zones_retrieve('unit.tests')
+ self.assertEquals('last', text_type(ctx.exception))
diff --git a/tests/test_octodns_provider_ovh.py b/tests/test_octodns_provider_ovh.py
index d3f468d..924591f 100644
--- a/tests/test_octodns_provider_ovh.py
+++ b/tests/test_octodns_provider_ovh.py
@@ -382,64 +382,63 @@ class TestOvhProvider(TestCase):
get_mock.side_effect = [[100], [101], [102], [103]]
provider.apply(plan)
wanted_calls = [
- call(u'/domain/zone/unit.tests/record', fieldType=u'TXT',
- subDomain='txt', target=u'TXT text', ttl=1400),
- call(u'/domain/zone/unit.tests/record', fieldType=u'DKIM',
- subDomain='dkim', target=self.valid_dkim_key,
- ttl=1300),
- call(u'/domain/zone/unit.tests/record', fieldType=u'A',
- subDomain=u'', target=u'1.2.3.4', ttl=100),
- call(u'/domain/zone/unit.tests/record', fieldType=u'SRV',
+ call('/domain/zone/unit.tests/record', fieldType='A',
+ subDomain='', target='1.2.3.4', ttl=100),
+ call('/domain/zone/unit.tests/record', fieldType='AAAA',
+ subDomain='', target='1:1ec:1::1', ttl=200),
+ call('/domain/zone/unit.tests/record', fieldType='MX',
+ subDomain='', target='10 mx1.unit.tests.', ttl=400),
+ call('/domain/zone/unit.tests/record', fieldType='SPF',
+ subDomain='',
+ target='v=spf1 include:unit.texts.redirect ~all',
+ ttl=1000),
+ call('/domain/zone/unit.tests/record', fieldType='SSHFP',
+ subDomain='',
+ target='1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73',
+ ttl=1100),
+ call('/domain/zone/unit.tests/record', fieldType='PTR',
+ subDomain='4', target='unit.tests.', ttl=900),
+ call('/domain/zone/unit.tests/record', fieldType='SRV',
subDomain='_srv._tcp',
- target=u'10 20 30 foo-1.unit.tests.', ttl=800),
- call(u'/domain/zone/unit.tests/record', fieldType=u'SRV',
+ target='10 20 30 foo-1.unit.tests.', ttl=800),
+ call('/domain/zone/unit.tests/record', fieldType='SRV',
subDomain='_srv._tcp',
- target=u'40 50 60 foo-2.unit.tests.', ttl=800),
- call(u'/domain/zone/unit.tests/record', fieldType=u'PTR',
- subDomain='4', target=u'unit.tests.', ttl=900),
- call(u'/domain/zone/unit.tests/record', fieldType=u'NS',
- subDomain='www3', target=u'ns3.unit.tests.', ttl=700),
- call(u'/domain/zone/unit.tests/record', fieldType=u'NS',
- subDomain='www3', target=u'ns4.unit.tests.', ttl=700),
- call(u'/domain/zone/unit.tests/record',
- fieldType=u'SSHFP', subDomain=u'', ttl=1100,
- target=u'1 1 bf6b6825d2977c511a475bbefb88a'
- u'ad54'
- u'a92ac73',
- ),
- call(u'/domain/zone/unit.tests/record', fieldType=u'AAAA',
- subDomain=u'', target=u'1:1ec:1::1', ttl=200),
- call(u'/domain/zone/unit.tests/record', fieldType=u'MX',
- subDomain=u'', target=u'10 mx1.unit.tests.', ttl=400),
- call(u'/domain/zone/unit.tests/record', fieldType=u'CNAME',
- subDomain='www2', target=u'unit.tests.', ttl=300),
- call(u'/domain/zone/unit.tests/record', fieldType=u'SPF',
- subDomain=u'', ttl=1000,
- target=u'v=spf1 include:unit.texts.'
- u'redirect ~all',
- ),
- call(u'/domain/zone/unit.tests/record', fieldType=u'A',
- subDomain='sub', target=u'1.2.3.4', ttl=200),
- call(u'/domain/zone/unit.tests/record', fieldType=u'NAPTR',
- subDomain='naptr', ttl=500,
- target=u'10 100 "S" "SIP+D2U" "!^.*$!sip:'
- u'info@bar'
- u'.example.com!" .'
- ),
- call(u'/domain/zone/unit.tests/refresh')]
+ target='40 50 60 foo-2.unit.tests.', ttl=800),
+ call('/domain/zone/unit.tests/record', fieldType='DKIM',
+ subDomain='dkim',
+ target='p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCxLaG'
+ '16G4SaEcXVdiIxTg7gKSGbHKQLm30CHib1h9FzS9nkcyvQSyQj1r'
+ 'MFyqC//tft3ohx3nvJl+bGCWxdtLYDSmir9PW54e5CTdxEh8MWRk'
+ 'BO3StF6QG/tAh3aTGDmkqhIJGLb87iHvpmVKqURmEUzJPv5KPJfW'
+ 'LofADI+q9lQIDAQAB', ttl=1300),
+ call('/domain/zone/unit.tests/record', fieldType='NAPTR',
+ subDomain='naptr',
+ target='10 100 "S" "SIP+D2U" "!^.*$!sip:info@bar.exam'
+ 'ple.com!" .', ttl=500),
+ call('/domain/zone/unit.tests/record', fieldType='A',
+ subDomain='sub', target='1.2.3.4', ttl=200),
+ call('/domain/zone/unit.tests/record', fieldType='TXT',
+ subDomain='txt', target='TXT text', ttl=1400),
+ call('/domain/zone/unit.tests/record', fieldType='CNAME',
+ subDomain='www2', target='unit.tests.', ttl=300),
+ call('/domain/zone/unit.tests/record', fieldType='NS',
+ subDomain='www3', target='ns3.unit.tests.', ttl=700),
+ call('/domain/zone/unit.tests/record', fieldType='NS',
+ subDomain='www3', target='ns4.unit.tests.', ttl=700),
+ call('/domain/zone/unit.tests/refresh')]
post_mock.assert_has_calls(wanted_calls)
# Get for delete calls
wanted_get_calls = [
- call(u'/domain/zone/unit.tests/record', fieldType=u'TXT',
- subDomain='txt'),
+ call(u'/domain/zone/unit.tests/record', fieldType=u'A',
+ subDomain=u''),
call(u'/domain/zone/unit.tests/record', fieldType=u'DKIM',
subDomain='dkim'),
call(u'/domain/zone/unit.tests/record', fieldType=u'A',
- subDomain=u''),
- call(u'/domain/zone/unit.tests/record', fieldType=u'A',
- subDomain='fake')]
+ subDomain='fake'),
+ call(u'/domain/zone/unit.tests/record', fieldType=u'TXT',
+ subDomain='txt')]
get_mock.assert_has_calls(wanted_get_calls)
# 4 delete calls for update and delete
delete_mock.assert_has_calls(
diff --git a/tests/test_octodns_provider_powerdns.py b/tests/test_octodns_provider_powerdns.py
index 067dc74..6baee6c 100644
--- a/tests/test_octodns_provider_powerdns.py
+++ b/tests/test_octodns_provider_powerdns.py
@@ -9,6 +9,7 @@ from json import loads, dumps
from os.path import dirname, join
from requests import HTTPError
from requests_mock import ANY, mock as requests_mock
+from six import text_type
from unittest import TestCase
from octodns.record import Record
@@ -41,7 +42,7 @@ with open('./tests/fixtures/powerdns-full-data.json') as fh:
class TestPowerDnsProvider(TestCase):
def test_provider(self):
- provider = PowerDnsProvider('test', 'non.existant', 'api-key',
+ provider = PowerDnsProvider('test', 'non.existent', 'api-key',
nameserver_values=['8.8.8.8.',
'9.9.9.9.'])
@@ -52,7 +53,7 @@ class TestPowerDnsProvider(TestCase):
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
- self.assertTrue('unauthorized' in ctx.exception.message)
+ self.assertTrue('unauthorized' in text_type(ctx.exception))
# General error
with requests_mock() as mock:
@@ -63,7 +64,7 @@ class TestPowerDnsProvider(TestCase):
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
- # Non-existant zone doesn't populate anything
+ # Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=422,
json={'error': "Could not find domain 'unit.tests.'"})
@@ -163,7 +164,7 @@ class TestPowerDnsProvider(TestCase):
provider.apply(plan)
def test_small_change(self):
- provider = PowerDnsProvider('test', 'non.existant', 'api-key')
+ provider = PowerDnsProvider('test', 'non.existent', 'api-key')
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
@@ -203,7 +204,7 @@ class TestPowerDnsProvider(TestCase):
def test_existing_nameservers(self):
ns_values = ['8.8.8.8.', '9.9.9.9.']
- provider = PowerDnsProvider('test', 'non.existant', 'api-key',
+ provider = PowerDnsProvider('test', 'non.existent', 'api-key',
nameserver_values=ns_values)
expected = Zone('unit.tests.', [])
diff --git a/tests/test_octodns_provider_rackspace.py b/tests/test_octodns_provider_rackspace.py
index c467dec..0a6564d 100644
--- a/tests/test_octodns_provider_rackspace.py
+++ b/tests/test_octodns_provider_rackspace.py
@@ -7,8 +7,9 @@ from __future__ import absolute_import, division, print_function, \
import json
import re
+from six import text_type
+from six.moves.urllib.parse import urlparse
from unittest import TestCase
-from urlparse import urlparse
from requests import HTTPError
from requests_mock import ANY, mock as requests_mock
@@ -39,7 +40,6 @@ with open('./tests/fixtures/rackspace-sample-recordset-page2.json') as fh:
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',
@@ -53,7 +53,7 @@ class TestRackspaceProvider(TestCase):
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
self.provider.populate(zone)
- self.assertTrue('unauthorized' in ctx.exception.message)
+ self.assertTrue('unauthorized' in text_type(ctx.exception))
self.assertTrue(mock.called_once)
def test_server_error(self):
@@ -792,13 +792,13 @@ class TestRackspaceProvider(TestCase):
ExpectedUpdates = {
"records": [{
"name": "unit.tests",
- "id": "A-222222",
- "data": "1.2.3.5",
+ "id": "A-111111",
+ "data": "1.2.3.4",
"ttl": 3600
}, {
"name": "unit.tests",
- "id": "A-111111",
- "data": "1.2.3.4",
+ "id": "A-222222",
+ "data": "1.2.3.5",
"ttl": 3600
}, {
"name": "unit.tests",
diff --git a/tests/test_octodns_provider_route53.py b/tests/test_octodns_provider_route53.py
index 67fcb76..7691804 100644
--- a/tests/test_octodns_provider_route53.py
+++ b/tests/test_octodns_provider_route53.py
@@ -7,12 +7,14 @@ from __future__ import absolute_import, division, print_function, \
from botocore.exceptions import ClientError
from botocore.stub import ANY, Stubber
+from six import text_type
from unittest import TestCase
from mock import patch
from octodns.record import Create, Delete, Record, Update
from octodns.provider.route53 import Route53Provider, _Route53GeoDefault, \
- _Route53GeoRecord, _Route53Record, _mod_keyer, _octal_replace
+ _Route53DynamicValue, _Route53GeoRecord, _Route53Record, _mod_keyer, \
+ _octal_replace
from octodns.zone import Zone
from helpers import GeoProvider
@@ -502,7 +504,7 @@ class TestRoute53Provider(TestCase):
'ResourceRecords': [{
'Value': '10 smtp-1.unit.tests.',
}, {
- 'Value': '20 smtp-2.unit.tests.',
+ 'Value': '20 smtp-2.unit.tests.',
}],
'TTL': 64,
}, {
@@ -699,18 +701,6 @@ class TestRoute53Provider(TestCase):
'TTL': 61,
'Type': 'A'
}
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'GeoLocation': {'CountryCode': 'US',
- 'SubdivisionCode': 'CA'},
- 'HealthCheckId': u'44',
- 'Name': 'unit.tests.',
- 'ResourceRecords': [{'Value': '7.2.3.4'}],
- 'SetIdentifier': 'NA-US-CA',
- 'TTL': 61,
- 'Type': 'A'
- }
}, {
'Action': 'UPSERT',
'ResourceRecordSet': {
@@ -734,6 +724,18 @@ class TestRoute53Provider(TestCase):
'TTL': 61,
'Type': 'A'
}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'GeoLocation': {'CountryCode': 'US',
+ 'SubdivisionCode': 'CA'},
+ 'HealthCheckId': u'44',
+ 'Name': 'unit.tests.',
+ 'ResourceRecords': [{'Value': '7.2.3.4'}],
+ 'SetIdentifier': 'NA-US-CA',
+ 'TTL': 61,
+ 'Type': 'A'
+ }
}, {
'Action': 'UPSERT',
'ResourceRecordSet': {
@@ -874,6 +876,25 @@ class TestRoute53Provider(TestCase):
'CallerReference': ANY,
})
+ list_resource_record_sets_resp = {
+ 'ResourceRecordSets': [{
+ 'Name': 'a.unit.tests.',
+ 'Type': 'A',
+ 'GeoLocation': {
+ 'ContinentCode': 'NA',
+ },
+ 'ResourceRecords': [{
+ 'Value': '2.2.3.4',
+ }],
+ 'TTL': 61,
+ }],
+ 'IsTruncated': False,
+ 'MaxItems': '100',
+ }
+ stubber.add_response('list_resource_record_sets',
+ list_resource_record_sets_resp,
+ {'HostedZoneId': 'z42'})
+
stubber.add_response('list_health_checks',
{
'HealthChecks': self.health_checks,
@@ -1236,7 +1257,7 @@ class TestRoute53Provider(TestCase):
'HealthCheckId': '44',
})
change = Create(record)
- provider._mod_Create(change, 'z43')
+ provider._mod_Create(change, 'z43', [])
stubber.assert_no_pending_responses()
# gc through _mod_Update
@@ -1245,7 +1266,7 @@ class TestRoute53Provider(TestCase):
})
# first record is ignored for our purposes, we have to pass something
change = Update(record, record)
- provider._mod_Create(change, 'z43')
+ provider._mod_Create(change, 'z43', [])
stubber.assert_no_pending_responses()
# gc through _mod_Delete, expect 3 to go away, can't check order
@@ -1260,7 +1281,7 @@ class TestRoute53Provider(TestCase):
'HealthCheckId': ANY,
})
change = Delete(record)
- provider._mod_Delete(change, 'z43')
+ provider._mod_Delete(change, 'z43', [])
stubber.assert_no_pending_responses()
# gc only AAAA, leave the A's alone
@@ -1653,7 +1674,7 @@ class TestRoute53Provider(TestCase):
desired.add_record(record)
list_resource_record_sets_resp = {
'ResourceRecordSets': [{
- # other name
+ # Not dynamic value and other name
'Name': 'unit.tests.',
'Type': 'A',
'GeoLocation': {
@@ -1663,17 +1684,21 @@ class TestRoute53Provider(TestCase):
'Value': '1.2.3.4',
}],
'TTL': 61,
+ # All the non-matches have a different Id so we'll fail if they
+ # match
+ 'HealthCheckId': '33',
}, {
- # matching name, other type
+ # Not dynamic value, matching name, other type
'Name': 'a.unit.tests.',
'Type': 'AAAA',
'ResourceRecords': [{
'Value': '2001:0db8:3c4d:0015:0000:0000:1a2f:1a4b'
}],
'TTL': 61,
+ 'HealthCheckId': '33',
}, {
# default value pool
- 'Name': '_octodns-default-pool.a.unit.tests.',
+ 'Name': '_octodns-default-value.a.unit.tests.',
'Type': 'A',
'GeoLocation': {
'CountryCode': '*',
@@ -1682,6 +1707,37 @@ class TestRoute53Provider(TestCase):
'Value': '1.2.3.4',
}],
'TTL': 61,
+ 'HealthCheckId': '33',
+ }, {
+ # different record
+ 'Name': '_octodns-two-value.other.unit.tests.',
+ 'Type': 'A',
+ 'GeoLocation': {
+ 'CountryCode': '*',
+ },
+ 'ResourceRecords': [{
+ 'Value': '1.2.3.4',
+ }],
+ 'TTL': 61,
+ 'HealthCheckId': '33',
+ }, {
+ # same everything, but different type
+ 'Name': '_octodns-one-value.a.unit.tests.',
+ 'Type': 'AAAA',
+ 'ResourceRecords': [{
+ 'Value': '2001:0db8:3c4d:0015:0000:0000:1a2f:1a4b'
+ }],
+ 'TTL': 61,
+ 'HealthCheckId': '33',
+ }, {
+ # same everything, sub
+ 'Name': '_octodns-one-value.sub.a.unit.tests.',
+ 'Type': 'A',
+ 'ResourceRecords': [{
+ 'Value': '1.2.3.4',
+ }],
+ 'TTL': 61,
+ 'HealthCheckId': '33',
}, {
# match
'Name': '_octodns-one-value.a.unit.tests.',
@@ -1804,46 +1860,51 @@ class TestRoute53Provider(TestCase):
# _get_test_plan() returns a plan with 11 modifications, 17 RRs
+ @patch('octodns.provider.route53.Route53Provider._load_records')
@patch('octodns.provider.route53.Route53Provider._really_apply')
- def test_apply_1(self, really_apply_mock):
+ def test_apply_1(self, really_apply_mock, _):
# 18 RRs with max of 19 should only get applied in one call
provider, plan = self._get_test_plan(19)
provider.apply(plan)
really_apply_mock.assert_called_once()
+ @patch('octodns.provider.route53.Route53Provider._load_records')
@patch('octodns.provider.route53.Route53Provider._really_apply')
- def test_apply_2(self, really_apply_mock):
+ def test_apply_2(self, really_apply_mock, _):
# 18 RRs with max of 17 should only get applied in two calls
provider, plan = self._get_test_plan(18)
provider.apply(plan)
self.assertEquals(2, really_apply_mock.call_count)
+ @patch('octodns.provider.route53.Route53Provider._load_records')
@patch('octodns.provider.route53.Route53Provider._really_apply')
- def test_apply_3(self, really_apply_mock):
+ def test_apply_3(self, really_apply_mock, _):
- # with a max of seven modifications, four calls
+ # with a max of seven modifications, three calls
provider, plan = self._get_test_plan(7)
provider.apply(plan)
- self.assertEquals(4, really_apply_mock.call_count)
+ self.assertEquals(3, really_apply_mock.call_count)
+ @patch('octodns.provider.route53.Route53Provider._load_records')
@patch('octodns.provider.route53.Route53Provider._really_apply')
- def test_apply_4(self, really_apply_mock):
+ def test_apply_4(self, really_apply_mock, _):
# with a max of 11 modifications, two calls
provider, plan = self._get_test_plan(11)
provider.apply(plan)
self.assertEquals(2, really_apply_mock.call_count)
+ @patch('octodns.provider.route53.Route53Provider._load_records')
@patch('octodns.provider.route53.Route53Provider._really_apply')
- def test_apply_bad(self, really_apply_mock):
+ def test_apply_bad(self, really_apply_mock, _):
# with a max of 1 modifications, fail
provider, plan = self._get_test_plan(1)
with self.assertRaises(Exception) as ctx:
provider.apply(plan)
- self.assertTrue('modifications' in ctx.exception.message)
+ self.assertTrue('modifications' in text_type(ctx.exception))
def test_semicolon_fixup(self):
provider = Route53Provider('test', 'abc', '123')
@@ -1939,6 +2000,12 @@ class TestRoute53Provider(TestCase):
}], [r.data for r in record.dynamic.rules])
+class DummyProvider(object):
+
+ def get_health_check_id(self, *args, **kwargs):
+ return None
+
+
class TestRoute53Records(TestCase):
existing = Zone('unit.tests.', [])
record_a = Record.new(existing, '', {
@@ -2005,11 +2072,6 @@ class TestRoute53Records(TestCase):
e = _Route53GeoDefault(None, self.record_a, False)
self.assertNotEquals(a, e)
- class DummyProvider(object):
-
- def get_health_check_id(self, *args, **kwargs):
- return None
-
provider = DummyProvider()
f = _Route53GeoRecord(provider, self.record_a, 'NA-US',
self.record_a.geo['NA-US'], False)
@@ -2029,6 +2091,153 @@ class TestRoute53Records(TestCase):
e.__repr__()
f.__repr__()
+ def test_route53_record_ordering(self):
+ # Matches
+ a = _Route53Record(None, self.record_a, False)
+ b = _Route53Record(None, self.record_a, False)
+ self.assertTrue(a == b)
+ self.assertFalse(a != b)
+ self.assertFalse(a < b)
+ self.assertTrue(a <= b)
+ self.assertFalse(a > b)
+ self.assertTrue(a >= b)
+
+ # Change the fqdn is greater
+ fqdn = _Route53Record(None, self.record_a, False,
+ fqdn_override='other')
+ self.assertFalse(a == fqdn)
+ self.assertTrue(a != fqdn)
+ self.assertFalse(a < fqdn)
+ self.assertFalse(a <= fqdn)
+ self.assertTrue(a > fqdn)
+ self.assertTrue(a >= fqdn)
+
+ provider = DummyProvider()
+ geo_a = _Route53GeoRecord(provider, self.record_a, 'NA-US',
+ self.record_a.geo['NA-US'], False)
+ geo_b = _Route53GeoRecord(provider, self.record_a, 'NA-US',
+ self.record_a.geo['NA-US'], False)
+ self.assertTrue(geo_a == geo_b)
+ self.assertFalse(geo_a != geo_b)
+ self.assertFalse(geo_a < geo_b)
+ self.assertTrue(geo_a <= geo_b)
+ self.assertFalse(geo_a > geo_b)
+ self.assertTrue(geo_a >= geo_b)
+
+ # Other base
+ geo_fqdn = _Route53GeoRecord(provider, self.record_a, 'NA-US',
+ self.record_a.geo['NA-US'], False)
+ geo_fqdn.fqdn = 'other'
+ self.assertFalse(geo_a == geo_fqdn)
+ self.assertTrue(geo_a != geo_fqdn)
+ self.assertFalse(geo_a < geo_fqdn)
+ self.assertFalse(geo_a <= geo_fqdn)
+ self.assertTrue(geo_a > geo_fqdn)
+ self.assertTrue(geo_a >= geo_fqdn)
+
+ # Other class
+ self.assertFalse(a == geo_a)
+ self.assertTrue(a != geo_a)
+ self.assertFalse(a < geo_a)
+ self.assertFalse(a <= geo_a)
+ self.assertTrue(a > geo_a)
+ self.assertTrue(a >= geo_a)
+
+ def test_dynamic_value_delete(self):
+ provider = DummyProvider()
+ geo = _Route53DynamicValue(provider, self.record_a, 'iad', '2.2.2.2',
+ 1, 0, False)
+
+ rrset = {
+ 'HealthCheckId': 'x12346z',
+ 'Name': '_octodns-iad-value.unit.tests.',
+ 'ResourceRecords': [{
+ 'Value': '2.2.2.2'
+ }],
+ 'SetIdentifier': 'iad-000',
+ 'TTL': 99,
+ 'Type': 'A',
+ 'Weight': 1,
+ }
+
+ candidates = [
+ # Empty, will test no SetIdentifier
+ {},
+ # Non-matching
+ {
+ 'SetIdentifier': 'not-a-match',
+ },
+ # Same set-id, different name
+ {
+ 'Name': 'not-a-match',
+ 'SetIdentifier': 'x12346z',
+ },
+ rrset,
+ ]
+
+ # Provide a matching rrset so that we'll just use it for the delete
+ # rathr than building up an almost identical one, note the way we'll
+ # know that we got the one we passed in is that it'll have a
+ # HealthCheckId and one that was created wouldn't since DummyProvider
+ # stubs out the lookup for them
+ mod = geo.mod('DELETE', candidates)
+ self.assertEquals('x12346z', mod['ResourceRecordSet']['HealthCheckId'])
+
+ # If we don't provide the candidate rrsets we get back exactly what we
+ # put in minus the healthcheck
+ rrset['HealthCheckId'] = None
+ mod = geo.mod('DELETE', [])
+ self.assertEquals(rrset, mod['ResourceRecordSet'])
+
+ def test_geo_delete(self):
+ provider = DummyProvider()
+ geo = _Route53GeoRecord(provider, self.record_a, 'NA-US',
+ self.record_a.geo['NA-US'], False)
+
+ rrset = {
+ 'GeoLocation': {
+ 'CountryCode': 'US'
+ },
+ 'HealthCheckId': 'x12346z',
+ 'Name': 'unit.tests.',
+ 'ResourceRecords': [{
+ 'Value': '2.2.2.2'
+ }, {
+ 'Value': '3.3.3.3'
+ }],
+ 'SetIdentifier': 'NA-US',
+ 'TTL': 99,
+ 'Type': 'A'
+ }
+
+ candidates = [
+ # Empty, will test no SetIdentifier
+ {},
+ {
+ 'SetIdentifier': 'not-a-match',
+ },
+ # Same set-id, different name
+ {
+ 'Name': 'not-a-match',
+ 'SetIdentifier': 'x12346z',
+ },
+ rrset,
+ ]
+
+ # Provide a matching rrset so that we'll just use it for the delete
+ # rathr than building up an almost identical one, note the way we'll
+ # know that we got the one we passed in is that it'll have a
+ # HealthCheckId and one that was created wouldn't since DummyProvider
+ # stubs out the lookup for them
+ mod = geo.mod('DELETE', candidates)
+ self.assertEquals('x12346z', mod['ResourceRecordSet']['HealthCheckId'])
+
+ # If we don't provide the candidate rrsets we get back exactly what we
+ # put in minus the healthcheck
+ del rrset['HealthCheckId']
+ mod = geo.mod('DELETE', [])
+ self.assertEquals(rrset, mod['ResourceRecordSet'])
+
def test_new_dynamic(self):
provider = Route53Provider('test', 'abc', '123')
@@ -2050,136 +2259,38 @@ class TestRoute53Records(TestCase):
creating=True)
self.assertEquals(18, len(route53_records))
+ expected_mods = [r.mod('CREATE', []) for r in route53_records]
+ # Sort so that we get a consistent order and don't rely on set ordering
+ expected_mods.sort(key=_mod_keyer)
+
# Convert the route53_records into mods
self.assertEquals([{
'Action': 'CREATE',
'ResourceRecordSet': {
'HealthCheckId': 'hc42',
'Name': '_octodns-ap-southeast-1-value.unit.tests.',
- 'ResourceRecords': [{
- 'Value': '1.4.1.2'}],
- 'SetIdentifier': 'ap-southeast-1-001',
+ 'ResourceRecords': [{'Value': '1.4.1.1'}],
+ 'SetIdentifier': 'ap-southeast-1-000',
'TTL': 60,
'Type': 'A',
- 'Weight': 2
- }
+ 'Weight': 2}
}, {
'Action': 'CREATE',
'ResourceRecordSet': {
'HealthCheckId': 'hc42',
'Name': '_octodns-ap-southeast-1-value.unit.tests.',
- 'ResourceRecords': [{
- 'Value': '1.4.1.1'}],
- 'SetIdentifier': 'ap-southeast-1-000',
+ 'ResourceRecords': [{'Value': '1.4.1.2'}],
+ 'SetIdentifier': 'ap-southeast-1-001',
'TTL': 60,
'Type': 'A',
- 'Weight': 2
- }
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'AliasTarget': {
- 'DNSName': '_octodns-ap-southeast-1-pool.unit.tests.',
- 'EvaluateTargetHealth': True,
- 'HostedZoneId': 'z45'
- },
- 'GeoLocation': {
- 'CountryCode': 'JP'},
- 'Name': 'unit.tests.',
- 'SetIdentifier': '0-ap-southeast-1-AS-JP',
- 'Type': 'A'
- }
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'AliasTarget': {
- 'DNSName': '_octodns-eu-central-1-pool.unit.tests.',
- 'EvaluateTargetHealth': True,
- 'HostedZoneId': 'z45'},
- 'GeoLocation': {
- 'CountryCode': 'US',
- 'SubdivisionCode': 'FL',
- },
- 'Name': 'unit.tests.',
- 'SetIdentifier': '1-eu-central-1-NA-US-FL',
- 'Type': 'A'}
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'AliasTarget': {
- 'DNSName': '_octodns-us-east-1-pool.unit.tests.',
- 'EvaluateTargetHealth': True,
- 'HostedZoneId': 'z45'},
- 'GeoLocation': {
- 'CountryCode': '*'},
- 'Name': 'unit.tests.',
- 'SetIdentifier': '2-us-east-1-None',
- 'Type': 'A'}
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'AliasTarget': {
- 'DNSName': '_octodns-us-east-1-pool.unit.tests.',
- 'EvaluateTargetHealth': True,
- 'HostedZoneId': 'z45'},
- 'Failover': 'SECONDARY',
- 'Name': '_octodns-ap-southeast-1-pool.unit.tests.',
- 'SetIdentifier': 'ap-southeast-1-Secondary-us-east-1',
- 'Type': 'A'}
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'AliasTarget': {
- 'DNSName': '_octodns-ap-southeast-1-pool.unit.tests.',
- 'EvaluateTargetHealth': True,
- 'HostedZoneId': 'z45'},
- 'GeoLocation': {
- 'CountryCode': 'CN'},
- 'Name': 'unit.tests.',
- 'SetIdentifier': '0-ap-southeast-1-AS-CN',
- 'Type': 'A'}
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'AliasTarget': {
- 'DNSName': '_octodns-us-east-1-value.unit.tests.',
- 'EvaluateTargetHealth': True,
- 'HostedZoneId': 'z45'},
- 'Failover': 'PRIMARY',
- 'Name': '_octodns-us-east-1-pool.unit.tests.',
- 'SetIdentifier': 'us-east-1-Primary',
- 'Type': 'A'}
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'AliasTarget': {
- 'DNSName': '_octodns-eu-central-1-pool.unit.tests.',
- 'EvaluateTargetHealth': True,
- 'HostedZoneId': 'z45'},
- 'GeoLocation': {
- 'ContinentCode': 'EU'},
- 'Name': 'unit.tests.',
- 'SetIdentifier': '1-eu-central-1-EU',
- 'Type': 'A'}
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'AliasTarget': {
- 'DNSName': '_octodns-eu-central-1-value.unit.tests.',
- 'EvaluateTargetHealth': True,
- 'HostedZoneId': 'z45'},
- 'Failover': 'PRIMARY',
- 'Name': '_octodns-eu-central-1-pool.unit.tests.',
- 'SetIdentifier': 'eu-central-1-Primary',
- 'Type': 'A'}
+ 'Weight': 2}
}, {
'Action': 'CREATE',
'ResourceRecordSet': {
'Name': '_octodns-default-pool.unit.tests.',
- 'ResourceRecords': [{
- 'Value': '1.1.2.1'},
- {
- 'Value': '1.1.2.2'}],
+ 'ResourceRecords': [
+ {'Value': '1.1.2.1'},
+ {'Value': '1.1.2.2'}],
'TTL': 60,
'Type': 'A'}
}, {
@@ -2187,56 +2298,41 @@ class TestRoute53Records(TestCase):
'ResourceRecordSet': {
'HealthCheckId': 'hc42',
'Name': '_octodns-eu-central-1-value.unit.tests.',
- 'ResourceRecords': [{
- 'Value': '1.3.1.2'}],
+ 'ResourceRecords': [{'Value': '1.3.1.1'}],
+ 'SetIdentifier': 'eu-central-1-000',
+ 'TTL': 60,
+ 'Type': 'A',
+ 'Weight': 1}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'HealthCheckId': 'hc42',
+ 'Name': '_octodns-eu-central-1-value.unit.tests.',
+ 'ResourceRecords': [{'Value': '1.3.1.2'}],
'SetIdentifier': 'eu-central-1-001',
'TTL': 60,
'Type': 'A',
'Weight': 1}
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'HealthCheckId': 'hc42',
- 'Name': '_octodns-eu-central-1-value.unit.tests.',
- 'ResourceRecords': [{
- 'Value': '1.3.1.1'}],
- 'SetIdentifier': 'eu-central-1-000',
- 'TTL': 60,
- 'Type': 'A',
- 'Weight': 1}
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'AliasTarget': {
- 'DNSName': '_octodns-default-pool.unit.tests.',
- 'EvaluateTargetHealth': True,
- 'HostedZoneId': 'z45'},
- 'Failover': 'SECONDARY',
- 'Name': '_octodns-us-east-1-pool.unit.tests.',
- 'SetIdentifier': 'us-east-1-Secondary-default',
- 'Type': 'A'}
}, {
'Action': 'CREATE',
'ResourceRecordSet': {
'HealthCheckId': 'hc42',
'Name': '_octodns-us-east-1-value.unit.tests.',
- 'ResourceRecords': [{
- 'Value': '1.5.1.2'}],
- 'SetIdentifier': 'us-east-1-001',
- 'TTL': 60,
- 'Type': 'A',
- 'Weight': 1}
- }, {
- 'Action': 'CREATE',
- 'ResourceRecordSet': {
- 'HealthCheckId': 'hc42',
- 'Name': '_octodns-us-east-1-value.unit.tests.',
- 'ResourceRecords': [{
- 'Value': '1.5.1.1'}],
+ 'ResourceRecords': [{'Value': '1.5.1.1'}],
'SetIdentifier': 'us-east-1-000',
'TTL': 60,
'Type': 'A',
'Weight': 1}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'HealthCheckId': 'hc42',
+ 'Name': '_octodns-us-east-1-value.unit.tests.',
+ 'ResourceRecords': [{'Value': '1.5.1.2'}],
+ 'SetIdentifier': 'us-east-1-001',
+ 'TTL': 60,
+ 'Type': 'A',
+ 'Weight': 1}
}, {
'Action': 'CREATE',
'ResourceRecordSet': {
@@ -2248,6 +2344,39 @@ class TestRoute53Records(TestCase):
'Name': '_octodns-ap-southeast-1-pool.unit.tests.',
'SetIdentifier': 'ap-southeast-1-Primary',
'Type': 'A'}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'AliasTarget': {
+ 'DNSName': '_octodns-eu-central-1-value.unit.tests.',
+ 'EvaluateTargetHealth': True,
+ 'HostedZoneId': 'z45'},
+ 'Failover': 'PRIMARY',
+ 'Name': '_octodns-eu-central-1-pool.unit.tests.',
+ 'SetIdentifier': 'eu-central-1-Primary',
+ 'Type': 'A'}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'AliasTarget': {
+ 'DNSName': '_octodns-us-east-1-value.unit.tests.',
+ 'EvaluateTargetHealth': True,
+ 'HostedZoneId': 'z45'},
+ 'Failover': 'PRIMARY',
+ 'Name': '_octodns-us-east-1-pool.unit.tests.',
+ 'SetIdentifier': 'us-east-1-Primary',
+ 'Type': 'A'}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'AliasTarget': {
+ 'DNSName': '_octodns-us-east-1-pool.unit.tests.',
+ 'EvaluateTargetHealth': True,
+ 'HostedZoneId': 'z45'},
+ 'Failover': 'SECONDARY',
+ 'Name': '_octodns-ap-southeast-1-pool.unit.tests.',
+ 'SetIdentifier': 'ap-southeast-1-Secondary-us-east-1',
+ 'Type': 'A'}
}, {
'Action': 'CREATE',
'ResourceRecordSet': {
@@ -2259,7 +2388,79 @@ class TestRoute53Records(TestCase):
'Name': '_octodns-eu-central-1-pool.unit.tests.',
'SetIdentifier': 'eu-central-1-Secondary-us-east-1',
'Type': 'A'}
- }], [r.mod('CREATE') for r in route53_records])
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'AliasTarget': {
+ 'DNSName': '_octodns-default-pool.unit.tests.',
+ 'EvaluateTargetHealth': True,
+ 'HostedZoneId': 'z45'},
+ 'Failover': 'SECONDARY',
+ 'Name': '_octodns-us-east-1-pool.unit.tests.',
+ 'SetIdentifier': 'us-east-1-Secondary-default',
+ 'Type': 'A'}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'AliasTarget': {
+ 'DNSName': '_octodns-ap-southeast-1-pool.unit.tests.',
+ 'EvaluateTargetHealth': True,
+ 'HostedZoneId': 'z45'},
+ 'GeoLocation': {
+ 'CountryCode': 'CN'},
+ 'Name': 'unit.tests.',
+ 'SetIdentifier': '0-ap-southeast-1-AS-CN',
+ 'Type': 'A'}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'AliasTarget': {
+ 'DNSName': '_octodns-ap-southeast-1-pool.unit.tests.',
+ 'EvaluateTargetHealth': True,
+ 'HostedZoneId': 'z45'},
+ 'GeoLocation': {
+ 'CountryCode': 'JP'},
+ 'Name': 'unit.tests.',
+ 'SetIdentifier': '0-ap-southeast-1-AS-JP',
+ 'Type': 'A'}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'AliasTarget': {
+ 'DNSName': '_octodns-eu-central-1-pool.unit.tests.',
+ 'EvaluateTargetHealth': True,
+ 'HostedZoneId': 'z45'},
+ 'GeoLocation': {
+ 'ContinentCode': 'EU'},
+ 'Name': 'unit.tests.',
+ 'SetIdentifier': '1-eu-central-1-EU',
+ 'Type': 'A'}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'AliasTarget': {
+ 'DNSName': '_octodns-eu-central-1-pool.unit.tests.',
+ 'EvaluateTargetHealth': True,
+ 'HostedZoneId': 'z45'},
+ 'GeoLocation': {
+ 'CountryCode': 'US',
+ 'SubdivisionCode': 'FL'},
+ 'Name': 'unit.tests.',
+ 'SetIdentifier': '1-eu-central-1-NA-US-FL',
+ 'Type': 'A'}
+ }, {
+ 'Action': 'CREATE',
+ 'ResourceRecordSet': {
+ 'AliasTarget': {
+ 'DNSName': '_octodns-us-east-1-pool.unit.tests.',
+ 'EvaluateTargetHealth': True,
+ 'HostedZoneId': 'z45'},
+ 'GeoLocation': {
+ 'CountryCode': '*'},
+ 'Name': 'unit.tests.',
+ 'SetIdentifier': '2-us-east-1-None',
+ 'Type': 'A'}
+ }], expected_mods)
for route53_record in route53_records:
# Smoke test stringification
@@ -2270,7 +2471,7 @@ class TestModKeyer(TestCase):
def test_mod_keyer(self):
- # First "column"
+ # First "column" is the action priority for C/R/U
# Deletes come first
self.assertEquals((0, 0, 'something'), _mod_keyer({
@@ -2288,8 +2489,8 @@ class TestModKeyer(TestCase):
}
}))
- # Then upserts
- self.assertEquals((2, 0, 'last'), _mod_keyer({
+ # Upserts are the same as creates
+ self.assertEquals((1, 0, 'last'), _mod_keyer({
'Action': 'UPSERT',
'ResourceRecordSet': {
'Name': 'last',
@@ -2299,7 +2500,7 @@ class TestModKeyer(TestCase):
# Second "column" value records tested above
# AliasTarget primary second (to value)
- self.assertEquals((0, 1, 'thing'), _mod_keyer({
+ self.assertEquals((0, -1, 'thing'), _mod_keyer({
'Action': 'DELETE',
'ResourceRecordSet': {
'AliasTarget': 'some-target',
@@ -2308,8 +2509,17 @@ class TestModKeyer(TestCase):
}
}))
+ self.assertEquals((1, 1, 'thing'), _mod_keyer({
+ 'Action': 'UPSERT',
+ 'ResourceRecordSet': {
+ 'AliasTarget': 'some-target',
+ 'Failover': 'PRIMARY',
+ 'Name': 'thing',
+ }
+ }))
+
# AliasTarget secondary third
- self.assertEquals((0, 2, 'thing'), _mod_keyer({
+ self.assertEquals((0, -2, 'thing'), _mod_keyer({
'Action': 'DELETE',
'ResourceRecordSet': {
'AliasTarget': 'some-target',
@@ -2318,8 +2528,17 @@ class TestModKeyer(TestCase):
}
}))
+ self.assertEquals((1, 2, 'thing'), _mod_keyer({
+ 'Action': 'UPSERT',
+ 'ResourceRecordSet': {
+ 'AliasTarget': 'some-target',
+ 'Failover': 'SECONDARY',
+ 'Name': 'thing',
+ }
+ }))
+
# GeoLocation fourth
- self.assertEquals((0, 3, 'some-id'), _mod_keyer({
+ self.assertEquals((0, -3, 'some-id'), _mod_keyer({
'Action': 'DELETE',
'ResourceRecordSet': {
'GeoLocation': 'some-target',
@@ -2327,4 +2546,12 @@ class TestModKeyer(TestCase):
}
}))
+ self.assertEquals((1, 3, 'some-id'), _mod_keyer({
+ 'Action': 'UPSERT',
+ 'ResourceRecordSet': {
+ 'GeoLocation': 'some-target',
+ 'SetIdentifier': 'some-id',
+ }
+ }))
+
# The third "column" has already been tested above, Name/SetIdentifier
diff --git a/tests/test_octodns_provider_selectel.py b/tests/test_octodns_provider_selectel.py
new file mode 100644
index 0000000..7ad1e6b
--- /dev/null
+++ b/tests/test_octodns_provider_selectel.py
@@ -0,0 +1,402 @@
+#
+#
+#
+
+from __future__ import absolute_import, division, print_function, \
+ unicode_literals
+
+from unittest import TestCase
+from six import text_type
+
+import requests_mock
+
+from octodns.provider.selectel import SelectelProvider
+from octodns.record import Record, Update
+from octodns.zone import Zone
+
+
+class TestSelectelProvider(TestCase):
+ API_URL = 'https://api.selectel.ru/domains/v1'
+
+ api_record = []
+
+ zone = Zone('unit.tests.', [])
+ expected = set()
+
+ domain = [{"name": "unit.tests", "id": 100000}]
+
+ # A, subdomain=''
+ api_record.append({
+ 'type': 'A',
+ 'ttl': 100,
+ 'content': '1.2.3.4',
+ 'name': 'unit.tests',
+ 'id': 1
+ })
+ expected.add(Record.new(zone, '', {
+ 'ttl': 100,
+ 'type': 'A',
+ 'value': '1.2.3.4',
+ }))
+
+ # A, subdomain='sub'
+ api_record.append({
+ 'type': 'A',
+ 'ttl': 200,
+ 'content': '1.2.3.4',
+ 'name': 'sub.unit.tests',
+ 'id': 2
+ })
+ expected.add(Record.new(zone, 'sub', {
+ 'ttl': 200,
+ 'type': 'A',
+ 'value': '1.2.3.4',
+ }))
+
+ # CNAME
+ api_record.append({
+ 'type': 'CNAME',
+ 'ttl': 300,
+ 'content': 'unit.tests',
+ 'name': 'www2.unit.tests',
+ 'id': 3
+ })
+ expected.add(Record.new(zone, 'www2', {
+ 'ttl': 300,
+ 'type': 'CNAME',
+ 'value': 'unit.tests.',
+ }))
+
+ # MX
+ api_record.append({
+ 'type': 'MX',
+ 'ttl': 400,
+ 'content': 'mx1.unit.tests',
+ 'priority': 10,
+ 'name': 'unit.tests',
+ 'id': 4
+ })
+ expected.add(Record.new(zone, '', {
+ 'ttl': 400,
+ 'type': 'MX',
+ 'values': [{
+ 'preference': 10,
+ 'exchange': 'mx1.unit.tests.',
+ }]
+ }))
+
+ # NS
+ api_record.append({
+ 'type': 'NS',
+ 'ttl': 600,
+ 'content': 'ns1.unit.tests',
+ 'name': 'unit.tests.',
+ 'id': 6
+ })
+ api_record.append({
+ 'type': 'NS',
+ 'ttl': 600,
+ 'content': 'ns2.unit.tests',
+ 'name': 'unit.tests',
+ 'id': 7
+ })
+ expected.add(Record.new(zone, '', {
+ 'ttl': 600,
+ 'type': 'NS',
+ 'values': ['ns1.unit.tests.', 'ns2.unit.tests.'],
+ }))
+
+ # NS with sub
+ api_record.append({
+ 'type': 'NS',
+ 'ttl': 700,
+ 'content': 'ns3.unit.tests',
+ 'name': 'www3.unit.tests',
+ 'id': 8
+ })
+ api_record.append({
+ 'type': 'NS',
+ 'ttl': 700,
+ 'content': 'ns4.unit.tests',
+ 'name': 'www3.unit.tests',
+ 'id': 9
+ })
+ expected.add(Record.new(zone, 'www3', {
+ 'ttl': 700,
+ 'type': 'NS',
+ 'values': ['ns3.unit.tests.', 'ns4.unit.tests.'],
+ }))
+
+ # SRV
+ api_record.append({
+ 'type': 'SRV',
+ 'ttl': 800,
+ 'target': 'foo-1.unit.tests',
+ 'weight': 20,
+ 'priority': 10,
+ 'port': 30,
+ 'id': 10,
+ 'name': '_srv._tcp.unit.tests'
+ })
+ api_record.append({
+ 'type': 'SRV',
+ 'ttl': 800,
+ 'target': 'foo-2.unit.tests',
+ 'name': '_srv._tcp.unit.tests',
+ 'weight': 50,
+ 'priority': 40,
+ 'port': 60,
+ 'id': 11
+ })
+ expected.add(Record.new(zone, '_srv._tcp', {
+ 'ttl': 800,
+ 'type': 'SRV',
+ 'values': [{
+ 'priority': 10,
+ 'weight': 20,
+ 'port': 30,
+ 'target': 'foo-1.unit.tests.',
+ }, {
+ 'priority': 40,
+ 'weight': 50,
+ 'port': 60,
+ 'target': 'foo-2.unit.tests.',
+ }]
+ }))
+
+ # AAAA
+ aaaa_record = {
+ 'type': 'AAAA',
+ 'ttl': 200,
+ 'content': '1:1ec:1::1',
+ 'name': 'unit.tests',
+ 'id': 15
+ }
+ api_record.append(aaaa_record)
+ expected.add(Record.new(zone, '', {
+ 'ttl': 200,
+ 'type': 'AAAA',
+ 'value': '1:1ec:1::1',
+ }))
+
+ # TXT
+ api_record.append({
+ 'type': 'TXT',
+ 'ttl': 300,
+ 'content': 'little text',
+ 'name': 'text.unit.tests',
+ 'id': 16
+ })
+ expected.add(Record.new(zone, 'text', {
+ 'ttl': 200,
+ 'type': 'TXT',
+ 'value': 'little text',
+ }))
+
+ @requests_mock.Mocker()
+ def test_populate(self, fake_http):
+ zone = Zone('unit.tests.', [])
+ fake_http.get('{}/unit.tests/records/'.format(self.API_URL),
+ json=self.api_record)
+ fake_http.get('{}/'.format(self.API_URL), json=self.domain)
+ fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.api_record))})
+ fake_http.head('{}/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.domain))})
+
+ provider = SelectelProvider(123, 'secret_token')
+ provider.populate(zone)
+
+ self.assertEquals(self.expected, zone.records)
+
+ @requests_mock.Mocker()
+ def test_populate_invalid_record(self, fake_http):
+ more_record = self.api_record
+ more_record.append({"name": "unit.tests",
+ "id": 100001,
+ "content": "support.unit.tests.",
+ "ttl": 300, "ns": "ns1.unit.tests",
+ "type": "SOA",
+ "email": "support@unit.tests"})
+
+ zone = Zone('unit.tests.', [])
+ fake_http.get('{}/unit.tests/records/'.format(self.API_URL),
+ json=more_record)
+ fake_http.get('{}/'.format(self.API_URL), json=self.domain)
+ fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.api_record))})
+ fake_http.head('{}/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.domain))})
+
+ zone.add_record(Record.new(self.zone, 'unsup', {
+ 'ttl': 200,
+ 'type': 'NAPTR',
+ 'value': {
+ 'order': 40,
+ 'preference': 70,
+ 'flags': 'U',
+ 'service': 'SIP+D2U',
+ 'regexp': '!^.*$!sip:info@bar.example.com!',
+ 'replacement': '.',
+ }
+ }))
+
+ provider = SelectelProvider(123, 'secret_token')
+ provider.populate(zone)
+
+ self.assertNotEqual(self.expected, zone.records)
+
+ @requests_mock.Mocker()
+ def test_apply(self, fake_http):
+
+ fake_http.get('{}/unit.tests/records/'.format(self.API_URL),
+ json=list())
+ fake_http.get('{}/'.format(self.API_URL), json=self.domain)
+ fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
+ headers={'X-Total-Count': '0'})
+ fake_http.head('{}/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.domain))})
+ fake_http.post('{}/100000/records/'.format(self.API_URL), json=list())
+
+ provider = SelectelProvider(123, 'test_token')
+
+ zone = Zone('unit.tests.', [])
+
+ for record in self.expected:
+ zone.add_record(record)
+
+ plan = provider.plan(zone)
+ self.assertEquals(8, len(plan.changes))
+ self.assertEquals(8, provider.apply(plan))
+
+ @requests_mock.Mocker()
+ def test_domain_list(self, fake_http):
+ fake_http.get('{}/'.format(self.API_URL), json=self.domain)
+ fake_http.head('{}/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.domain))})
+
+ expected = {'unit.tests': self.domain[0]}
+ provider = SelectelProvider(123, 'test_token')
+
+ result = provider.domain_list()
+ self.assertEquals(result, expected)
+
+ @requests_mock.Mocker()
+ def test_authentication_fail(self, fake_http):
+ fake_http.get('{}/'.format(self.API_URL), status_code=401)
+ fake_http.head('{}/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.domain))})
+
+ with self.assertRaises(Exception) as ctx:
+ SelectelProvider(123, 'fail_token')
+ self.assertEquals(text_type(ctx.exception),
+ 'Authorization failed. Invalid or empty token.')
+
+ @requests_mock.Mocker()
+ def test_not_exist_domain(self, fake_http):
+ fake_http.get('{}/'.format(self.API_URL), status_code=404, json='')
+ fake_http.head('{}/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.domain))})
+
+ fake_http.post('{}/'.format(self.API_URL),
+ json={"name": "unit.tests",
+ "create_date": 1507154178,
+ "id": 100000})
+ fake_http.get('{}/unit.tests/records/'.format(self.API_URL),
+ json=list())
+ fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.api_record))})
+ fake_http.post('{}/100000/records/'.format(self.API_URL),
+ json=list())
+
+ provider = SelectelProvider(123, 'test_token')
+
+ zone = Zone('unit.tests.', [])
+
+ for record in self.expected:
+ zone.add_record(record)
+
+ plan = provider.plan(zone)
+ self.assertEquals(8, len(plan.changes))
+ self.assertEquals(8, provider.apply(plan))
+
+ @requests_mock.Mocker()
+ def test_delete_no_exist_record(self, fake_http):
+ fake_http.get('{}/'.format(self.API_URL), json=self.domain)
+ fake_http.get('{}/100000/records/'.format(self.API_URL), json=list())
+ fake_http.head('{}/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.domain))})
+ fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
+ headers={'X-Total-Count': '0'})
+
+ provider = SelectelProvider(123, 'test_token')
+
+ zone = Zone('unit.tests.', [])
+
+ provider.delete_record('unit.tests', 'NS', zone)
+
+ @requests_mock.Mocker()
+ def test_change_record(self, fake_http):
+ exist_record = [self.aaaa_record,
+ {"content": "6.6.5.7",
+ "ttl": 100,
+ "type": "A",
+ "id": 100001,
+ "name": "delete.unit.tests"},
+ {"content": "9.8.2.1",
+ "ttl": 100,
+ "type": "A",
+ "id": 100002,
+ "name": "unit.tests"}] # exist
+ fake_http.get('{}/unit.tests/records/'.format(self.API_URL),
+ json=exist_record)
+ fake_http.get('{}/'.format(self.API_URL), json=self.domain)
+ fake_http.get('{}/100000/records/'.format(self.API_URL),
+ json=exist_record)
+ fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(exist_record))})
+ fake_http.head('{}/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.domain))})
+ fake_http.head('{}/100000/records/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(exist_record))})
+ fake_http.post('{}/100000/records/'.format(self.API_URL),
+ json=list())
+ fake_http.delete('{}/100000/records/100001'.format(self.API_URL),
+ text="")
+ fake_http.delete('{}/100000/records/100002'.format(self.API_URL),
+ text="")
+
+ provider = SelectelProvider(123, 'test_token')
+
+ zone = Zone('unit.tests.', [])
+
+ for record in self.expected:
+ zone.add_record(record)
+
+ plan = provider.plan(zone)
+ self.assertEquals(8, len(plan.changes))
+ self.assertEquals(8, provider.apply(plan))
+
+ @requests_mock.Mocker()
+ def test_include_change_returns_false(self, fake_http):
+ fake_http.get('{}/'.format(self.API_URL), json=self.domain)
+ fake_http.head('{}/'.format(self.API_URL),
+ headers={'X-Total-Count': str(len(self.domain))})
+ provider = SelectelProvider(123, 'test_token')
+ zone = Zone('unit.tests.', [])
+
+ exist_record = Record.new(zone, '', {
+ 'ttl': 60,
+ 'type': 'A',
+ 'values': ['1.1.1.1', '2.2.2.2']
+ })
+ new = Record.new(zone, '', {
+ 'ttl': 10,
+ 'type': 'A',
+ 'values': ['1.1.1.1', '2.2.2.2']
+ })
+ change = Update(exist_record, new)
+
+ include_change = provider._include_change(change)
+
+ self.assertFalse(include_change)
diff --git a/tests/test_octodns_provider_transip.py b/tests/test_octodns_provider_transip.py
new file mode 100644
index 0000000..3fbfc44
--- /dev/null
+++ b/tests/test_octodns_provider_transip.py
@@ -0,0 +1,275 @@
+#
+#
+#
+
+from __future__ import absolute_import, division, print_function, \
+ unicode_literals
+
+from os.path import dirname, join
+from six import text_type
+
+from suds import WebFault
+
+from unittest import TestCase
+
+from octodns.provider.transip import TransipProvider
+from octodns.provider.yaml import YamlProvider
+from octodns.zone import Zone
+from transip.service.domain import DomainService
+from transip.service.objects import DnsEntry
+
+
+class MockFault(object):
+ faultstring = ""
+ faultcode = ""
+
+ def __init__(self, code, string, *args, **kwargs):
+ self.faultstring = string
+ self.faultcode = code
+
+
+class MockResponse(object):
+ dnsEntries = []
+
+
+class MockDomainService(DomainService):
+
+ def __init__(self, *args, **kwargs):
+ super(MockDomainService, self).__init__('MockDomainService', *args,
+ **kwargs)
+ self.mockupEntries = []
+
+ def mockup(self, records):
+
+ provider = TransipProvider('', '', '')
+
+ _dns_entries = []
+ for record in records:
+ if record._type in provider.SUPPORTS:
+ entries_for = getattr(provider,
+ '_entries_for_{}'.format(record._type))
+
+ # Root records have '@' as name
+ name = record.name
+ if name == '':
+ name = provider.ROOT_RECORD
+
+ _dns_entries.extend(entries_for(name, record))
+
+ # NS is not supported as a DNS Entry,
+ # so it should cover the if statement
+ _dns_entries.append(
+ DnsEntry('@', '3600', 'NS', 'ns01.transip.nl.'))
+
+ self.mockupEntries = _dns_entries
+
+ # Skips authentication layer and returns the entries loaded by "Mockup"
+ def get_info(self, domain_name):
+
+ # Special 'domain' to trigger error
+ if str(domain_name) == str('notfound.unit.tests'):
+ self.raiseZoneNotFound()
+
+ result = MockResponse()
+ result.dnsEntries = self.mockupEntries
+ return result
+
+ def set_dns_entries(self, domain_name, dns_entries):
+
+ # Special 'domain' to trigger error
+ if str(domain_name) == str('failsetdns.unit.tests'):
+ self.raiseSaveError()
+
+ return True
+
+ def raiseZoneNotFound(self):
+ fault = MockFault(str('102'), '102 is zone not found')
+ document = {}
+ raise WebFault(fault, document)
+
+ def raiseInvalidAuth(self):
+ fault = MockFault(str('200'), '200 is invalid auth')
+ document = {}
+ raise WebFault(fault, document)
+
+ def raiseSaveError(self):
+ fault = MockFault(str('200'), '202 random error')
+ document = {}
+ raise WebFault(fault, document)
+
+
+class TestTransipProvider(TestCase):
+ bogus_key = str("""-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEA0U5HGCkLrz423IyUf3u4cKN2WrNz1x5KNr6PvH2M/zxas+zB
+elbxkdT3AQ+wmfcIvOuTmFRTHv35q2um1aBrPxVw+2s+lWo28VwIRttwIB1vIeWu
+lSBnkEZQRLyPI2tH0i5QoMX4CVPf9rvij3Uslimi84jdzDfPFIh6jZ6C8nLipOTG
+0IMhge1ofVfB0oSy5H+7PYS2858QLAf5ruYbzbAxZRivS402wGmQ0d0Lc1KxraAj
+kiMM5yj/CkH/Vm2w9I6+tLFeASE4ub5HCP5G/ig4dbYtqZMQMpqyAbGxd5SOVtyn
+UHagAJUxf8DT3I8PyjEHjxdOPUsxNyRtepO/7QIDAQABAoIBAQC7fiZ7gxE/ezjD
+2n6PsHFpHVTBLS2gzzZl0dCKZeFvJk6ODJDImaeuHhrh7X8ifMNsEI9XjnojMhl8
+MGPzy88mZHugDNK0H8B19x5G8v1/Fz7dG5WHas660/HFkS+b59cfdXOugYiOOn9O
+08HBBpLZNRUOmVUuQfQTjapSwGLG8PocgpyRD4zx0LnldnJcqYCxwCdev+AAsPnq
+ibNtOd/MYD37w9MEGcaxLE8wGgkv8yd97aTjkgE+tp4zsM4QE4Rag133tsLLNznT
+4Qr/of15M3NW/DXq/fgctyRcJjZpU66eCXLCz2iRTnLyyxxDC2nwlxKbubV+lcS0
+S4hbfd/BAoGBAO8jXxEaiybR0aIhhSR5esEc3ymo8R8vBN3ZMJ+vr5jEPXr/ZuFj
+/R4cZ2XV3VoQJG0pvIOYVPZ5DpJM7W+zSXtJ/7bLXy4Bnmh/rc+YYgC+AXQoLSil
+iD2OuB2xAzRAK71DVSO0kv8gEEXCersPT2i6+vC2GIlJvLcYbOdRKWGxAoGBAOAQ
+aJbRLtKujH+kMdoMI7tRlL8XwI+SZf0FcieEu//nFyerTePUhVgEtcE+7eQ7hyhG
+fIXUFx/wALySoqFzdJDLc8U8pTLhbUaoLOTjkwnCTKQVprhnISqQqqh/0U5u47IE
+RWzWKN6OHb0CezNTq80Dr6HoxmPCnJHBHn5LinT9AoGAQSpvZpbIIqz8pmTiBl2A
+QQ2gFpcuFeRXPClKYcmbXVLkuhbNL1BzEniFCLAt4LQTaRf9ghLJ3FyCxwVlkpHV
+zV4N6/8hkcTpKOraL38D/dXJSaEFJVVuee/hZl3tVJjEEpA9rDwx7ooLRSdJEJ6M
+ciq55UyKBSdt4KssSiDI2RECgYBL3mJ7xuLy5bWfNsrGiVvD/rC+L928/5ZXIXPw
+26oI0Yfun7ulDH4GOroMcDF/GYT/Zzac3h7iapLlR0WYI47xxGI0A//wBZLJ3QIu
+krxkDo2C9e3Y/NqnHgsbOQR3aWbiDT4wxydZjIeXS3LKA2fl6Hyc90PN3cTEOb8I
+hq2gRQKBgEt0SxhhtyB93SjgTzmUZZ7PiEf0YJatfM6cevmjWHexrZH+x31PB72s
+fH2BQyTKKzoCLB1k/6HRaMnZdrWyWSZ7JKz3AHJ8+58d0Hr8LTrzDM1L6BbjeDct
+N4OiVz1I3rbZGYa396lpxO6ku8yCglisL1yrSP6DdEUp66ntpKVd
+-----END RSA PRIVATE KEY-----""")
+
+ def make_expected(self):
+ expected = Zone('unit.tests.', [])
+ source = YamlProvider('test', join(dirname(__file__), 'config'))
+ source.populate(expected)
+ return expected
+
+ def test_init(self):
+ with self.assertRaises(Exception) as ctx:
+ TransipProvider('test', 'unittest')
+
+ self.assertEquals(
+ str('Missing `key` of `key_file` parameter in config'),
+ str(ctx.exception))
+
+ TransipProvider('test', 'unittest', key=self.bogus_key)
+
+ # Existence and content of the key is tested in the SDK on client call
+ TransipProvider('test', 'unittest', key_file='/fake/path')
+
+ def test_populate(self):
+ _expected = self.make_expected()
+
+ # Unhappy Plan - Not authenticated
+ # Live test against API, will fail in an unauthorized error
+ with self.assertRaises(WebFault) as ctx:
+ provider = TransipProvider('test', 'unittest', self.bogus_key)
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone, True)
+
+ self.assertEquals(str('WebFault'),
+ str(ctx.exception.__class__.__name__))
+
+ self.assertEquals(str('200'), ctx.exception.fault.faultcode)
+
+ # Unhappy Plan - Zone does not exists
+ # Will trigger an exception if provider is used as a target for a
+ # non-existing zone
+ with self.assertRaises(Exception) as ctx:
+ provider = TransipProvider('test', 'unittest', self.bogus_key)
+ provider._client = MockDomainService('unittest', self.bogus_key)
+ zone = Zone('notfound.unit.tests.', [])
+ provider.populate(zone, True)
+
+ self.assertEquals(str('TransipNewZoneException'),
+ str(ctx.exception.__class__.__name__))
+
+ self.assertEquals(
+ 'populate: (102) Transip used as target' +
+ ' for non-existing zone: notfound.unit.tests.',
+ text_type(ctx.exception))
+
+ # Happy Plan - Zone does not exists
+ # Won't trigger an exception if provider is NOT used as a target for a
+ # non-existing zone.
+ provider = TransipProvider('test', 'unittest', self.bogus_key)
+ provider._client = MockDomainService('unittest', self.bogus_key)
+ zone = Zone('notfound.unit.tests.', [])
+ provider.populate(zone, False)
+
+ # Happy Plan - Populate with mockup records
+ provider = TransipProvider('test', 'unittest', self.bogus_key)
+ provider._client = MockDomainService('unittest', self.bogus_key)
+ provider._client.mockup(_expected.records)
+ zone = Zone('unit.tests.', [])
+ provider.populate(zone, False)
+
+ # Transip allows relative values for types like cname, mx.
+ # Test is these are correctly appended with the domain
+ provider._currentZone = zone
+ self.assertEquals("www.unit.tests.", provider._parse_to_fqdn("www"))
+ self.assertEquals("www.unit.tests.",
+ provider._parse_to_fqdn("www.unit.tests."))
+ self.assertEquals("www.sub.sub.sub.unit.tests.",
+ provider._parse_to_fqdn("www.sub.sub.sub"))
+ self.assertEquals("unit.tests.",
+ provider._parse_to_fqdn("@"))
+
+ # Happy Plan - Even if the zone has no records the zone should exist
+ provider = TransipProvider('test', 'unittest', self.bogus_key)
+ provider._client = MockDomainService('unittest', self.bogus_key)
+ zone = Zone('unit.tests.', [])
+ exists = provider.populate(zone, True)
+ self.assertTrue(exists, 'populate should return true')
+
+ return
+
+ def test_plan(self):
+ _expected = self.make_expected()
+
+ # Test Happy plan, only create
+ provider = TransipProvider('test', 'unittest', self.bogus_key)
+ provider._client = MockDomainService('unittest', self.bogus_key)
+ plan = provider.plan(_expected)
+
+ self.assertEqual(12, plan.change_counts['Create'])
+ self.assertEqual(0, plan.change_counts['Update'])
+ self.assertEqual(0, plan.change_counts['Delete'])
+
+ return
+
+ def test_apply(self):
+ _expected = self.make_expected()
+
+ # Test happy flow. Create all supoorted records
+ provider = TransipProvider('test', 'unittest', self.bogus_key)
+ provider._client = MockDomainService('unittest', self.bogus_key)
+ plan = provider.plan(_expected)
+ self.assertEqual(12, len(plan.changes))
+ changes = provider.apply(plan)
+ self.assertEqual(changes, len(plan.changes))
+
+ # Test unhappy flow. Trigger 'not found error' in apply stage
+ # This should normally not happen as populate will capture it first
+ # but just in case.
+ changes = [] # reset changes
+ with self.assertRaises(Exception) as ctx:
+ provider = TransipProvider('test', 'unittest', self.bogus_key)
+ provider._client = MockDomainService('unittest', self.bogus_key)
+ plan = provider.plan(_expected)
+ plan.desired.name = 'notfound.unit.tests.'
+ changes = provider.apply(plan)
+
+ # Changes should not be set due to an Exception
+ self.assertEqual([], changes)
+
+ self.assertEquals(str('WebFault'),
+ str(ctx.exception.__class__.__name__))
+
+ self.assertEquals(str('102'), ctx.exception.fault.faultcode)
+
+ # Test unhappy flow. Trigger a unrecoverable error while saving
+ _expected = self.make_expected() # reset expected
+ changes = [] # reset changes
+
+ with self.assertRaises(Exception) as ctx:
+ provider = TransipProvider('test', 'unittest', self.bogus_key)
+ provider._client = MockDomainService('unittest', self.bogus_key)
+ plan = provider.plan(_expected)
+ plan.desired.name = 'failsetdns.unit.tests.'
+ changes = provider.apply(plan)
+
+ # Changes should not be set due to an Exception
+ self.assertEqual([], changes)
+
+ self.assertEquals(str('TransipException'),
+ str(ctx.exception.__class__.__name__))
diff --git a/tests/test_octodns_provider_yaml.py b/tests/test_octodns_provider_yaml.py
index 123f9b2..0efcee9 100644
--- a/tests/test_octodns_provider_yaml.py
+++ b/tests/test_octodns_provider_yaml.py
@@ -8,6 +8,7 @@ from __future__ import absolute_import, division, print_function, \
from os import makedirs
from os.path import basename, dirname, isdir, isfile, join
from unittest import TestCase
+from six import text_type
from yaml import safe_load
from yaml.constructor import ConstructorError
@@ -57,8 +58,8 @@ class TestYamlProvider(TestCase):
# We add everything
plan = target.plan(zone)
- self.assertEquals(15, len(filter(lambda c: isinstance(c, Create),
- plan.changes)))
+ self.assertEquals(15, len([c for c in plan.changes
+ if isinstance(c, Create)]))
self.assertFalse(isfile(yaml_file))
# Now actually do it
@@ -67,8 +68,8 @@ class TestYamlProvider(TestCase):
# Dynamic plan
plan = target.plan(dynamic_zone)
- self.assertEquals(5, len(filter(lambda c: isinstance(c, Create),
- plan.changes)))
+ self.assertEquals(5, len([c for c in plan.changes
+ if isinstance(c, Create)]))
self.assertFalse(isfile(dynamic_yaml_file))
# Apply it
self.assertEquals(5, target.apply(plan))
@@ -79,16 +80,15 @@ class TestYamlProvider(TestCase):
target.populate(reloaded)
self.assertDictEqual(
{'included': ['test']},
- filter(
- lambda x: x.name == 'included', reloaded.records
- )[0]._octodns)
+ [x for x in reloaded.records
+ if x.name == 'included'][0]._octodns)
self.assertFalse(zone.changes(reloaded, target=source))
# A 2nd sync should still create everything
plan = target.plan(zone)
- self.assertEquals(15, len(filter(lambda c: isinstance(c, Create),
- plan.changes)))
+ self.assertEquals(15, len([c for c in plan.changes
+ if isinstance(c, Create)]))
with open(yaml_file) as fh:
data = safe_load(fh.read())
@@ -116,7 +116,7 @@ class TestYamlProvider(TestCase):
self.assertTrue('value' in data.pop('www.sub'))
# make sure nothing is left
- self.assertEquals([], data.keys())
+ self.assertEquals([], list(data.keys()))
with open(dynamic_yaml_file) as fh:
data = safe_load(fh.read())
@@ -145,7 +145,7 @@ class TestYamlProvider(TestCase):
# self.assertTrue('dynamic' in dyna)
# make sure nothing is left
- self.assertEquals([], data.keys())
+ self.assertEquals([], list(data.keys()))
def test_empty(self):
source = YamlProvider('test', join(dirname(__file__), 'config'))
@@ -178,7 +178,7 @@ class TestYamlProvider(TestCase):
with self.assertRaises(SubzoneRecordException) as ctx:
source.populate(zone)
self.assertEquals('Record www.sub.unit.tests. is under a managed '
- 'subzone', ctx.exception.message)
+ 'subzone', text_type(ctx.exception))
class TestSplitYamlProvider(TestCase):
@@ -201,9 +201,8 @@ class TestSplitYamlProvider(TestCase):
# This isn't great, but given the variable nature of the temp dir
# names, it's necessary.
- self.assertItemsEqual(
- yaml_files,
- (basename(f) for f in _list_all_yaml_files(directory)))
+ d = list(basename(f) for f in _list_all_yaml_files(directory))
+ self.assertEqual(len(yaml_files), len(d))
def test_zone_directory(self):
source = SplitYamlProvider(
@@ -252,8 +251,8 @@ class TestSplitYamlProvider(TestCase):
# We add everything
plan = target.plan(zone)
- self.assertEquals(15, len(filter(lambda c: isinstance(c, Create),
- plan.changes)))
+ self.assertEquals(15, len([c for c in plan.changes
+ if isinstance(c, Create)]))
self.assertFalse(isdir(zone_dir))
# Now actually do it
@@ -261,8 +260,8 @@ class TestSplitYamlProvider(TestCase):
# Dynamic plan
plan = target.plan(dynamic_zone)
- self.assertEquals(5, len(filter(lambda c: isinstance(c, Create),
- plan.changes)))
+ self.assertEquals(5, len([c for c in plan.changes
+ if isinstance(c, Create)]))
self.assertFalse(isdir(dynamic_zone_dir))
# Apply it
self.assertEquals(5, target.apply(plan))
@@ -273,16 +272,15 @@ class TestSplitYamlProvider(TestCase):
target.populate(reloaded)
self.assertDictEqual(
{'included': ['test']},
- filter(
- lambda x: x.name == 'included', reloaded.records
- )[0]._octodns)
+ [x for x in reloaded.records
+ if x.name == 'included'][0]._octodns)
self.assertFalse(zone.changes(reloaded, target=source))
# A 2nd sync should still create everything
plan = target.plan(zone)
- self.assertEquals(15, len(filter(lambda c: isinstance(c, Create),
- plan.changes)))
+ self.assertEquals(15, len([c for c in plan.changes
+ if isinstance(c, Create)]))
yaml_file = join(zone_dir, '$unit.tests.yaml')
self.assertTrue(isfile(yaml_file))
@@ -371,7 +369,7 @@ class TestSplitYamlProvider(TestCase):
with self.assertRaises(SubzoneRecordException) as ctx:
source.populate(zone)
self.assertEquals('Record www.sub.unit.tests. is under a managed '
- 'subzone', ctx.exception.message)
+ 'subzone', text_type(ctx.exception))
class TestOverridingYamlProvider(TestCase):
diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py
index 53bc5e7..f313342 100644
--- a/tests/test_octodns_record.py
+++ b/tests/test_octodns_record.py
@@ -5,12 +5,14 @@
from __future__ import absolute_import, division, print_function, \
unicode_literals
+from six import text_type
from unittest import TestCase
from octodns.record import ARecord, AaaaRecord, AliasRecord, CaaRecord, \
- CnameRecord, Create, Delete, GeoValue, MxRecord, NaptrRecord, NaptrValue, \
- NsRecord, PtrRecord, Record, SshfpRecord, SpfRecord, SrvRecord, \
- TxtRecord, Update, ValidationError, _Dynamic, _DynamicPool, _DynamicRule
+ CaaValue, CnameRecord, Create, Delete, GeoValue, MxRecord, MxValue, \
+ NaptrRecord, NaptrValue, NsRecord, PtrRecord, Record, SshfpRecord, \
+ SshfpValue, SpfRecord, SrvRecord, SrvValue, TxtRecord, Update, \
+ ValidationError, _Dynamic, _DynamicPool, _DynamicRule
from octodns.zone import Zone
from helpers import DynamicProvider, GeoProvider, SimpleProvider
@@ -482,113 +484,140 @@ class TestRecord(TestCase):
# full sorting
# equivalent
b_naptr_value = b.values[0]
- self.assertEquals(0, b_naptr_value.__cmp__(b_naptr_value))
+ self.assertTrue(b_naptr_value == b_naptr_value)
+ self.assertFalse(b_naptr_value != b_naptr_value)
+ self.assertTrue(b_naptr_value <= b_naptr_value)
+ self.assertTrue(b_naptr_value >= b_naptr_value)
# by order
- self.assertEquals(1, b_naptr_value.__cmp__(NaptrValue({
+ self.assertTrue(b_naptr_value > NaptrValue({
'order': 10,
'preference': 31,
'flags': 'M',
'service': 'N',
'regexp': 'O',
'replacement': 'x',
- })))
- self.assertEquals(-1, b_naptr_value.__cmp__(NaptrValue({
+ }))
+ self.assertTrue(b_naptr_value < NaptrValue({
'order': 40,
'preference': 31,
'flags': 'M',
'service': 'N',
'regexp': 'O',
'replacement': 'x',
- })))
+ }))
# by preference
- self.assertEquals(1, b_naptr_value.__cmp__(NaptrValue({
+ self.assertTrue(b_naptr_value > NaptrValue({
'order': 30,
'preference': 10,
'flags': 'M',
'service': 'N',
'regexp': 'O',
'replacement': 'x',
- })))
- self.assertEquals(-1, b_naptr_value.__cmp__(NaptrValue({
+ }))
+ self.assertTrue(b_naptr_value < NaptrValue({
'order': 30,
'preference': 40,
'flags': 'M',
'service': 'N',
'regexp': 'O',
'replacement': 'x',
- })))
+ }))
# by flags
- self.assertEquals(1, b_naptr_value.__cmp__(NaptrValue({
+ self.assertTrue(b_naptr_value > NaptrValue({
'order': 30,
'preference': 31,
'flags': 'A',
'service': 'N',
'regexp': 'O',
'replacement': 'x',
- })))
- self.assertEquals(-1, b_naptr_value.__cmp__(NaptrValue({
+ }))
+ self.assertTrue(b_naptr_value < NaptrValue({
'order': 30,
'preference': 31,
'flags': 'Z',
'service': 'N',
'regexp': 'O',
'replacement': 'x',
- })))
+ }))
# by service
- self.assertEquals(1, b_naptr_value.__cmp__(NaptrValue({
+ self.assertTrue(b_naptr_value > NaptrValue({
'order': 30,
'preference': 31,
'flags': 'M',
'service': 'A',
'regexp': 'O',
'replacement': 'x',
- })))
- self.assertEquals(-1, b_naptr_value.__cmp__(NaptrValue({
+ }))
+ self.assertTrue(b_naptr_value < NaptrValue({
'order': 30,
'preference': 31,
'flags': 'M',
'service': 'Z',
'regexp': 'O',
'replacement': 'x',
- })))
+ }))
# by regexp
- self.assertEquals(1, b_naptr_value.__cmp__(NaptrValue({
+ self.assertTrue(b_naptr_value > NaptrValue({
'order': 30,
'preference': 31,
'flags': 'M',
'service': 'N',
'regexp': 'A',
'replacement': 'x',
- })))
- self.assertEquals(-1, b_naptr_value.__cmp__(NaptrValue({
+ }))
+ self.assertTrue(b_naptr_value < NaptrValue({
'order': 30,
'preference': 31,
'flags': 'M',
'service': 'N',
'regexp': 'Z',
'replacement': 'x',
- })))
+ }))
# by replacement
- self.assertEquals(1, b_naptr_value.__cmp__(NaptrValue({
+ self.assertTrue(b_naptr_value > NaptrValue({
'order': 30,
'preference': 31,
'flags': 'M',
'service': 'N',
'regexp': 'O',
'replacement': 'a',
- })))
- self.assertEquals(-1, b_naptr_value.__cmp__(NaptrValue({
+ }))
+ self.assertTrue(b_naptr_value < NaptrValue({
'order': 30,
'preference': 31,
'flags': 'M',
'service': 'N',
'regexp': 'O',
'replacement': 'z',
- })))
+ }))
# __repr__ doesn't blow up
a.__repr__()
+ # Hash
+ v = NaptrValue({
+ 'order': 30,
+ 'preference': 31,
+ 'flags': 'M',
+ 'service': 'N',
+ 'regexp': 'O',
+ 'replacement': 'z',
+ })
+ o = NaptrValue({
+ 'order': 30,
+ 'preference': 32,
+ 'flags': 'M',
+ 'service': 'N',
+ 'regexp': 'O',
+ 'replacement': 'z',
+ })
+ values = set()
+ values.add(v)
+ self.assertTrue(v in values)
+ self.assertFalse(o in values)
+ values.add(o)
+ self.assertTrue(o in values)
+
def test_ns(self):
a_values = ['5.6.7.8.', '6.7.8.9.', '7.8.9.0.']
a_data = {'ttl': 30, 'values': a_values}
@@ -758,14 +787,14 @@ class TestRecord(TestCase):
# Missing type
with self.assertRaises(Exception) as ctx:
Record.new(self.zone, 'unknown', {})
- self.assertTrue('missing type' in ctx.exception.message)
+ self.assertTrue('missing type' in text_type(ctx.exception))
# Unknown type
with self.assertRaises(Exception) as ctx:
Record.new(self.zone, 'unknown', {
'type': 'XXX',
})
- self.assertTrue('Unknown record type' in ctx.exception.message)
+ self.assertTrue('Unknown record type' in text_type(ctx.exception))
def test_change(self):
existing = Record.new(self.zone, 'txt', {
@@ -796,6 +825,38 @@ class TestRecord(TestCase):
self.assertEquals(values, geo.values)
self.assertEquals(['NA-US', 'NA'], list(geo.parents))
+ a = GeoValue('NA-US-CA', values)
+ b = GeoValue('AP-JP', values)
+ c = GeoValue('NA-US-CA', ['2.3.4.5'])
+
+ self.assertEqual(a, a)
+ self.assertEqual(b, b)
+ self.assertEqual(c, c)
+
+ self.assertNotEqual(a, b)
+ self.assertNotEqual(a, c)
+ self.assertNotEqual(b, a)
+ self.assertNotEqual(b, c)
+ self.assertNotEqual(c, a)
+ self.assertNotEqual(c, b)
+
+ self.assertTrue(a > b)
+ self.assertTrue(a < c)
+ self.assertTrue(b < a)
+ self.assertTrue(b < c)
+ self.assertTrue(c > a)
+ self.assertTrue(c > b)
+
+ self.assertTrue(a >= a)
+ self.assertTrue(a >= b)
+ self.assertTrue(a <= c)
+ self.assertTrue(b <= a)
+ self.assertTrue(b <= b)
+ self.assertTrue(b <= c)
+ self.assertTrue(c > a)
+ self.assertTrue(c > b)
+ self.assertTrue(c >= b)
+
def test_healthcheck(self):
new = Record.new(self.zone, 'a', {
'ttl': 44,
@@ -851,11 +912,339 @@ class TestRecord(TestCase):
})
self.assertFalse(new.ignored)
+ def test_ordering_functions(self):
+ a = Record.new(self.zone, 'a', {
+ 'ttl': 44,
+ 'type': 'A',
+ 'value': '1.2.3.4',
+ })
+ b = Record.new(self.zone, 'b', {
+ 'ttl': 44,
+ 'type': 'A',
+ 'value': '1.2.3.4',
+ })
+ c = Record.new(self.zone, 'c', {
+ 'ttl': 44,
+ 'type': 'A',
+ 'value': '1.2.3.4',
+ })
+ aaaa = Record.new(self.zone, 'a', {
+ 'ttl': 44,
+ 'type': 'AAAA',
+ 'value': '2601:644:500:e210:62f8:1dff:feb8:947a',
+ })
+
+ self.assertEquals(a, a)
+ self.assertEquals(b, b)
+ self.assertEquals(c, c)
+ self.assertEquals(aaaa, aaaa)
+
+ self.assertNotEqual(a, b)
+ self.assertNotEqual(a, c)
+ self.assertNotEqual(a, aaaa)
+ self.assertNotEqual(b, a)
+ self.assertNotEqual(b, c)
+ self.assertNotEqual(b, aaaa)
+ self.assertNotEqual(c, a)
+ self.assertNotEqual(c, b)
+ self.assertNotEqual(c, aaaa)
+ self.assertNotEqual(aaaa, a)
+ self.assertNotEqual(aaaa, b)
+ self.assertNotEqual(aaaa, c)
+
+ self.assertTrue(a < b)
+ self.assertTrue(a < c)
+ self.assertTrue(a < aaaa)
+ self.assertTrue(b > a)
+ self.assertTrue(b < c)
+ self.assertTrue(b > aaaa)
+ self.assertTrue(c > a)
+ self.assertTrue(c > b)
+ self.assertTrue(c > aaaa)
+ self.assertTrue(aaaa > a)
+ self.assertTrue(aaaa < b)
+ self.assertTrue(aaaa < c)
+
+ self.assertTrue(a <= a)
+ self.assertTrue(a <= b)
+ self.assertTrue(a <= c)
+ self.assertTrue(a <= aaaa)
+ self.assertTrue(b >= a)
+ self.assertTrue(b >= b)
+ self.assertTrue(b <= c)
+ self.assertTrue(b >= aaaa)
+ self.assertTrue(c >= a)
+ self.assertTrue(c >= b)
+ self.assertTrue(c >= c)
+ self.assertTrue(c >= aaaa)
+ self.assertTrue(aaaa >= a)
+ self.assertTrue(aaaa <= b)
+ self.assertTrue(aaaa <= c)
+ self.assertTrue(aaaa <= aaaa)
+
+ def test_caa_value(self):
+ a = CaaValue({'flags': 0, 'tag': 'a', 'value': 'v'})
+ b = CaaValue({'flags': 1, 'tag': 'a', 'value': 'v'})
+ c = CaaValue({'flags': 0, 'tag': 'c', 'value': 'v'})
+ d = CaaValue({'flags': 0, 'tag': 'a', 'value': 'z'})
+
+ self.assertEqual(a, a)
+ self.assertEqual(b, b)
+ self.assertEqual(c, c)
+ self.assertEqual(d, d)
+
+ self.assertNotEqual(a, b)
+ self.assertNotEqual(a, c)
+ self.assertNotEqual(a, d)
+ self.assertNotEqual(b, a)
+ self.assertNotEqual(b, c)
+ self.assertNotEqual(b, d)
+ self.assertNotEqual(c, a)
+ self.assertNotEqual(c, b)
+ self.assertNotEqual(c, d)
+
+ self.assertTrue(a < b)
+ self.assertTrue(a < c)
+ self.assertTrue(a < d)
+
+ self.assertTrue(b > a)
+ self.assertTrue(b > c)
+ self.assertTrue(b > d)
+
+ self.assertTrue(c > a)
+ self.assertTrue(c < b)
+ self.assertTrue(c > d)
+
+ self.assertTrue(d > a)
+ self.assertTrue(d < b)
+ self.assertTrue(d < c)
+
+ self.assertTrue(a <= b)
+ self.assertTrue(a <= c)
+ self.assertTrue(a <= d)
+ self.assertTrue(a <= a)
+ self.assertTrue(a >= a)
+
+ self.assertTrue(b >= a)
+ self.assertTrue(b >= c)
+ self.assertTrue(b >= d)
+ self.assertTrue(b >= b)
+ self.assertTrue(b <= b)
+
+ self.assertTrue(c >= a)
+ self.assertTrue(c <= b)
+ self.assertTrue(c >= d)
+ self.assertTrue(c >= c)
+ self.assertTrue(c <= c)
+
+ self.assertTrue(d >= a)
+ self.assertTrue(d <= b)
+ self.assertTrue(d <= c)
+ self.assertTrue(d >= d)
+ self.assertTrue(d <= d)
+
+ def test_mx_value(self):
+ a = MxValue({'preference': 0, 'priority': 'a', 'exchange': 'v',
+ 'value': '1'})
+ b = MxValue({'preference': 10, 'priority': 'a', 'exchange': 'v',
+ 'value': '2'})
+ c = MxValue({'preference': 0, 'priority': 'b', 'exchange': 'z',
+ 'value': '3'})
+
+ self.assertEqual(a, a)
+ self.assertEqual(b, b)
+ self.assertEqual(c, c)
+
+ self.assertNotEqual(a, b)
+ self.assertNotEqual(a, c)
+ self.assertNotEqual(b, a)
+ self.assertNotEqual(b, c)
+ self.assertNotEqual(c, a)
+ self.assertNotEqual(c, b)
+
+ self.assertTrue(a < b)
+ self.assertTrue(a < c)
+
+ self.assertTrue(b > a)
+ self.assertTrue(b > c)
+
+ self.assertTrue(c > a)
+ self.assertTrue(c < b)
+
+ self.assertTrue(a <= b)
+ self.assertTrue(a <= c)
+ self.assertTrue(a <= a)
+ self.assertTrue(a >= a)
+
+ self.assertTrue(b >= a)
+ self.assertTrue(b >= c)
+ self.assertTrue(b >= b)
+ self.assertTrue(b <= b)
+
+ self.assertTrue(c >= a)
+ self.assertTrue(c <= b)
+ self.assertTrue(c >= c)
+ self.assertTrue(c <= c)
+
+ def test_sshfp_value(self):
+ a = SshfpValue({'algorithm': 0, 'fingerprint_type': 0,
+ 'fingerprint': 'abcd'})
+ b = SshfpValue({'algorithm': 1, 'fingerprint_type': 0,
+ 'fingerprint': 'abcd'})
+ c = SshfpValue({'algorithm': 0, 'fingerprint_type': 1,
+ 'fingerprint': 'abcd'})
+ d = SshfpValue({'algorithm': 0, 'fingerprint_type': 0,
+ 'fingerprint': 'bcde'})
+
+ self.assertEqual(a, a)
+ self.assertEqual(b, b)
+ self.assertEqual(c, c)
+ self.assertEqual(d, d)
+
+ self.assertNotEqual(a, b)
+ self.assertNotEqual(a, c)
+ self.assertNotEqual(a, d)
+ self.assertNotEqual(b, a)
+ self.assertNotEqual(b, c)
+ self.assertNotEqual(b, d)
+ self.assertNotEqual(c, a)
+ self.assertNotEqual(c, b)
+ self.assertNotEqual(c, d)
+ self.assertNotEqual(d, a)
+ self.assertNotEqual(d, b)
+ self.assertNotEqual(d, c)
+
+ self.assertTrue(a < b)
+ self.assertTrue(a < c)
+
+ self.assertTrue(b > a)
+ self.assertTrue(b > c)
+
+ self.assertTrue(c > a)
+ self.assertTrue(c < b)
+
+ self.assertTrue(a <= b)
+ self.assertTrue(a <= c)
+ self.assertTrue(a <= a)
+ self.assertTrue(a >= a)
+
+ self.assertTrue(b >= a)
+ self.assertTrue(b >= c)
+ self.assertTrue(b >= b)
+ self.assertTrue(b <= b)
+
+ self.assertTrue(c >= a)
+ self.assertTrue(c <= b)
+ self.assertTrue(c >= c)
+ self.assertTrue(c <= c)
+
+ # Hash
+ values = set()
+ values.add(a)
+ self.assertTrue(a in values)
+ self.assertFalse(b in values)
+ values.add(b)
+ self.assertTrue(b in values)
+
+ def test_srv_value(self):
+ a = SrvValue({'priority': 0, 'weight': 0, 'port': 0, 'target': 'foo.'})
+ b = SrvValue({'priority': 1, 'weight': 0, 'port': 0, 'target': 'foo.'})
+ c = SrvValue({'priority': 0, 'weight': 2, 'port': 0, 'target': 'foo.'})
+ d = SrvValue({'priority': 0, 'weight': 0, 'port': 3, 'target': 'foo.'})
+ e = SrvValue({'priority': 0, 'weight': 0, 'port': 0, 'target': 'mmm.'})
+
+ self.assertEqual(a, a)
+ self.assertEqual(b, b)
+ self.assertEqual(c, c)
+ self.assertEqual(d, d)
+ self.assertEqual(e, e)
+
+ self.assertNotEqual(a, b)
+ self.assertNotEqual(a, c)
+ self.assertNotEqual(a, d)
+ self.assertNotEqual(a, e)
+ self.assertNotEqual(b, a)
+ self.assertNotEqual(b, c)
+ self.assertNotEqual(b, d)
+ self.assertNotEqual(b, e)
+ self.assertNotEqual(c, a)
+ self.assertNotEqual(c, b)
+ self.assertNotEqual(c, d)
+ self.assertNotEqual(c, e)
+ self.assertNotEqual(d, a)
+ self.assertNotEqual(d, b)
+ self.assertNotEqual(d, c)
+ self.assertNotEqual(d, e)
+ self.assertNotEqual(e, a)
+ self.assertNotEqual(e, b)
+ self.assertNotEqual(e, c)
+ self.assertNotEqual(e, d)
+
+ self.assertTrue(a < b)
+ self.assertTrue(a < c)
+
+ self.assertTrue(b > a)
+ self.assertTrue(b > c)
+
+ self.assertTrue(c > a)
+ self.assertTrue(c < b)
+
+ self.assertTrue(a <= b)
+ self.assertTrue(a <= c)
+ self.assertTrue(a <= a)
+ self.assertTrue(a >= a)
+
+ self.assertTrue(b >= a)
+ self.assertTrue(b >= c)
+ self.assertTrue(b >= b)
+ self.assertTrue(b <= b)
+
+ self.assertTrue(c >= a)
+ self.assertTrue(c <= b)
+ self.assertTrue(c >= c)
+ self.assertTrue(c <= c)
+
+ # Hash
+ values = set()
+ values.add(a)
+ self.assertTrue(a in values)
+ self.assertFalse(b in values)
+ values.add(b)
+ self.assertTrue(b in values)
+
class TestRecordValidation(TestCase):
zone = Zone('unit.tests.', [])
def test_base(self):
+ # fqdn length, DNS defins max as 253
+ with self.assertRaises(ValidationError) as ctx:
+ # The . will put this over the edge
+ name = 'x' * (253 - len(self.zone.name))
+ Record.new(self.zone, name, {
+ 'ttl': 300,
+ 'type': 'A',
+ 'value': '1.2.3.4',
+ })
+ reason = ctx.exception.reasons[0]
+ self.assertTrue(reason.startswith('invalid fqdn, "xxxx'))
+ self.assertTrue(reason.endswith('.unit.tests." is too long at 254'
+ ' chars, max is 253'))
+
+ # label length, DNS defins max as 63
+ with self.assertRaises(ValidationError) as ctx:
+ # The . will put this over the edge
+ name = 'x' * 64
+ Record.new(self.zone, name, {
+ 'ttl': 300,
+ 'type': 'A',
+ 'value': '1.2.3.4',
+ })
+ reason = ctx.exception.reasons[0]
+ self.assertTrue(reason.startswith('invalid name, "xxxx'))
+ self.assertTrue(reason.endswith('xxx" is too long at 64'
+ ' chars, max is 63'))
+
# no ttl
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {
@@ -2460,7 +2849,7 @@ class TestDynamicRecords(TestCase):
'weight': 1,
'value': '6.6.6.6',
}, {
- 'weight': 256,
+ 'weight': 16,
'value': '7.7.7.7',
}],
},
@@ -2484,7 +2873,7 @@ class TestDynamicRecords(TestCase):
}
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'bad', a_data)
- self.assertEquals(['invalid weight "256" in pool "three" value 2'],
+ self.assertEquals(['invalid weight "16" in pool "three" value 2'],
ctx.exception.reasons)
# invalid non-int weight
@@ -2845,7 +3234,7 @@ class TestDynamicRecords(TestCase):
self.assertEquals(['rule 1 invalid pool "[]"'],
ctx.exception.reasons)
- # rule references non-existant pool
+ # rule references non-existent pool
a_data = {
'dynamic': {
'pools': {
@@ -2864,7 +3253,7 @@ class TestDynamicRecords(TestCase):
},
'rules': [{
'geos': ['NA-US-CA'],
- 'pool': 'non-existant',
+ 'pool': 'non-existent',
}, {
'pool': 'one',
}],
@@ -2878,7 +3267,7 @@ class TestDynamicRecords(TestCase):
}
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'bad', a_data)
- self.assertEquals(["rule 1 undefined pool \"non-existant\""],
+ self.assertEquals(["rule 1 undefined pool \"non-existent\""],
ctx.exception.reasons)
# rule with invalid geos
diff --git a/tests/test_octodns_source_axfr.py b/tests/test_octodns_source_axfr.py
index 9251113..62e1a65 100644
--- a/tests/test_octodns_source_axfr.py
+++ b/tests/test_octodns_source_axfr.py
@@ -9,6 +9,7 @@ import dns.zone
from dns.exception import DNSException
from mock import patch
+from six import text_type
from unittest import TestCase
from octodns.source.axfr import AxfrSource, AxfrSourceZoneTransferFailed, \
@@ -38,7 +39,7 @@ class TestAxfrSource(TestCase):
zone = Zone('unit.tests.', [])
self.source.populate(zone)
self.assertEquals('Unable to Perform Zone Transfer',
- ctx.exception.message)
+ text_type(ctx.exception))
class TestZoneFileSource(TestCase):
@@ -68,4 +69,4 @@ class TestZoneFileSource(TestCase):
zone = Zone('invalid.zone.', [])
self.source.populate(zone)
self.assertEquals('The DNS zone has no NS RRset at its origin.',
- ctx.exception.message)
+ text_type(ctx.exception))
diff --git a/tests/test_octodns_yaml.py b/tests/test_octodns_yaml.py
index effe231..f211854 100644
--- a/tests/test_octodns_yaml.py
+++ b/tests/test_octodns_yaml.py
@@ -5,7 +5,7 @@
from __future__ import absolute_import, division, print_function, \
unicode_literals
-from StringIO import StringIO
+from six import StringIO
from unittest import TestCase
from yaml.constructor import ConstructorError
diff --git a/tests/test_octodns_zone.py b/tests/test_octodns_zone.py
index 2fff996..1d000f2 100644
--- a/tests/test_octodns_zone.py
+++ b/tests/test_octodns_zone.py
@@ -6,6 +6,7 @@ from __future__ import absolute_import, division, print_function, \
unicode_literals
from unittest import TestCase
+from six import text_type
from octodns.record import ARecord, AaaaRecord, Create, Delete, Record, Update
from octodns.zone import DuplicateRecordException, InvalidNodeException, \
@@ -47,7 +48,7 @@ class TestZone(TestCase):
with self.assertRaises(DuplicateRecordException) as ctx:
zone.add_record(a)
self.assertEquals('Duplicate record a.unit.tests., type A',
- ctx.exception.message)
+ text_type(ctx.exception))
self.assertEquals(zone.records, set([a]))
# can add duplicate with replace=True
@@ -137,7 +138,7 @@ class TestZone(TestCase):
def test_missing_dot(self):
with self.assertRaises(Exception) as ctx:
Zone('not.allowed', [])
- self.assertTrue('missing ending dot' in ctx.exception.message)
+ self.assertTrue('missing ending dot' in text_type(ctx.exception))
def test_sub_zones(self):
@@ -160,7 +161,7 @@ class TestZone(TestCase):
})
with self.assertRaises(SubzoneRecordException) as ctx:
zone.add_record(record)
- self.assertTrue('not of type NS', ctx.exception.message)
+ self.assertTrue('not of type NS', text_type(ctx.exception))
# Can add it w/lenient
zone.add_record(record, lenient=True)
self.assertEquals(set([record]), zone.records)
@@ -174,7 +175,7 @@ class TestZone(TestCase):
})
with self.assertRaises(SubzoneRecordException) as ctx:
zone.add_record(record)
- self.assertTrue('under a managed sub-zone', ctx.exception.message)
+ self.assertTrue('under a managed sub-zone', text_type(ctx.exception))
# Can add it w/lenient
zone.add_record(record, lenient=True)
self.assertEquals(set([record]), zone.records)
@@ -188,7 +189,7 @@ class TestZone(TestCase):
})
with self.assertRaises(SubzoneRecordException) as ctx:
zone.add_record(record)
- self.assertTrue('under a managed sub-zone', ctx.exception.message)
+ self.assertTrue('under a managed sub-zone', text_type(ctx.exception))
# Can add it w/lenient
zone.add_record(record, lenient=True)
self.assertEquals(set([record]), zone.records)