diff --git a/octodns/record.py b/octodns/record.py index be5f107..4b054db 100644 --- a/octodns/record.py +++ b/octodns/record.py @@ -253,6 +253,7 @@ class _ValuesMixin(object): @classmethod def validate(cls, name, data): reasons = super(_ValuesMixin, cls).validate(name, data) + values = [] try: values = data['values'] @@ -279,12 +280,11 @@ class _ValuesMixin(object): reasons.append('empty value') values = [] else: - values = [value] + values = value except KeyError: reasons.append('missing value(s)') - for value in values: - reasons.extend(cls._validate_value(value)) + reasons.extend(cls._value_type.validate(values)) return reasons @@ -339,8 +339,7 @@ class _GeoMixin(_ValuesMixin): # 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)) except KeyError: pass return reasons @@ -353,6 +352,8 @@ class _GeoMixin(_ValuesMixin): super(_GeoMixin, self).__init__(zone, name, data, *args, **kwargs) try: self.geo = dict(data['geo']) + self.log.warn("'geo' support has been deprecated, " + "transition %s to use 'dynamic'", name) except KeyError: self.geo = {} for code, values in self.geo.items(): @@ -397,7 +398,7 @@ class _ValueMixin(object): except KeyError: reasons.append('missing value') if value: - reasons.extend(cls._validate_value(value)) + reasons.extend(cls._value_type.validate(value, cls)) return reasons def __init__(self, zone, name, data, source=None): @@ -421,59 +422,97 @@ class _ValueMixin(object): self.fqdn, self.value) -class _DynamicBaseMixin(object): - pass +class _DynamicMixin(object): + @classmethod + def validate(cls, name, data): + reasons = super(_DynamicMixin, cls).validate(name, data) + try: + pools = data['dynamic']['pools'] + except KeyError: + pools = {} + for pool in sorted(pools.values()): + reasons.extend(cls._value_type.validate(pool)) + return reasons -class _DynamicValuesMixin(_DynamicBaseMixin, _GeoMixin): - pass + def __init__(self, zone, name, data, *args, **kwargs): + super(_DynamicMixin, self).__init__(zone, name, data, *args, + **kwargs) + try: + self.dynamic = dict(data['dynamic']) + except: + self.dynamic = {} + # TODO: -class _DynamicValueMixin(_DynamicBaseMixin, _ValueMixin): - pass +class Ipv4List(object): + + @classmethod + def validate(cls, data): + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + try: + IPv4Address(unicode(value)) + except Exception: + reasons.append('invalid IPv4 address "{}"'.format(value)) + return reasons -class ARecord(_DynamicValuesMixin, Record): - _type = 'A' +class Ipv6List(object): @classmethod - def _validate_value(self, value): + def validate(cls, data): + if not isinstance(data, (list, tuple)): + data = (data,) reasons = [] - try: - IPv4Address(unicode(value)) - except Exception: - reasons.append('invalid ip address "{}"'.format(value)) + for value in data: + try: + IPv6Address(unicode(value)) + except Exception: + reasons.append('invalid IPv6 address "{}"'.format(value)) return reasons + +class _TargetValue(object): + + @classmethod + def validate(cls, data, record_cls): + reasons = [] + if not data.endswith('.'): + reasons.append('{} value "{}" missing trailing .' + .format(record_cls._type, data)) + return reasons + + +class CnameValue(_TargetValue): + pass + + +class ARecord(_DynamicMixin, _GeoMixin, Record): + _type = 'A' + _value_type = Ipv4List + 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 + _value_type = Ipv6List def _process_values(self, values): return values +class AliasValue(_TargetValue): + pass + + class AliasRecord(_ValueMixin, Record): _type = 'ALIAS' - - @classmethod - def _validate_value(self, value): - reasons = [] - if not value.endswith('.'): - reasons.append('missing trailing .') - return reasons + _value_type = AliasValue def _process_value(self, value): return value @@ -483,20 +522,22 @@ class CaaValue(object): # https://tools.ietf.org/html/rfc6844#page-5 @classmethod - def _validate_value(cls, value): + def validate(cls, data): + 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 def __init__(self, value): @@ -525,10 +566,7 @@ class CaaValue(object): class CaaRecord(_ValuesMixin, Record): _type = 'CAA' - - @classmethod - def _validate_value(cls, value): - return CaaValue._validate_value(value) + _value_type = CaaValue def _process_values(self, values): return [CaaValue(v) for v in values] @@ -536,6 +574,7 @@ class CaaRecord(_ValuesMixin, Record): class CnameRecord(_ValueMixin, Record): _type = 'CNAME' + _value_type = CnameValue @classmethod def validate(cls, name, data): @@ -545,13 +584,6 @@ 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 @@ -559,25 +591,29 @@ class CnameRecord(_ValueMixin, Record): class MxValue(object): @classmethod - def _validate_value(cls, value): + def validate(cls, data): + 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 def __init__(self, value): @@ -612,10 +648,7 @@ class MxValue(object): class MxRecord(_ValuesMixin, Record): _type = 'MX' - - @classmethod - def _validate_value(cls, value): - return MxValue._validate_value(value) + _value_type = MxValue def _process_values(self, values): return [MxValue(v) for v in values] @@ -625,32 +658,36 @@ class NaptrValue(object): VALID_FLAGS = ('S', 'A', 'U', 'P') @classmethod - def _validate_value(cls, data): + def validate(cls, data): + 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 def __init__(self, value): @@ -696,38 +733,41 @@ class NaptrValue(object): class NaptrRecord(_ValuesMixin, Record): _type = 'NAPTR' - - @classmethod - def _validate_value(cls, value): - return NaptrValue._validate_value(value) + _value_type = NaptrValue def _process_values(self, values): return [NaptrValue(v) for v in values] -class NsRecord(_ValuesMixin, Record): - _type = 'NS' +class _NsValue(object): @classmethod - def _validate_value(cls, value): + def validate(cls, data): + if not isinstance(data, (list, tuple)): + data = (data,) reasons = [] - if not value.endswith('.'): - reasons.append('missing trailing .') + for value in data: + if not value.endswith('.'): + reasons.append('NS value "{}" missing trailing .' + .format(value)) return reasons + +class NsRecord(_ValuesMixin, Record): + _type = 'NS' + _value_type = _NsValue + 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 + _value_type = PtrValue def _process_value(self, value): return value @@ -738,28 +778,33 @@ class SshfpValue(object): VALID_FINGERPRINT_TYPES = (1, 2) @classmethod - def _validate_value(cls, value): + def validate(cls, data): + 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 def __init__(self, value): @@ -789,26 +834,15 @@ class SshfpValue(object): class SshfpRecord(_ValuesMixin, Record): _type = 'SSHFP' - - @classmethod - def _validate_value(cls, value): - return SshfpValue._validate_value(value) + _value_type = SshfpValue def _process_values(self, values): return [SshfpValue(v) for v in values] -_unescaped_semicolon_re = re.compile(r'\w;') - - class _ChunkedValuesMixin(_ValuesMixin): CHUNK_SIZE = 255 - - @classmethod - def _validate_value(cls, value): - if _unescaped_semicolon_re.search(value): - return ['unescaped ;'] - return [] + _unescaped_semicolon_re = re.compile(r'\w;') def _process_values(self, values): ret = [] @@ -830,39 +864,59 @@ class _ChunkedValuesMixin(_ValuesMixin): return values +class _ChunkedValue(object): + _unescaped_semicolon_re = re.compile(r'\w;') + + @classmethod + def validate(cls, data): + if 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 + + class SpfRecord(_ChunkedValuesMixin, Record): _type = 'SPF' + _value_type = _ChunkedValue class SrvValue(object): @classmethod - def _validate_value(self, value): + def validate(cls, data): + 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 def __init__(self, value): @@ -896,6 +950,7 @@ class SrvValue(object): class SrvRecord(_ValuesMixin, Record): _type = 'SRV' + _value_type = SrvValue _name_re = re.compile(r'^_[^\.]+\.[^\.]+') @classmethod @@ -906,13 +961,14 @@ 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 diff --git a/tests/config/dynamic.tests.yaml b/tests/config/dynamic.tests.yaml index d4855c8..07d3a54 100644 --- a/tests/config/dynamic.tests.yaml +++ b/tests/config/dynamic.tests.yaml @@ -2,17 +2,12 @@ a: dynamic: pools: - ams: - values: - - 1.1.1.1 + ams: 1.1.1.1 iad: - values: - 2.2.2.2 - 3.3.3.3 - lax: - value: 4.4.4.4 - sea: - value: 5.5.5.5 + lax: 4.4.4.4 + sea: 5.5.5.5 rules: - geo: EU-UK pools: @@ -28,8 +23,7 @@ a: pools: 25: iad 75: sea - - default: - pool: iad + - pool: iad type: A values: - 2.2.2.2 @@ -60,7 +54,7 @@ cname: pools: 12: sea 250: iad - - default: + - pools: 1: sea 4: iad type: CNAME @@ -69,12 +63,12 @@ simple-weighted: dynamic: pools: one: - one.unit.tests. + value: one.unit.tests. two: - two.unit.tests. + value: two.unit.tests. rules: - - default: - 100: one - 200: two + - pools: + 100: one + 200: two type: CNAME value: default.unit.tests. diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 3facd32..d761573 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -941,7 +941,7 @@ class TestRecordValidation(TestCase): 'ttl': 600, 'value': 'hello' }) - self.assertEquals(['invalid ip address "hello"'], + self.assertEquals(['invalid IPv4 address "hello"'], ctx.exception.reasons) # invalid ip addresses @@ -952,8 +952,8 @@ class TestRecordValidation(TestCase): 'values': ['hello', 'goodbye'] }) self.assertEquals([ - 'invalid ip address "hello"', - 'invalid ip address "goodbye"' + 'invalid IPv4 address "hello"', + 'invalid IPv4 address "goodbye"' ], ctx.exception.reasons) # invalid & valid ip addresses, no ttl @@ -964,7 +964,7 @@ class TestRecordValidation(TestCase): }) self.assertEquals([ 'missing ttl', - 'invalid ip address "hello"', + 'invalid IPv4 address "hello"', ], ctx.exception.reasons) def test_geo(self): @@ -989,7 +989,7 @@ class TestRecordValidation(TestCase): 'ttl': 600, 'value': '1.2.3.4', }) - self.assertEquals(['invalid ip address "hello"'], + self.assertEquals(['invalid IPv4 address "hello"'], ctx.exception.reasons) # invalid geo code @@ -1016,8 +1016,8 @@ class TestRecordValidation(TestCase): 'value': '1.2.3.4', }) self.assertEquals([ - 'invalid ip address "hello"', - 'invalid ip address "goodbye"' + 'invalid IPv4 address "hello"', + 'invalid IPv4 address "goodbye"' ], ctx.exception.reasons) # invalid healthcheck protocol @@ -1062,16 +1062,21 @@ class TestRecordValidation(TestCase): 'ttl': 600, 'value': 'hello' }) - self.assertEquals(['invalid ip address "hello"'], + self.assertEquals(['invalid IPv6 address "hello"'], ctx.exception.reasons) with self.assertRaises(ValidationError) as ctx: Record.new(self.zone, '', { 'type': 'AAAA', 'ttl': 600, - 'value': '1.2.3.4' + 'values': [ + '1.2.3.4', + '2.3.4.5', + ], }) - self.assertEquals(['invalid ip address "1.2.3.4"'], - ctx.exception.reasons) + self.assertEquals([ + 'invalid IPv6 address "1.2.3.4"', + 'invalid IPv6 address "2.3.4.5"', + ], ctx.exception.reasons) # invalid ip addresses with self.assertRaises(ValidationError) as ctx: @@ -1081,8 +1086,8 @@ class TestRecordValidation(TestCase): 'values': ['hello', 'goodbye'] }) self.assertEquals([ - 'invalid ip address "hello"', - 'invalid ip address "goodbye"' + 'invalid IPv6 address "hello"', + 'invalid IPv6 address "goodbye"' ], ctx.exception.reasons) def test_ALIAS_and_value_mixin(self): @@ -1126,7 +1131,8 @@ class TestRecordValidation(TestCase): 'ttl': 600, 'value': 'foo.bar.com', }) - self.assertEquals(['missing trailing .'], ctx.exception.reasons) + self.assertEquals(['ALIAS value "foo.bar.com" missing trailing .'], + ctx.exception.reasons) def test_CAA(self): # doesn't blow up @@ -1221,7 +1227,8 @@ class TestRecordValidation(TestCase): 'ttl': 600, 'value': 'foo.bar.com', }) - self.assertEquals(['missing trailing .'], ctx.exception.reasons) + self.assertEquals(['CNAME value "foo.bar.com" missing trailing .'], + ctx.exception.reasons) def test_MX(self): # doesn't blow up @@ -1278,7 +1285,8 @@ class TestRecordValidation(TestCase): 'exchange': 'foo.bar.com' } }) - self.assertEquals(['missing trailing .'], ctx.exception.reasons) + self.assertEquals(['MX value "foo.bar.com" missing trailing .'], + ctx.exception.reasons) def test_NXPTR(self): # doesn't blow up @@ -1375,7 +1383,8 @@ class TestRecordValidation(TestCase): 'ttl': 600, 'value': 'foo.bar', }) - self.assertEquals(['missing trailing .'], ctx.exception.reasons) + self.assertEquals(['NS value "foo.bar" missing trailing .'], + ctx.exception.reasons) def test_PTR(self): # doesn't blow up (name & zone here don't make any sense, but not @@ -1401,7 +1410,8 @@ class TestRecordValidation(TestCase): 'ttl': 600, 'value': 'foo.bar', }) - self.assertEquals(['missing trailing .'], ctx.exception.reasons) + self.assertEquals(['PTR value "foo.bar" missing trailing .'], + ctx.exception.reasons) def test_SSHFP(self): # doesn't blow up @@ -1534,7 +1544,8 @@ class TestRecordValidation(TestCase): 'ttl': 600, 'value': 'this has some; semi-colons\\; in it', }) - self.assertEquals(['unescaped ;'], ctx.exception.reasons) + self.assertEquals(['unescaped ; in "this has some; ' + 'semi-colons\\; in it"'], ctx.exception.reasons) def test_SRV(self): # doesn't blow up @@ -1666,7 +1677,7 @@ class TestRecordValidation(TestCase): 'target': 'foo.bar.baz' } }) - self.assertEquals(['missing trailing .'], + self.assertEquals(['SRV value "foo.bar.baz" missing trailing .'], ctx.exception.reasons) def test_TXT(self): @@ -1696,7 +1707,8 @@ class TestRecordValidation(TestCase): 'ttl': 600, 'value': 'this has some; semi-colons\\; in it', }) - self.assertEquals(['unescaped ;'], ctx.exception.reasons) + self.assertEquals(['unescaped ; in "this has some; semi-colons\\; ' + 'in it"'], ctx.exception.reasons) def test_TXT_long_value_chunking(self): expected = '"Lorem ipsum dolor sit amet, consectetur adipiscing ' \ @@ -1757,3 +1769,66 @@ class TestRecordValidation(TestCase): self.assertEquals(single.values, chunked.values) # should be chunked values, with quoting self.assertEquals(single.chunked_values, chunked.chunked_values) + + +class TestDynamicRecords(TestCase): + zone = Zone('unit.tests.', []) + + def test_simple_a_weighted(self): + a_data = { + 'dynamic': { + 'pools': { + 'one': '3.3.3.3', + 'two': [ + '4.4.4.4', + '5.5.5.5', + ], + }, + 'rules': [{ + 'pools': { + 100: 'one', + 200: 'two', + } + }], + }, + 'ttl': 60, + 'values': [ + '1.1.1.1', + '2.2.2.2', + ], + } + a = ARecord(self.zone, 'weighted', a_data) + self.assertEquals('A', a._type) + self.assertEquals(a_data['ttl'], a.ttl) + self.assertEquals(a_data['values'], a.values) + self.assertEquals(a_data['dynamic'], a.dynamic) + + def test_a_validation(self): + a_data = { + 'dynamic': { + 'pools': { + 'one': 'this-aint-right', + 'two': [ + '4.4.4.4', + 'nor-is-this', + ], + }, + 'rules': [{ + 'pools': { + 100: '5.5.5.5', + 200: '6.6.6.6', + } + }], + }, + 'ttl': 60, + 'type': 'A', + 'values': [ + '1.1.1.1', + '2.2.2.2', + ], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEquals(['invalid IPv4 address "nor-is-this"', + 'invalid IPv4 address "this-aint-right"'], + ctx.exception.reasons)