Merge pull request #303 from github/dynamic-records

Base dynamic record support
This commit is contained in:
Ross McFarland
2018-12-14 11:26:16 -08:00
committed by GitHub
32 changed files with 2662 additions and 302 deletions
+19
View File
@@ -1,3 +1,22 @@
## v0.9.4 - ....-..-.. - Dynamic Records Beta
* Dynamic Records (more powerful/flexible replacement for geo)
* Will support A, AAAA, and CNAME out the gate and include the ability to
weight records. It should provide a foundation for further suppport
if/when needed.
* Major refactoring and improvements to validation of the Record hierarchy,
things are much more consisntely implemented now and error messages should
be more actionable/clear. Both the base values and dynamic values use the
same validatio logic.
* natsort version bump to address setup issues
* DNSSimple TXT record handling fixes, ; it's always ;
* Route53Provider support for sessiom tokens
* Add ALIAS to the list of Cloudflare record types that support proxying
* Fix for TTL bug in Dyn CCA records
* Records updated so that 'octodns' record metadata is persisted through
YamlProvider
* Added --version support to ArguementParser (thus all commands)
## v0.9.3 - 2018-10-29 - Misc. stuff sort of release
* ZoneFile source added
+7
View File
@@ -37,6 +37,13 @@ class _AggregateTarget(object):
return False
return True
@property
def SUPPORTS_DYNAMIC(self):
for target in self.targets:
if not target.SUPPORTS_DYNAMIC:
return False
return True
class MakeThreadFuture(object):
+1
View File
@@ -249,6 +249,7 @@ class AzureProvider(BaseProvider):
possible to also hard-code into the config file: eg, resource_group.
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'SRV', 'TXT'))
def __init__(self, id, client_id, key, directory_id, sub_id,
+1
View File
@@ -59,6 +59,7 @@ class CloudflareProvider(BaseProvider):
value: 1.2.3.4
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('ALIAS', 'A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SRV',
'SPF', 'TXT'))
+1
View File
@@ -116,6 +116,7 @@ class DigitalOceanProvider(BaseProvider):
token: foo
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'TXT', 'SRV'))
def __init__(self, id, token, *args, **kwargs):
+1
View File
@@ -91,6 +91,7 @@ class DnsimpleProvider(BaseProvider):
account: 42
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS',
'PTR', 'SPF', 'SRV', 'SSHFP', 'TXT'))
+1
View File
@@ -158,6 +158,7 @@ class DnsMadeEasyProvider(BaseProvider):
sandbox: true
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX',
'NS', 'PTR', 'SPF', 'SRV', 'TXT'))
+5
View File
@@ -259,6 +259,11 @@ class DynProvider(BaseProvider):
def SUPPORTS_GEO(self):
return self.traffic_directors_enabled
@property
def SUPPORTS_DYNAMIC(self):
# TODO: dynamic
return False
def _check_dyn_sess(self):
# We don't have to worry about locking for the check since the
# underlying pieces are pre-thread. We can check to see if this thread
+1
View File
@@ -25,6 +25,7 @@ class EtcHostsProvider(BaseProvider):
directory: ./hosts
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME'))
def __init__(self, id, directory, *args, **kwargs):
+1
View File
@@ -40,6 +40,7 @@ class GoogleCloudProvider(BaseProvider):
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NAPTR',
'NS', 'PTR', 'SPF', 'SRV', 'TXT'))
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
CHANGE_LOOP_WAIT = 5
+1
View File
@@ -26,6 +26,7 @@ class Ns1Provider(BaseProvider):
api_key: env/NS1_API_KEY
'''
SUPPORTS_GEO = True
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR',
'NS', 'PTR', 'SPF', 'SRV', 'TXT'))
+1
View File
@@ -34,6 +34,7 @@ class OvhProvider(BaseProvider):
"""
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
ZONE_NOT_FOUND_MESSAGE = 'This service does not exist'
# This variable is also used in populate method to filter which OVH record
+1
View File
@@ -14,6 +14,7 @@ from .base import BaseProvider
class PowerDnsBaseProvider(BaseProvider):
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS',
'PTR', 'SPF', 'SSHFP', 'SRV', 'TXT'))
TIMEOUT = 5
+1
View File
@@ -38,6 +38,7 @@ def unescape_semicolon(s):
class RackspaceProvider(BaseProvider):
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NS', 'PTR', 'SPF',
'TXT'))
TIMEOUT = 5
+2
View File
@@ -232,6 +232,8 @@ class Route53Provider(BaseProvider):
In general the account used will need full permissions on Route53.
'''
SUPPORTS_GEO = True
# TODO: dynamic
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR',
'SPF', 'SRV', 'TXT'))
+1
View File
@@ -31,6 +31,7 @@ class YamlProvider(BaseProvider):
enforce_order: True
'''
SUPPORTS_GEO = True
SUPPORTS_DYNAMIC = True
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS',
'PTR', 'SSHFP', 'SPF', 'SRV', 'TXT'))
+526 -264
View File
@@ -9,6 +9,8 @@ from ipaddress import IPv4Address, IPv6Address
from logging import getLogger
import re
from .geo import GeoCodes
class Change(object):
@@ -253,38 +255,10 @@ class _ValuesMixin(object):
@classmethod
def validate(cls, name, data):
reasons = super(_ValuesMixin, cls).validate(name, data)
values = []
try:
values = data['values']
if not values:
values = []
reasons.append('missing value(s)')
else:
# loop through copy of values
# remove invalid value from values
for value in list(values):
if value is None:
reasons.append('missing value(s)')
values.remove(value)
elif len(value) == 0:
reasons.append('empty value')
values.remove(value)
except KeyError:
try:
value = data['value']
if value is None:
reasons.append('missing value(s)')
values = []
elif len(value) == 0:
reasons.append('empty value')
values = []
else:
values = [value]
except KeyError:
reasons.append('missing value(s)')
for value in values:
reasons.extend(cls._validate_value(value))
values = data.get('values', data.get('value', []))
reasons.extend(cls._value_type.validate(values, cls._type))
return reasons
@@ -294,7 +268,7 @@ class _ValuesMixin(object):
values = data['values']
except KeyError:
values = [data['value']]
self.values = sorted(self._process_values(values))
self.values = sorted(self._value_type.process(values))
def changes(self, other, target):
if self.values != other.values:
@@ -336,19 +310,13 @@ class _GeoMixin(_ValuesMixin):
reasons = super(_GeoMixin, cls).validate(name, data)
try:
geo = dict(data['geo'])
# TODO: validate legal codes
for code, values in geo.items():
reasons.extend(GeoValue._validate_geo(code))
for value in values:
reasons.extend(cls._validate_value(value))
reasons.extend(cls._value_type.validate(values, cls._type))
except KeyError:
pass
return reasons
# TODO: support 'value' as well
# TODO: move away from "data" hash to strict params, it's kind of leaking
# the yaml implementation into here and then forcing it back out into
# non-yaml providers during input
def __init__(self, zone, name, data, *args, **kwargs):
super(_GeoMixin, self).__init__(zone, name, data, *args, **kwargs)
try:
@@ -382,59 +350,18 @@ class _GeoMixin(_ValuesMixin):
return super(_GeoMixin, self).__repr__()
class ARecord(_GeoMixin, Record):
_type = 'A'
@classmethod
def _validate_value(self, value):
reasons = []
try:
IPv4Address(unicode(value))
except Exception:
reasons.append('invalid ip address "{}"'.format(value))
return reasons
def _process_values(self, values):
return values
class AaaaRecord(_GeoMixin, Record):
_type = 'AAAA'
@classmethod
def _validate_value(self, value):
reasons = []
try:
IPv6Address(unicode(value))
except Exception:
reasons.append('invalid ip address "{}"'.format(value))
return reasons
def _process_values(self, values):
return values
class _ValueMixin(object):
@classmethod
def validate(cls, name, data):
reasons = super(_ValueMixin, cls).validate(name, data)
value = None
try:
value = data['value']
if value is None:
reasons.append('missing value')
elif value == '':
reasons.append('empty value')
except KeyError:
reasons.append('missing value')
if value:
reasons.extend(cls._validate_value(value))
reasons.extend(cls._value_type.validate(data.get('value', None),
cls._type))
return reasons
def __init__(self, zone, name, data, source=None):
super(_ValueMixin, self).__init__(zone, name, data, source=source)
self.value = self._process_value(data['value'])
self.value = self._value_type.process(data['value'])
def changes(self, other, target):
if self.value != other.value:
@@ -453,40 +380,359 @@ class _ValueMixin(object):
self.fqdn, self.value)
class AliasRecord(_ValueMixin, Record):
_type = 'ALIAS'
class _DynamicPool(object):
def __init__(self, _id, data):
self._id = _id
self.data = data
def _data(self):
return self.data
def __eq__(self, other):
return self.data == other.data
def __ne__(self, other):
return not self.__eq__(other)
def __repr__(self):
return '{}'.format(self.data)
class _DynamicRule(object):
def __init__(self, i, data):
self.i = i
self.data = data
def _data(self):
return self.data
def __eq__(self, other):
return self.data == other.data
def __ne__(self, other):
return not self.__eq__(other)
def __repr__(self):
return '{}'.format(self.data)
class _Dynamic(object):
def __init__(self, pools, rules):
self.pools = pools
self.rules = rules
def _data(self):
pools = {}
for _id, pool in self.pools.items():
pools[_id] = pool._data()
rules = []
for rule in self.rules:
rules.append(rule._data())
return {
'pools': pools,
'rules': rules,
}
def __eq__(self, other):
ret = self.pools == other.pools and self.rules == other.rules
return ret
def __ne__(self, other):
return not self.__eq__(other)
def __repr__(self):
return '{}, {}'.format(self.pools, self.rules)
class _DynamicMixin(object):
geo_re = re.compile(r'^(?P<continent_code>\w\w)(-(?P<country_code>\w\w)'
r'(-(?P<subdivision_code>\w\w))?)?$')
@classmethod
def _validate_value(self, value):
reasons = []
if not value.endswith('.'):
reasons.append('missing trailing .')
def validate(cls, name, data):
reasons = super(_DynamicMixin, cls).validate(name, data)
if 'dynamic' not in data:
return reasons
try:
pools = data['dynamic']['pools']
except KeyError:
pools = {}
if not isinstance(pools, dict):
reasons.append('pools must be a dict')
elif not pools:
reasons.append('missing pools')
else:
for _id, pool in sorted(pools.items()):
if not isinstance(pool, dict):
reasons.append('pool "{}" must be a dict'.format(_id))
continue
try:
values = pool['values']
except KeyError:
reasons.append('pool "{}" is missing values'.format(_id))
continue
for i, value in enumerate(values):
value_num = i + 1
try:
weight = value['weight']
weight = int(weight)
if weight < 1 or weight > 255:
reasons.append('invalid weight "{}" in pool "{}" '
'value {}'.format(weight, _id,
value_num))
except KeyError:
pass
except ValueError:
reasons.append('invalid weight "{}" in pool "{}" '
'value {}'.format(weight, _id,
value_num))
try:
value = value['value']
reasons.extend(cls._value_type.validate(value,
cls._type))
except KeyError:
reasons.append('missing value in pool "{}" '
'value {}'.format(_id, value_num))
fallback = pool.get('fallback', None)
if fallback is not None and fallback not in pools:
reasons.append('undefined fallback "{}" for pool "{}"'
.format(fallback, _id))
# Check for loops
fallback = pools[_id].get('fallback', None)
seen = [_id, fallback]
while fallback is not None:
# See if there's a next fallback
fallback = pools.get(fallback, {}).get('fallback', None)
if fallback in seen:
loop = ' -> '.join(seen)
reasons.append('loop in pool fallbacks: {}'
.format(loop))
# exit the loop
break
seen.append(fallback)
try:
rules = data['dynamic']['rules']
except KeyError:
rules = []
if not isinstance(rules, (list, tuple)):
reasons.append('rules must be a list')
elif not rules:
reasons.append('missing rules')
else:
seen_default = False
for i, rule in enumerate(rules):
rule_num = i + 1
try:
pool = rule['pool']
except KeyError:
reasons.append('rule {} missing pool'.format(rule_num))
continue
if not isinstance(pool, basestring):
reasons.append('rule {} invalid pool "{}"'
.format(rule_num, pool))
elif pool not in pools:
reasons.append('rule {} undefined pool "{}"'
.format(rule_num, pool))
try:
geos = rule['geos']
except KeyError:
geos = []
if seen_default:
reasons.append('rule {} duplicate default'
.format(rule_num))
seen_default = True
if not isinstance(geos, (list, tuple)):
reasons.append('rule {} geos must be a list'
.format(rule_num))
else:
for geo in geos:
reasons.extend(GeoCodes.validate(geo, 'rule {} '
.format(rule_num)))
return reasons
def _process_value(self, value):
def __init__(self, zone, name, data, *args, **kwargs):
super(_DynamicMixin, self).__init__(zone, name, data, *args,
**kwargs)
self.dynamic = {}
if 'dynamic' not in data:
return
# pools
try:
pools = dict(data['dynamic']['pools'])
except:
pools = {}
for _id, pool in sorted(pools.items()):
pools[_id] = _DynamicPool(_id, pool)
# rules
try:
rules = list(data['dynamic']['rules'])
except:
rules = []
parsed = []
for i, rule in enumerate(rules):
parsed.append(_DynamicRule(i, rule))
# dynamic
self.dynamic = _Dynamic(pools, parsed)
def _data(self):
ret = super(_DynamicMixin, self)._data()
if self.dynamic:
ret['dynamic'] = self.dynamic._data()
return ret
def changes(self, other, target):
if target.SUPPORTS_DYNAMIC:
if self.dynamic != other.dynamic:
return Update(self, other)
return super(_DynamicMixin, self).changes(other, target)
def __repr__(self):
# TODO: improve this whole thing, we need multi-line...
if self.dynamic:
# TODO: this hack can't going to cut it, as part of said
# improvements the value types should deal with serializing their
# value
try:
values = self.values
except AttributeError:
values = self.value
return '<{} {} {}, {}, {}, {}>'.format(self.__class__.__name__,
self._type, self.ttl,
self.fqdn, values,
self.dynamic)
return super(_DynamicMixin, self).__repr__()
class _IpList(object):
@classmethod
def validate(cls, data, _type):
if not isinstance(data, (list, tuple)):
data = (data,)
if len(data) == 0:
return ['missing value(s)']
reasons = []
for value in data:
if value is '':
reasons.append('empty value')
elif value is None:
reasons.append('missing value(s)')
else:
try:
cls._address_type(unicode(value))
except Exception:
reasons.append('invalid {} address "{}"'
.format(cls._address_name, value))
return reasons
@classmethod
def process(cls, values):
return values
class Ipv4List(_IpList):
_address_name = 'IPv4'
_address_type = IPv4Address
class Ipv6List(_IpList):
_address_name = 'IPv6'
_address_type = IPv6Address
class _TargetValue(object):
@classmethod
def validate(cls, data, _type):
reasons = []
if data == '':
reasons.append('empty value')
elif not data:
reasons.append('missing value')
elif not data.endswith('.'):
reasons.append('{} value "{}" missing trailing .'
.format(_type, data))
return reasons
@classmethod
def process(self, value):
return value
class CnameValue(_TargetValue):
pass
class ARecord(_DynamicMixin, _GeoMixin, Record):
_type = 'A'
_value_type = Ipv4List
class AaaaRecord(_DynamicMixin, _GeoMixin, Record):
_type = 'AAAA'
_value_type = Ipv6List
class AliasValue(_TargetValue):
pass
class AliasRecord(_ValueMixin, Record):
_type = 'ALIAS'
_value_type = AliasValue
class CaaValue(object):
# https://tools.ietf.org/html/rfc6844#page-5
@classmethod
def _validate_value(cls, value):
def validate(cls, data, _type):
if not isinstance(data, (list, tuple)):
data = (data,)
reasons = []
try:
flags = int(value.get('flags', 0))
if flags < 0 or flags > 255:
reasons.append('invalid flags "{}"'.format(flags))
except ValueError:
reasons.append('invalid flags "{}"'.format(value['flags']))
if 'tag' not in value:
reasons.append('missing tag')
if 'value' not in value:
reasons.append('missing value')
for value in data:
try:
flags = int(value.get('flags', 0))
if flags < 0 or flags > 255:
reasons.append('invalid flags "{}"'.format(flags))
except ValueError:
reasons.append('invalid flags "{}"'.format(value['flags']))
if 'tag' not in value:
reasons.append('missing tag')
if 'value' not in value:
reasons.append('missing value')
return reasons
@classmethod
def process(cls, values):
return [CaaValue(v) for v in values]
def __init__(self, value):
self.flags = int(value.get('flags', 0))
self.tag = value['tag']
@@ -513,17 +759,12 @@ class CaaValue(object):
class CaaRecord(_ValuesMixin, Record):
_type = 'CAA'
@classmethod
def _validate_value(cls, value):
return CaaValue._validate_value(value)
def _process_values(self, values):
return [CaaValue(v) for v in values]
_value_type = CaaValue
class CnameRecord(_ValueMixin, Record):
class CnameRecord(_DynamicMixin, _ValueMixin, Record):
_type = 'CNAME'
_value_type = CnameValue
@classmethod
def validate(cls, name, data):
@@ -533,41 +774,39 @@ class CnameRecord(_ValueMixin, Record):
reasons.extend(super(CnameRecord, cls).validate(name, data))
return reasons
@classmethod
def _validate_value(cls, value):
reasons = []
if not value.endswith('.'):
reasons.append('missing trailing .')
return reasons
def _process_value(self, value):
return value
class MxValue(object):
@classmethod
def _validate_value(cls, value):
def validate(cls, data, _type):
if not isinstance(data, (list, tuple)):
data = (data,)
reasons = []
try:
for value in data:
try:
int(value['preference'])
try:
int(value['preference'])
except KeyError:
int(value['priority'])
except KeyError:
int(value['priority'])
except KeyError:
reasons.append('missing preference')
except ValueError:
reasons.append('invalid preference "{}"'
.format(value['preference']))
exchange = None
try:
exchange = value.get('exchange', None) or value['value']
if not exchange.endswith('.'):
reasons.append('missing trailing .')
except KeyError:
reasons.append('missing exchange')
reasons.append('missing preference')
except ValueError:
reasons.append('invalid preference "{}"'
.format(value['preference']))
exchange = None
try:
exchange = value.get('exchange', None) or value['value']
if not exchange.endswith('.'):
reasons.append('MX value "{}" missing trailing .'
.format(exchange))
except KeyError:
reasons.append('missing exchange')
return reasons
@classmethod
def process(cls, values):
return [MxValue(v) for v in values]
def __init__(self, value):
# RFC1035 says preference, half the providers use priority
try:
@@ -600,47 +839,49 @@ class MxValue(object):
class MxRecord(_ValuesMixin, Record):
_type = 'MX'
@classmethod
def _validate_value(cls, value):
return MxValue._validate_value(value)
def _process_values(self, values):
return [MxValue(v) for v in values]
_value_type = MxValue
class NaptrValue(object):
VALID_FLAGS = ('S', 'A', 'U', 'P')
@classmethod
def _validate_value(cls, data):
def validate(cls, data, _type):
if not isinstance(data, (list, tuple)):
data = (data,)
reasons = []
try:
int(data['order'])
except KeyError:
reasons.append('missing order')
except ValueError:
reasons.append('invalid order "{}"'.format(data['order']))
try:
int(data['preference'])
except KeyError:
reasons.append('missing preference')
except ValueError:
reasons.append('invalid preference "{}"'
.format(data['preference']))
try:
flags = data['flags']
if flags not in cls.VALID_FLAGS:
reasons.append('unrecognized flags "{}"'.format(flags))
except KeyError:
reasons.append('missing flags')
for value in data:
try:
int(value['order'])
except KeyError:
reasons.append('missing order')
except ValueError:
reasons.append('invalid order "{}"'.format(value['order']))
try:
int(value['preference'])
except KeyError:
reasons.append('missing preference')
except ValueError:
reasons.append('invalid preference "{}"'
.format(value['preference']))
try:
flags = value['flags']
if flags not in cls.VALID_FLAGS:
reasons.append('unrecognized flags "{}"'.format(flags))
except KeyError:
reasons.append('missing flags')
# TODO: validate these... they're non-trivial
for k in ('service', 'regexp', 'replacement'):
if k not in value:
reasons.append('missing {}'.format(k))
# TODO: validate these... they're non-trivial
for k in ('service', 'regexp', 'replacement'):
if k not in data:
reasons.append('missing {}'.format(k))
return reasons
@classmethod
def process(cls, values):
return [NaptrValue(v) for v in values]
def __init__(self, value):
self.order = int(value['order'])
self.preference = int(value['preference'])
@@ -684,41 +925,41 @@ class NaptrValue(object):
class NaptrRecord(_ValuesMixin, Record):
_type = 'NAPTR'
_value_type = NaptrValue
class _NsValue(object):
@classmethod
def _validate_value(cls, value):
return NaptrValue._validate_value(value)
def validate(cls, data, _type):
if not data:
return ['missing value(s)']
elif not isinstance(data, (list, tuple)):
data = (data,)
reasons = []
for value in data:
if not value.endswith('.'):
reasons.append('NS value "{}" missing trailing .'
.format(value))
return reasons
def _process_values(self, values):
return [NaptrValue(v) for v in values]
@classmethod
def process(cls, values):
return values
class NsRecord(_ValuesMixin, Record):
_type = 'NS'
_value_type = _NsValue
@classmethod
def _validate_value(cls, value):
reasons = []
if not value.endswith('.'):
reasons.append('missing trailing .')
return reasons
def _process_values(self, values):
return values
class PtrValue(_TargetValue):
pass
class PtrRecord(_ValueMixin, Record):
_type = 'PTR'
@classmethod
def _validate_value(cls, value):
reasons = []
if not value.endswith('.'):
reasons.append('missing trailing .')
return reasons
def _process_value(self, value):
return value
_value_type = PtrValue
class SshfpValue(object):
@@ -726,30 +967,39 @@ class SshfpValue(object):
VALID_FINGERPRINT_TYPES = (1, 2)
@classmethod
def _validate_value(cls, value):
def validate(cls, data, _type):
if not isinstance(data, (list, tuple)):
data = (data,)
reasons = []
try:
algorithm = int(value['algorithm'])
if algorithm not in cls.VALID_ALGORITHMS:
reasons.append('unrecognized algorithm "{}"'.format(algorithm))
except KeyError:
reasons.append('missing algorithm')
except ValueError:
reasons.append('invalid algorithm "{}"'.format(value['algorithm']))
try:
fingerprint_type = int(value['fingerprint_type'])
if fingerprint_type not in cls.VALID_FINGERPRINT_TYPES:
reasons.append('unrecognized fingerprint_type "{}"'
.format(fingerprint_type))
except KeyError:
reasons.append('missing fingerprint_type')
except ValueError:
reasons.append('invalid fingerprint_type "{}"'
.format(value['fingerprint_type']))
if 'fingerprint' not in value:
reasons.append('missing fingerprint')
for value in data:
try:
algorithm = int(value['algorithm'])
if algorithm not in cls.VALID_ALGORITHMS:
reasons.append('unrecognized algorithm "{}"'
.format(algorithm))
except KeyError:
reasons.append('missing algorithm')
except ValueError:
reasons.append('invalid algorithm "{}"'
.format(value['algorithm']))
try:
fingerprint_type = int(value['fingerprint_type'])
if fingerprint_type not in cls.VALID_FINGERPRINT_TYPES:
reasons.append('unrecognized fingerprint_type "{}"'
.format(fingerprint_type))
except KeyError:
reasons.append('missing fingerprint_type')
except ValueError:
reasons.append('invalid fingerprint_type "{}"'
.format(value['fingerprint_type']))
if 'fingerprint' not in value:
reasons.append('missing fingerprint')
return reasons
@classmethod
def process(cls, values):
return [SshfpValue(v) for v in values]
def __init__(self, value):
self.algorithm = int(value['algorithm'])
self.fingerprint_type = int(value['fingerprint_type'])
@@ -777,34 +1027,12 @@ class SshfpValue(object):
class SshfpRecord(_ValuesMixin, Record):
_type = 'SSHFP'
@classmethod
def _validate_value(cls, value):
return SshfpValue._validate_value(value)
def _process_values(self, values):
return [SshfpValue(v) for v in values]
_unescaped_semicolon_re = re.compile(r'\w;')
_value_type = SshfpValue
class _ChunkedValuesMixin(_ValuesMixin):
CHUNK_SIZE = 255
@classmethod
def _validate_value(cls, value):
if _unescaped_semicolon_re.search(value):
return ['unescaped ;']
return []
def _process_values(self, values):
ret = []
for v in values:
if v and v[0] == '"':
v = v[1:-1]
ret.append(v.replace('" "', ''))
return ret
_unescaped_semicolon_re = re.compile(r'\w;')
@property
def chunked_values(self):
@@ -818,41 +1046,76 @@ class _ChunkedValuesMixin(_ValuesMixin):
return values
class _ChunkedValue(object):
_unescaped_semicolon_re = re.compile(r'\w;')
@classmethod
def validate(cls, data, _type):
if not data:
return ['missing value(s)']
elif not isinstance(data, (list, tuple)):
data = (data,)
reasons = []
for value in data:
if cls._unescaped_semicolon_re.search(value):
reasons.append('unescaped ; in "{}"'.format(value))
return reasons
@classmethod
def process(cls, values):
ret = []
for v in values:
if v and v[0] == '"':
v = v[1:-1]
ret.append(v.replace('" "', ''))
return ret
class SpfRecord(_ChunkedValuesMixin, Record):
_type = 'SPF'
_value_type = _ChunkedValue
class SrvValue(object):
@classmethod
def _validate_value(self, value):
def validate(cls, data, _type):
if not isinstance(data, (list, tuple)):
data = (data,)
reasons = []
# TODO: validate algorithm and fingerprint_type values
try:
int(value['priority'])
except KeyError:
reasons.append('missing priority')
except ValueError:
reasons.append('invalid priority "{}"'.format(value['priority']))
try:
int(value['weight'])
except KeyError:
reasons.append('missing weight')
except ValueError:
reasons.append('invalid weight "{}"'.format(value['weight']))
try:
int(value['port'])
except KeyError:
reasons.append('missing port')
except ValueError:
reasons.append('invalid port "{}"'.format(value['port']))
try:
if not value['target'].endswith('.'):
reasons.append('missing trailing .')
except KeyError:
reasons.append('missing target')
for value in data:
# TODO: validate algorithm and fingerprint_type values
try:
int(value['priority'])
except KeyError:
reasons.append('missing priority')
except ValueError:
reasons.append('invalid priority "{}"'
.format(value['priority']))
try:
int(value['weight'])
except KeyError:
reasons.append('missing weight')
except ValueError:
reasons.append('invalid weight "{}"'.format(value['weight']))
try:
int(value['port'])
except KeyError:
reasons.append('missing port')
except ValueError:
reasons.append('invalid port "{}"'.format(value['port']))
try:
if not value['target'].endswith('.'):
reasons.append('SRV value "{}" missing trailing .'
.format(value['target']))
except KeyError:
reasons.append('missing target')
return reasons
@classmethod
def process(cls, values):
return [SrvValue(v) for v in values]
def __init__(self, value):
self.priority = int(value['priority'])
self.weight = int(value['weight'])
@@ -884,6 +1147,7 @@ class SrvValue(object):
class SrvRecord(_ValuesMixin, Record):
_type = 'SRV'
_value_type = SrvValue
_name_re = re.compile(r'^_[^\.]+\.[^\.]+')
@classmethod
@@ -894,13 +1158,11 @@ class SrvRecord(_ValuesMixin, Record):
reasons.extend(super(SrvRecord, cls).validate(name, data))
return reasons
@classmethod
def _validate_value(cls, value):
return SrvValue._validate_value(value)
def _process_values(self, values):
return [SrvValue(v) for v in values]
class _TxtValue(_ChunkedValue):
pass
class TxtRecord(_ChunkedValuesMixin, Record):
_type = 'TXT'
_value_type = _TxtValue
+35
View File
@@ -0,0 +1,35 @@
#
#
#
from .geo_data import geo_data
class GeoCodes(object):
__COUNTRIES = None
@classmethod
def validate(cls, code, prefix):
'''
Validates an octoDNS geo code making sure that it is a valid and
corresponding:
* continent
* continent & country
* continent, country, & province
'''
reasons = []
pieces = code.split('-')
n = len(pieces)
if n > 3:
reasons.append('{}invalid geo code "{}"'.format(prefix, code))
elif n > 0 and pieces[0] not in geo_data:
reasons.append('{}unknown continent code "{}"'
.format(prefix, code))
elif n > 1 and pieces[1] not in geo_data[pieces[0]]:
reasons.append('{}unknown country code "{}"'.format(prefix, code))
elif n > 2 and \
pieces[2] not in geo_data[pieces[0]][pieces[1]]['provinces']:
reasons.append('{}unknown province code "{}"'.format(prefix, code))
return reasons
+316
View File
@@ -0,0 +1,316 @@
#
# -*- coding: utf-8 -*-
#
# This file is generated using
# ./script/generate-geo-data > octodns/record/geo_data.py
# do not modify it directly
#
geo_data = \
{'AF': {'AO': {'name': 'Angola'},
'BF': {'name': 'Burkina Faso'},
'BI': {'name': 'Burundi'},
'BJ': {'name': 'Benin'},
'BW': {'name': 'Botswana'},
'CD': {'name': 'Congo, The Democratic Republic of the'},
'CF': {'name': 'Central African Republic'},
'CG': {'name': 'Congo'},
'CI': {'name': "Côte d'Ivoire"},
'CM': {'name': 'Cameroon'},
'CV': {'name': 'Cabo Verde'},
'DJ': {'name': 'Djibouti'},
'DZ': {'name': 'Algeria'},
'EG': {'name': 'Egypt'},
'EH': {'name': 'Western Sahara'},
'ER': {'name': 'Eritrea'},
'ET': {'name': 'Ethiopia'},
'GA': {'name': 'Gabon'},
'GH': {'name': 'Ghana'},
'GM': {'name': 'Gambia'},
'GN': {'name': 'Guinea'},
'GQ': {'name': 'Equatorial Guinea'},
'GW': {'name': 'Guinea-Bissau'},
'KE': {'name': 'Kenya'},
'KM': {'name': 'Comoros'},
'LR': {'name': 'Liberia'},
'LS': {'name': 'Lesotho'},
'LY': {'name': 'Libya'},
'MA': {'name': 'Morocco'},
'MG': {'name': 'Madagascar'},
'ML': {'name': 'Mali'},
'MR': {'name': 'Mauritania'},
'MU': {'name': 'Mauritius'},
'MW': {'name': 'Malawi'},
'MZ': {'name': 'Mozambique'},
'NA': {'name': 'Namibia'},
'NE': {'name': 'Niger'},
'NG': {'name': 'Nigeria'},
'RE': {'name': 'Réunion'},
'RW': {'name': 'Rwanda'},
'SC': {'name': 'Seychelles'},
'SD': {'name': 'Sudan'},
'SH': {'name': 'Saint Helena, Ascension and Tristan da Cunha'},
'SL': {'name': 'Sierra Leone'},
'SN': {'name': 'Senegal'},
'SO': {'name': 'Somalia'},
'SS': {'name': 'South Sudan'},
'ST': {'name': 'Sao Tome and Principe'},
'SZ': {'name': 'Swaziland'},
'TD': {'name': 'Chad'},
'TG': {'name': 'Togo'},
'TN': {'name': 'Tunisia'},
'TZ': {'name': 'Tanzania, United Republic of'},
'UG': {'name': 'Uganda'},
'YT': {'name': 'Mayotte'},
'ZA': {'name': 'South Africa'},
'ZM': {'name': 'Zambia'},
'ZW': {'name': 'Zimbabwe'}},
'AN': {'AQ': {'name': 'Antarctica'},
'BV': {'name': 'Bouvet Island'},
'HM': {'name': 'Heard Island and McDonald Islands'},
'TF': {'name': 'French Southern Territories'}},
'AS': {'AE': {'name': 'United Arab Emirates'},
'AF': {'name': 'Afghanistan'},
'AM': {'name': 'Armenia'},
'AZ': {'name': 'Azerbaijan'},
'BD': {'name': 'Bangladesh'},
'BH': {'name': 'Bahrain'},
'BN': {'name': 'Brunei Darussalam'},
'BT': {'name': 'Bhutan'},
'CC': {'name': 'Cocos (Keeling) Islands'},
'CN': {'name': 'China'},
'CX': {'name': 'Christmas Island'},
'CY': {'name': 'Cyprus'},
'GE': {'name': 'Georgia'},
'HK': {'name': 'Hong Kong'},
'ID': {'name': 'Indonesia'},
'IL': {'name': 'Israel'},
'IN': {'name': 'India'},
'IO': {'name': 'British Indian Ocean Territory'},
'IQ': {'name': 'Iraq'},
'IR': {'name': 'Iran, Islamic Republic of'},
'JO': {'name': 'Jordan'},
'JP': {'name': 'Japan'},
'KG': {'name': 'Kyrgyzstan'},
'KH': {'name': 'Cambodia'},
'KP': {'name': "Korea, Democratic People's Republic of"},
'KR': {'name': 'Korea, Republic of'},
'KW': {'name': 'Kuwait'},
'KZ': {'name': 'Kazakhstan'},
'LA': {'name': "Lao People's Democratic Republic"},
'LB': {'name': 'Lebanon'},
'LK': {'name': 'Sri Lanka'},
'MM': {'name': 'Myanmar'},
'MN': {'name': 'Mongolia'},
'MO': {'name': 'Macao'},
'MV': {'name': 'Maldives'},
'MY': {'name': 'Malaysia'},
'NP': {'name': 'Nepal'},
'OM': {'name': 'Oman'},
'PH': {'name': 'Philippines'},
'PK': {'name': 'Pakistan'},
'PS': {'name': 'Palestine, State of'},
'QA': {'name': 'Qatar'},
'SA': {'name': 'Saudi Arabia'},
'SG': {'name': 'Singapore'},
'SY': {'name': 'Syrian Arab Republic'},
'TH': {'name': 'Thailand'},
'TJ': {'name': 'Tajikistan'},
'TM': {'name': 'Turkmenistan'},
'TR': {'name': 'Turkey'},
'TW': {'name': 'Taiwan, Province of China'},
'UZ': {'name': 'Uzbekistan'},
'VN': {'name': 'Viet Nam'},
'YE': {'name': 'Yemen'}},
'EU': {'AD': {'name': 'Andorra'},
'AL': {'name': 'Albania'},
'AT': {'name': 'Austria'},
'AX': {'name': 'Åland Islands'},
'BA': {'name': 'Bosnia and Herzegovina'},
'BE': {'name': 'Belgium'},
'BG': {'name': 'Bulgaria'},
'BY': {'name': 'Belarus'},
'CH': {'name': 'Switzerland'},
'CZ': {'name': 'Czechia'},
'DE': {'name': 'Germany'},
'DK': {'name': 'Denmark'},
'EE': {'name': 'Estonia'},
'ES': {'name': 'Spain'},
'FI': {'name': 'Finland'},
'FO': {'name': 'Faroe Islands'},
'FR': {'name': 'France'},
'GB': {'name': 'United Kingdom'},
'GG': {'name': 'Guernsey'},
'GI': {'name': 'Gibraltar'},
'GR': {'name': 'Greece'},
'HR': {'name': 'Croatia'},
'HU': {'name': 'Hungary'},
'IE': {'name': 'Ireland'},
'IM': {'name': 'Isle of Man'},
'IS': {'name': 'Iceland'},
'IT': {'name': 'Italy'},
'JE': {'name': 'Jersey'},
'LI': {'name': 'Liechtenstein'},
'LT': {'name': 'Lithuania'},
'LU': {'name': 'Luxembourg'},
'LV': {'name': 'Latvia'},
'MC': {'name': 'Monaco'},
'MD': {'name': 'Moldova, Republic of'},
'ME': {'name': 'Montenegro'},
'MK': {'name': 'Macedonia, Republic of'},
'MT': {'name': 'Malta'},
'NL': {'name': 'Netherlands'},
'NO': {'name': 'Norway'},
'PL': {'name': 'Poland'},
'PT': {'name': 'Portugal'},
'RO': {'name': 'Romania'},
'RS': {'name': 'Serbia'},
'RU': {'name': 'Russian Federation'},
'SE': {'name': 'Sweden'},
'SI': {'name': 'Slovenia'},
'SJ': {'name': 'Svalbard and Jan Mayen'},
'SK': {'name': 'Slovakia'},
'SM': {'name': 'San Marino'},
'UA': {'name': 'Ukraine'},
'VA': {'name': 'Holy See (Vatican City State)'}},
'ID': {'TL': {'name': 'Timor-Leste'}},
'NA': {'AG': {'name': 'Antigua and Barbuda'},
'AI': {'name': 'Anguilla'},
'AW': {'name': 'Aruba'},
'BB': {'name': 'Barbados'},
'BL': {'name': 'Saint Barthélemy'},
'BM': {'name': 'Bermuda'},
'BQ': {'name': 'Bonaire, Sint Eustatius and Saba'},
'BS': {'name': 'Bahamas'},
'BZ': {'name': 'Belize'},
'CA': {'name': 'Canada'},
'CR': {'name': 'Costa Rica'},
'CU': {'name': 'Cuba'},
'CW': {'name': 'Curaçao'},
'DM': {'name': 'Dominica'},
'DO': {'name': 'Dominican Republic'},
'GD': {'name': 'Grenada'},
'GL': {'name': 'Greenland'},
'GP': {'name': 'Guadeloupe'},
'GT': {'name': 'Guatemala'},
'HN': {'name': 'Honduras'},
'HT': {'name': 'Haiti'},
'JM': {'name': 'Jamaica'},
'KN': {'name': 'Saint Kitts and Nevis'},
'KY': {'name': 'Cayman Islands'},
'LC': {'name': 'Saint Lucia'},
'MF': {'name': 'Saint Martin (French part)'},
'MQ': {'name': 'Martinique'},
'MS': {'name': 'Montserrat'},
'MX': {'name': 'Mexico'},
'NI': {'name': 'Nicaragua'},
'PA': {'name': 'Panama'},
'PM': {'name': 'Saint Pierre and Miquelon'},
'PR': {'name': 'Puerto Rico'},
'SV': {'name': 'El Salvador'},
'SX': {'name': 'Sint Maarten (Dutch part)'},
'TC': {'name': 'Turks and Caicos Islands'},
'TT': {'name': 'Trinidad and Tobago'},
'US': {'name': 'United States',
'provinces': {'AK': {'name': 'Alaska'},
'AL': {'name': 'Alabama'},
'AR': {'name': 'Arkansas'},
'AS': {'name': 'American Samoa'},
'AZ': {'name': 'Arizona'},
'CA': {'name': 'California'},
'CO': {'name': 'Colorado'},
'CT': {'name': 'Connecticut'},
'DC': {'name': 'District of Columbia'},
'DE': {'name': 'Delaware'},
'FL': {'name': 'Florida'},
'GA': {'name': 'Georgia'},
'GU': {'name': 'Guam'},
'HI': {'name': 'Hawaii'},
'IA': {'name': 'Iowa'},
'ID': {'name': 'Idaho'},
'IL': {'name': 'Illinois'},
'IN': {'name': 'Indiana'},
'KS': {'name': 'Kansas'},
'KY': {'name': 'Kentucky'},
'LA': {'name': 'Louisiana'},
'MA': {'name': 'Massachusetts'},
'MD': {'name': 'Maryland'},
'ME': {'name': 'Maine'},
'MI': {'name': 'Michigan'},
'MN': {'name': 'Minnesota'},
'MO': {'name': 'Missouri'},
'MP': {'name': 'Northern Mariana Islands'},
'MS': {'name': 'Mississippi'},
'MT': {'name': 'Montana'},
'NC': {'name': 'North Carolina'},
'ND': {'name': 'North Dakota'},
'NE': {'name': 'Nebraska'},
'NH': {'name': 'New Hampshire'},
'NJ': {'name': 'New Jersey'},
'NM': {'name': 'New Mexico'},
'NV': {'name': 'Nevada'},
'NY': {'name': 'New York'},
'OH': {'name': 'Ohio'},
'OK': {'name': 'Oklahoma'},
'OR': {'name': 'Oregon'},
'PA': {'name': 'Pennsylvania'},
'PR': {'name': 'Puerto Rico'},
'RI': {'name': 'Rhode Island'},
'SC': {'name': 'South Carolina'},
'SD': {'name': 'South Dakota'},
'TN': {'name': 'Tennessee'},
'TX': {'name': 'Texas'},
'UM': {'name': 'United States Minor Outlying '
'Islands'},
'UT': {'name': 'Utah'},
'VA': {'name': 'Virginia'},
'VI': {'name': 'Virgin Islands'},
'VT': {'name': 'Vermont'},
'WA': {'name': 'Washington'},
'WI': {'name': 'Wisconsin'},
'WV': {'name': 'West Virginia'},
'WY': {'name': 'Wyoming'}}},
'VC': {'name': 'Saint Vincent and the Grenadines'},
'VG': {'name': 'Virgin Islands, British'},
'VI': {'name': 'Virgin Islands, U.S.'}},
'OC': {'AS': {'name': 'American Samoa'},
'AU': {'name': 'Australia'},
'CK': {'name': 'Cook Islands'},
'FJ': {'name': 'Fiji'},
'FM': {'name': 'Micronesia, Federated States of'},
'GU': {'name': 'Guam'},
'KI': {'name': 'Kiribati'},
'MH': {'name': 'Marshall Islands'},
'MP': {'name': 'Northern Mariana Islands'},
'NC': {'name': 'New Caledonia'},
'NF': {'name': 'Norfolk Island'},
'NR': {'name': 'Nauru'},
'NU': {'name': 'Niue'},
'NZ': {'name': 'New Zealand'},
'PF': {'name': 'French Polynesia'},
'PG': {'name': 'Papua New Guinea'},
'PN': {'name': 'Pitcairn'},
'PW': {'name': 'Palau'},
'SB': {'name': 'Solomon Islands'},
'TK': {'name': 'Tokelau'},
'TO': {'name': 'Tonga'},
'TV': {'name': 'Tuvalu'},
'UM': {'name': 'United States Minor Outlying Islands'},
'VU': {'name': 'Vanuatu'},
'WF': {'name': 'Wallis and Futuna'},
'WS': {'name': 'Samoa'}},
'SA': {'AR': {'name': 'Argentina'},
'BO': {'name': 'Bolivia, Plurinational State of'},
'BR': {'name': 'Brazil'},
'CL': {'name': 'Chile'},
'CO': {'name': 'Colombia'},
'EC': {'name': 'Ecuador'},
'FK': {'name': 'Falkland Islands (Malvinas)'},
'GF': {'name': 'French Guiana'},
'GS': {'name': 'South Georgia and the South Sandwich Islands'},
'GY': {'name': 'Guyana'},
'PE': {'name': 'Peru'},
'PY': {'name': 'Paraguay'},
'SR': {'name': 'Suriname'},
'UY': {'name': 'Uruguay'},
'VE': {'name': 'Venezuela, Bolivarian Republic of'}}}
+1
View File
@@ -24,6 +24,7 @@ from .base import BaseSource
class AxfrBaseSource(BaseSource):
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'SPF',
'SRV', 'TXT'))
+3
View File
@@ -16,6 +16,9 @@ class BaseSource(object):
if not hasattr(self, 'SUPPORTS_GEO'):
raise NotImplementedError('Abstract base class, SUPPORTS_GEO '
'property missing')
if not hasattr(self, 'SUPPORTS_DYNAMIC'):
raise NotImplementedError('Abstract base class, SUPPORTS_DYNAMIC '
'property missing')
if not hasattr(self, 'SUPPORTS'):
raise NotImplementedError('Abstract base class, SUPPORTS '
'property missing')
+1
View File
@@ -19,6 +19,7 @@ from .base import BaseSource
class TinyDnsBaseSource(BaseSource):
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'CNAME', 'MX', 'NS'))
split_re = re.compile(r':+')
+2
View File
@@ -2,6 +2,8 @@ coverage
mock
nose
pycodestyle==2.4.0
pycountry>=18.12.8
pycountry_convert>=0.7.2
pyflakes==1.6.0
requests_mock
twine==1.11.0
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python
from collections import defaultdict
from pprint import pformat
from pycountry import countries, subdivisions
from pycountry_convert import country_alpha2_to_continent_code
subs = defaultdict(dict)
for subdivision in subdivisions:
# Route53 only supports US states, Dyn supports US states and CA provinces, but for now we'll just do US
if subdivision.country_code not in ('US'):
continue
subs[subdivision.country_code][subdivision.code[3:]] = {
'name': subdivision.name
}
subs = dict(subs)
#pprint(subs)
# These are best guesses at things pycountry_convert doesn't map
continent_backups = {
'AQ': 'AN',
'EH': 'AF',
'PN': 'OC',
'SX': 'NA',
'TF': 'AN',
'TL': 'ID',
'UM': 'OC',
'VA': 'EU',
}
geos = defaultdict(dict)
for country in countries:
try:
continent_code = country_alpha2_to_continent_code(country.alpha_2)
except KeyError:
try:
continent_code = continent_backups[country.alpha_2]
except KeyError:
raise
print('{} {} {}'.format(country.alpha_2, country.name, getattr(country, 'official_name', '')))
geos[continent_code][country.alpha_2] = {
'name': country.name
}
try:
geos[continent_code][country.alpha_2]['provinces'] = subs[country.alpha_2]
except KeyError:
pass
geos = dict(geos)
data = pformat(geos).replace('\n', '\n ')
print('''#
# -*- coding: utf-8 -*-
#
# This file is generated using
# ./script/generate-geo-data > octodns/record/geo_data.py
# do not modify it directly
#
geo_data = \\
{}'''.format(data))
+197
View File
@@ -0,0 +1,197 @@
---
a:
dynamic:
pools:
ams:
# TODO: make value possible
values:
- value: 1.1.1.1
iad:
values:
# TODO: make value optional
- value: 2.2.2.2
- value: 3.3.3.3
lax:
values:
- value: 4.4.4.4
sea:
values:
- value: 6.6.6.6
weight: 10
- value: 5.5.5.5
weight: 25
rules:
- geos:
- EU-GB
pool: iad
- geos:
- EU
pool: ams
- geos:
- NA-US-CA
- NA-US-OR
- NA-US-WA
pool: sea
- pool: iad
type: A
values:
- 2.2.2.2
- 3.3.3.3
aaaa:
dynamic:
pools:
ams:
values:
- value: 2601:642:500:e210:62f8:1dff:feb8:9471
iad:
values:
- value: 2601:642:500:e210:62f8:1dff:feb8:9472
- value: 2601:642:500:e210:62f8:1dff:feb8:9473
lax:
values:
- value: 2601:642:500:e210:62f8:1dff:feb8:9474
sea:
values:
- value: 2601:642:500:e210:62f8:1dff:feb8:9475
weight: 1
- value: 2601:642:500:e210:62f8:1dff:feb8:9476
weight: 2
rules:
- geos:
- EU-GB
pool: iad
- geos:
- EU
pool: ams
- geos:
- NA-US-CA
- NA-US-OR
- NA-US-WA
pool: sea
- pool: iad
type: AAAA
values:
- 2601:642:500:e210:62f8:1dff:feb8:947a
- 2601:644:500:e210:62f8:1dff:feb8:947a
cname:
dynamic:
pools:
ams:
values:
- value: target-ams.unit.tests.
iad:
values:
- value: target-iad.unit.tests.
lax:
values:
- value: target-lax.unit.tests.
sea:
values:
- value: target-sea-1.unit.tests.
weight: 100
- value: target-sea-2.unit.tests.
weight: 175
rules:
- geos:
- EU-GB
pool: iad
- geos:
- EU
pool: ams
- geos:
- NA-US-CA
- NA-US-OR
- NA-US-WA
pool: sea
- pool: iad
type: CNAME
value: target.unit.tests.
real-ish-a:
dynamic:
pools:
ap-southeast-1:
values:
# ap-southeast-1a
- value: 1.4.1.1
weight: 2
- value: 1.4.1.2
weight: 2
# ap-southeast-1b
- value: 1.4.2.1
- value: 1.4.2.2
# ap-southeast-1c
- value: 1.4.3.1
- value: 1.4.3.2
eu-central-1:
values:
# eu-central-1a
- value: 1.3.1.1
- value: 1.3.1.2
# eu-central-1b
- value: 1.3.2.1
- value: 1.3.2.2
# eu-central-1c
- value: 1.3.3.1
- value: 1.3.3.2
us-east-1:
values:
# us-east-1a
- value: 1.1.1.1
- value: 1.1.1.2
# us-east-1b
- value: 1.1.2.1
- value: 1.1.2.2
# us-east-1c
- value: 1.1.3.1
- value: 1.1.3.2
us-west-2:
values:
# us-west-2a
- value: 1.2.1.1
- value: 1.2.1.2
# us-west-2b
- value: 1.2.2.1
- value: 1.2.2.2
# us-west-2c
- value: 1.2.3.1
- value: 1.2.3.2
rules:
- geos:
# TODO: require sorted
- NA-US-CA
- NA-US-OR
- NA-US-WA
pool: us-west-2
- geos:
- AS-CN
pool: ap-southeast-1
- geos:
- AF
- EU
pool: eu-central-1
- pool: us-east-1
type: A
values:
# Generally these should match the values of your "default" rule's pools as
# if everything fails healthchecks they'll fallback to this
- 1.1.1.1
- 1.1.1.2
- 1.1.2.1
- 1.1.2.2
- 1.1.3.1
- 1.1.3.2
simple-weighted:
dynamic:
pools:
default:
values:
- value: one.unit.tests.
weight: 3
- value: two.unit.tests.
weight: 2
rules:
- pool: default
type: CNAME
# CNAMEs don't support health checks (currently) so these will never be used
# on providers with dynamic support
value: default.unit.tests.
+20
View File
@@ -17,6 +17,7 @@ class SimpleSource(object):
class SimpleProvider(object):
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A',))
id = 'test'
@@ -35,6 +36,25 @@ class SimpleProvider(object):
class GeoProvider(object):
SUPPORTS_GEO = True
SUPPORTS_DYNAMIC = False
id = 'test'
def __init__(self, id='test'):
pass
def populate(self, zone, source=False, lenient=False):
pass
def supports(self, record):
return True
def __repr__(self):
return self.__class__.__name__
class DynamicProvider(object):
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = True
id = 'test'
def __init__(self, id='test'):
+8 -2
View File
@@ -14,8 +14,8 @@ from octodns.manager import _AggregateTarget, MainThreadExecutor, Manager
from octodns.yaml import safe_load
from octodns.zone import Zone
from helpers import GeoProvider, NoSshFpProvider, SimpleProvider, \
TemporaryDirectory
from helpers import DynamicProvider, GeoProvider, NoSshFpProvider, \
SimpleProvider, TemporaryDirectory
config_dir = join(dirname(__file__), 'config')
@@ -187,6 +187,7 @@ class TestManager(TestCase):
def test_aggregate_target(self):
simple = SimpleProvider()
geo = GeoProvider()
dynamic = DynamicProvider()
nosshfp = NoSshFpProvider()
self.assertFalse(_AggregateTarget([simple, simple]).SUPPORTS_GEO)
@@ -194,6 +195,11 @@ class TestManager(TestCase):
self.assertFalse(_AggregateTarget([geo, simple]).SUPPORTS_GEO)
self.assertTrue(_AggregateTarget([geo, geo]).SUPPORTS_GEO)
self.assertFalse(_AggregateTarget([simple, simple]).SUPPORTS_DYNAMIC)
self.assertFalse(_AggregateTarget([simple, dynamic]).SUPPORTS_DYNAMIC)
self.assertFalse(_AggregateTarget([dynamic, simple]).SUPPORTS_DYNAMIC)
self.assertTrue(_AggregateTarget([dynamic, dynamic]).SUPPORTS_DYNAMIC)
zone = Zone('unit.tests.', [])
record = Record.new(zone, 'sshfp', {
'ttl': 60,
+10 -2
View File
@@ -61,13 +61,21 @@ class TestBaseProvider(TestCase):
class HasSupportsGeo(HasLog):
SUPPORTS_GEO = False
with self.assertRaises(NotImplementedError) as ctx:
HasSupportsGeo('hassupportsgeo')
self.assertEquals('Abstract base class, SUPPORTS_DYNAMIC '
'property missing', ctx.exception.message)
class HasSupportsDyanmic(HasSupportsGeo):
SUPPORTS_DYNAMIC = False
zone = Zone('unit.tests.', ['sub'])
with self.assertRaises(NotImplementedError) as ctx:
HasSupportsGeo('hassupportsgeo').populate(zone)
HasSupportsDyanmic('hassupportsdynamic').populate(zone)
self.assertEquals('Abstract base class, SUPPORTS property missing',
ctx.exception.message)
class HasSupports(HasSupportsGeo):
class HasSupports(HasSupportsDyanmic):
SUPPORTS = set(('A',))
with self.assertRaises(NotImplementedError) as ctx:
HasSupports('hassupports').populate(zone)
+60 -9
View File
@@ -23,6 +23,7 @@ class TestYamlProvider(TestCase):
source = YamlProvider('test', join(dirname(__file__), 'config'))
zone = Zone('unit.tests.', [])
dynamic_zone = Zone('dynamic.tests.', [])
# With target we don't add anything
source.populate(zone, target=source)
@@ -32,6 +33,9 @@ class TestYamlProvider(TestCase):
source.populate(zone)
self.assertEquals(18, len(zone.records))
source.populate(dynamic_zone)
self.assertEquals(5, len(dynamic_zone.records))
# Assumption here is that a clean round-trip means that everything
# worked as expected, data that went in came back out and could be
# pulled in yet again and still match up. That assumes that the input
@@ -45,6 +49,7 @@ class TestYamlProvider(TestCase):
# Add some subdirs to make sure that it can create them
directory = join(td.dirname, 'sub', 'dir')
yaml_file = join(directory, 'unit.tests.yaml')
dynamic_yaml_file = join(directory, 'dynamic.tests.yaml')
target = YamlProvider('test', directory)
# We add everything
@@ -57,6 +62,15 @@ class TestYamlProvider(TestCase):
self.assertEquals(15, target.apply(plan))
self.assertTrue(isfile(yaml_file))
# Dynamic plan
plan = target.plan(dynamic_zone)
self.assertEquals(5, len(filter(lambda c: isinstance(c, Create),
plan.changes)))
self.assertFalse(isfile(dynamic_yaml_file))
# Apply it
self.assertEquals(5, target.apply(plan))
self.assertTrue(isfile(dynamic_yaml_file))
# There should be no changes after the round trip
reloaded = Zone('unit.tests.', [])
target.populate(reloaded)
@@ -77,21 +91,58 @@ class TestYamlProvider(TestCase):
data = safe_load(fh.read())
# '' has some of both
roots = sorted(data[''], key=lambda r: r['type'])
roots = sorted(data.pop(''), key=lambda r: r['type'])
self.assertTrue('values' in roots[0]) # A
self.assertTrue('geo' in roots[0]) # geo made the trip
self.assertTrue('value' in roots[1]) # CAA
self.assertTrue('values' in roots[2]) # SSHFP
# these are stored as plural 'values'
self.assertTrue('values' in data['mx'])
self.assertTrue('values' in data['naptr'])
self.assertTrue('values' in data['_srv._tcp'])
self.assertTrue('values' in data['txt'])
self.assertTrue('values' in data.pop('_srv._tcp'))
self.assertTrue('values' in data.pop('mx'))
self.assertTrue('values' in data.pop('naptr'))
self.assertTrue('values' in data.pop('sub'))
self.assertTrue('values' in data.pop('txt'))
# these are stored as singular 'value'
self.assertTrue('value' in data['aaaa'])
self.assertTrue('value' in data['ptr'])
self.assertTrue('value' in data['spf'])
self.assertTrue('value' in data['www'])
self.assertTrue('value' in data.pop('aaaa'))
self.assertTrue('value' in data.pop('cname'))
self.assertTrue('value' in data.pop('included'))
self.assertTrue('value' in data.pop('ptr'))
self.assertTrue('value' in data.pop('spf'))
self.assertTrue('value' in data.pop('www'))
self.assertTrue('value' in data.pop('www.sub'))
# make sure nothing is left
self.assertEquals([], data.keys())
with open(dynamic_yaml_file) as fh:
data = safe_load(fh.read())
# make sure new dynamic records made the trip
dyna = data.pop('a')
self.assertTrue('values' in dyna)
# self.assertTrue('dynamic' in dyna)
# TODO:
# make sure new dynamic records made the trip
dyna = data.pop('aaaa')
self.assertTrue('values' in dyna)
# self.assertTrue('dynamic' in dyna)
dyna = data.pop('cname')
self.assertTrue('value' in dyna)
# self.assertTrue('dynamic' in dyna)
dyna = data.pop('real-ish-a')
self.assertTrue('values' in dyna)
# self.assertTrue('dynamic' in dyna)
dyna = data.pop('simple-weighted')
self.assertTrue('value' in dyna)
# self.assertTrue('dynamic' in dyna)
# make sure nothing is left
self.assertEquals([], data.keys())
def test_empty(self):
source = YamlProvider('test', join(dirname(__file__), 'config'))
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from unittest import TestCase
from octodns.record.geo import GeoCodes
class TestRecordGeoCodes(TestCase):
def test_validate(self):
prefix = 'xyz '
# All valid
self.assertEquals([], GeoCodes.validate('NA', prefix))
self.assertEquals([], GeoCodes.validate('NA-US', prefix))
self.assertEquals([], GeoCodes.validate('NA-US-OR', prefix))
# Just plain bad
self.assertEquals(['xyz invalid geo code "XX-YY-ZZ-AA"'],
GeoCodes.validate('XX-YY-ZZ-AA', prefix))
self.assertEquals(['xyz unknown continent code "X-Y-Z"'],
GeoCodes.validate('X-Y-Z', prefix))
self.assertEquals(['xyz unknown continent code "XXX-Y-Z"'],
GeoCodes.validate('XXX-Y-Z', prefix))
# Bad continent
self.assertEquals(['xyz unknown continent code "XX"'],
GeoCodes.validate('XX', prefix))
# Bad continent good country
self.assertEquals(['xyz unknown continent code "XX-US"'],
GeoCodes.validate('XX-US', prefix))
# Bad continent good country and province
self.assertEquals(['xyz unknown continent code "XX-US-OR"'],
GeoCodes.validate('XX-US-OR', prefix))
# Bad country, good continent
self.assertEquals(['xyz unknown country code "NA-XX"'],
GeoCodes.validate('NA-XX', prefix))
# Bad country, good continent and state
self.assertEquals(['xyz unknown country code "NA-XX-OR"'],
GeoCodes.validate('NA-XX-OR', prefix))
# Good country, good continent, but bad match
self.assertEquals(['xyz unknown country code "NA-GB"'],
GeoCodes.validate('NA-GB', prefix))
# Bad province code, good continent and country
self.assertEquals(['xyz unknown province code "NA-US-XX"'],
GeoCodes.validate('NA-US-XX', prefix))
+1
View File
@@ -111,6 +111,7 @@ class TestZone(TestCase):
class NoAaaaProvider(object):
id = 'no-aaaa'
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
def supports(self, record):
return record._type != 'AAAA'