From 9caaa5259a8c843d2a13d21f5cdb71361d23b2b4 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Wed, 7 Sep 2022 15:02:38 -0700 Subject: [PATCH 01/12] Implement to/from rr text for MxValue as a POC --- octodns/record/__init__.py | 30 +++++++++++++++++ tests/test_octodns_record.py | 63 ++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 58169d5..a8f756f 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -73,6 +73,10 @@ class RecordException(Exception): pass +class RrParseError(RecordException): + pass + + class ValidationError(RecordException): @classmethod def build_message(cls, fqdn, reasons): @@ -1254,12 +1258,32 @@ Record.register_type(LocRecord) class MxValue(EqualityTupleMixin, dict): + @classmethod + def parse_rr_text(self, value): + try: + preference, exchange = value.split(' ') + except ValueError: + raise RrParseError('failed to parse string value as RR text') + try: + preference = int(preference) + except ValueError: + pass + return {'preference': preference, 'exchange': exchange} + @classmethod def validate(cls, data, _type): if not isinstance(data, (list, tuple)): data = (data,) reasons = [] for value in data: + if isinstance(value, str): + # it's hopefully RR formatted, give parsing a try + try: + value = cls.parse_rr_text(value) + except RrParseError as e: + reasons.append(str(e)) + # not a dict so no point in continuing + continue try: try: int(value['preference']) @@ -1291,6 +1315,8 @@ class MxValue(EqualityTupleMixin, dict): return [cls(v) for v in values] def __init__(self, value): + if isinstance(value, str): + value = self.parse_rr_text(value) # RFC1035 says preference, half the providers use priority try: preference = value['preference'] @@ -1325,6 +1351,10 @@ class MxValue(EqualityTupleMixin, dict): def data(self): return self + @property + def rr_text(self): + return f'{self.preference} {self.exchange}' + def __hash__(self): return hash((self.preference, self.exchange)) diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 5521584..5ec7b71 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -32,6 +32,7 @@ from octodns.record import ( PtrRecord, Record, RecordException, + RrParseError, SshfpRecord, SshfpValue, SpfRecord, @@ -592,6 +593,68 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() + def test_mx_rr_text(self): + + # empty string won't parse + with self.assertRaises(RrParseError): + MxValue.parse_rr_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + MxValue.parse_rr_text('nope') + + # 3rd word won't parse + with self.assertRaises(RrParseError): + MxValue.parse_rr_text('10 mx.unit.tests. another') + + # preference not an int, will parse + self.assertEqual( + {'preference': 10, 'exchange': 'mx.unit.tests.'}, + MxValue.parse_rr_text('10 mx.unit.tests.'), + ) + + # preference not an int + self.assertEqual( + {'preference': 'abc', 'exchange': 'mx.unit.tests.'}, + MxValue.parse_rr_text('abc mx.unit.tests.'), + ) + + # make sure that validate is using parse_rr_text when passed string + # values + reasons = MxRecord.validate( + 'mx', 'mx.unit.tests.', {'ttl': 32, 'value': ''} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = MxRecord.validate( + 'mx', 'mx.unit.tests.', {'ttl': 32, 'values': ['nope']} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = MxRecord.validate( + 'mx', 'mx.unit.tests.', {'ttl': 32, 'value': '10 mail.unit.tests.'} + ) + self.assertFalse(reasons) + + # make sure that the cstor is using parse_rr_text + zone = Zone('unit.tests.', []) + a = MxRecord(zone, 'mx', {'ttl': 32, 'value': '10 mail.unit.tests.'}) + self.assertEqual(10, a.values[0].preference) + self.assertEqual('10 mail.unit.tests.', a.values[0].rr_text) + self.assertEqual('mail.unit.tests.', a.values[0].exchange) + a = MxRecord( + zone, + 'mx', + { + 'ttl': 32, + 'values': ['11 mail1.unit.tests.', '12 mail2.unit.tests.'], + }, + ) + self.assertEqual(11, a.values[0].preference) + self.assertEqual('mail1.unit.tests.', a.values[0].exchange) + self.assertEqual('11 mail1.unit.tests.', a.values[0].rr_text) + self.assertEqual(12, a.values[1].preference) + self.assertEqual('mail2.unit.tests.', a.values[1].exchange) + self.assertEqual('12 mail2.unit.tests.', a.values[1].rr_text) + def test_naptr(self): a_values = [ NaptrValue( From 0eb4e6663429b22bb5ef572c7c446682c7ff938d Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Wed, 7 Sep 2022 15:24:17 -0700 Subject: [PATCH 02/12] Naptr to/from rr text --- octodns/record/__init__.py | 41 ++++++++++++++++++++ tests/test_octodns_record.py | 73 +++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index a8f756f..86e2ef5 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -1376,12 +1376,47 @@ Record.register_type(MxRecord) class NaptrValue(EqualityTupleMixin, dict): VALID_FLAGS = ('S', 'A', 'U', 'P') + @classmethod + def parse_rr_text(self, value): + try: + ( + order, + preference, + flags, + service, + regexp, + replacement, + ) = value.split(' ') + except ValueError: + raise RrParseError('failed to parse string value as RR text') + try: + order = int(order) + preference = int(preference) + except ValueError: + pass + return { + 'order': order, + 'preference': preference, + 'flags': flags, + 'service': service, + 'regexp': regexp, + 'replacement': replacement, + } + @classmethod def validate(cls, data, _type): if not isinstance(data, (list, tuple)): data = (data,) reasons = [] for value in data: + if isinstance(value, str): + # it's hopefully RR formatted, give parsing a try + try: + value = cls.parse_rr_text(value) + except RrParseError as e: + reasons.append(str(e)) + # not a dict so no point in continuing + continue try: int(value['order']) except KeyError: @@ -1413,6 +1448,8 @@ class NaptrValue(EqualityTupleMixin, dict): return [cls(v) for v in values] def __init__(self, value): + if isinstance(value, str): + value = self.parse_rr_text(value) super().__init__( { 'order': int(value['order']), @@ -1476,6 +1513,10 @@ class NaptrValue(EqualityTupleMixin, dict): def data(self): return self + @property + def rr_text(self): + return f'{self.order} {self.preference} {self.flags} {self.service} {self.regexp} {self.replacement}' + def __hash__(self): return hash(self.__repr__()) diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 5ec7b71..1fc9db6 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -620,7 +620,7 @@ class TestRecord(TestCase): ) # make sure that validate is using parse_rr_text when passed string - # values + # value(s) reasons = MxRecord.validate( 'mx', 'mx.unit.tests.', {'ttl': 32, 'value': ''} ) @@ -638,8 +638,8 @@ class TestRecord(TestCase): zone = Zone('unit.tests.', []) a = MxRecord(zone, 'mx', {'ttl': 32, 'value': '10 mail.unit.tests.'}) self.assertEqual(10, a.values[0].preference) - self.assertEqual('10 mail.unit.tests.', a.values[0].rr_text) self.assertEqual('mail.unit.tests.', a.values[0].exchange) + self.assertEqual('10 mail.unit.tests.', a.values[0].rr_text) a = MxRecord( zone, 'mx', @@ -945,6 +945,75 @@ class TestRecord(TestCase): o.replacement = '1' self.assertEqual('1', o.replacement) + def test_naptr_rr_text(self): + # things with the wrong number of words won't parse + for v in ( + '', + 'one', + 'one two', + 'one two three', + 'one two three four', + 'one two three four five', + 'one two three four five six seven', + ): + with self.assertRaises(RrParseError): + NaptrValue.parse_rr_text(v) + + # we don't care if the types of things are correct when parsing rr text + self.assertEqual( + { + 'order': 'one', + 'preference': 'two', + 'flags': 'three', + 'service': 'four', + 'regexp': 'five', + 'replacement': 'six', + }, + NaptrValue.parse_rr_text('one two three four five six'), + ) + + # order and preference will be converted to int's when possible + self.assertEqual( + { + 'order': 1, + 'preference': 2, + 'flags': 'three', + 'service': 'four', + 'regexp': 'five', + 'replacement': 'six', + }, + NaptrValue.parse_rr_text('1 2 three four five six'), + ) + + # make sure that validate is using parse_rr_text when passed string + # value(s) + reasons = NaptrRecord.validate( + 'naptr', 'naptr.unit.tests.', {'ttl': 32, 'value': ''} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = NaptrRecord.validate( + 'naptr', 'naptr.unit.tests.', {'ttl': 32, 'value': ['']} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = NaptrRecord.validate( + 'naptr', + 'naptr.unit.tests.', + {'ttl': 32, 'value': ['1 2 S service regexp replacement']}, + ) + self.assertFalse(reasons) + + # make sure that the cstor is using parse_rr_text + zone = Zone('unit.tests.', []) + s = '1 2 S service regexp replacement' + a = NaptrRecord(zone, 'naptr', {'ttl': 32, 'value': s}) + self.assertEqual(1, a.values[0].order) + self.assertEqual(2, a.values[0].preference) + self.assertEqual('S', a.values[0].flags) + self.assertEqual('service', a.values[0].service) + self.assertEqual('regexp', a.values[0].regexp) + self.assertEqual('replacement', a.values[0].replacement) + self.assertEqual(s, a.values[0].rr_text) + 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} From e33cf55e7df4a7c1e8767b2f15f0e846f14917d1 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Thu, 8 Sep 2022 09:24:38 -0700 Subject: [PATCH 03/12] Another round of Record rr_text handling --- octodns/record/__init__.py | 239 ++++++++++++++++++++++++++++- tests/test_octodns_record.py | 282 +++++++++++++++++++++++++++++++++-- 2 files changed, 505 insertions(+), 16 deletions(-) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 86e2ef5..b2f07be 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -74,7 +74,8 @@ class RecordException(Exception): class RrParseError(RecordException): - pass + def __init__(self, message='failed to parse string value as RR text'): + super().__init__(message) class ValidationError(RecordException): @@ -775,8 +776,13 @@ class _DynamicMixin(object): class _TargetValue(str): + @classmethod + def parse_rr_text(self, value): + return value + @classmethod def validate(cls, data, _type): + # no need to call parse_rr_text since it's a noop reasons = [] if data == '': reasons.append('empty value') @@ -792,10 +798,15 @@ class _TargetValue(str): @classmethod def process(cls, value): + # no need to call parse_rr_text since it's a noop if value: return cls(value.lower()) return None + @property + def rr_text(self): + return self + class CnameValue(_TargetValue): pass @@ -806,6 +817,10 @@ class DnameValue(_TargetValue): class _IpAddress(str): + @classmethod + def parse_rr_text(cls, value): + return value + @classmethod def validate(cls, data, _type): if not isinstance(data, (list, tuple)): @@ -814,6 +829,7 @@ class _IpAddress(str): return ['missing value(s)'] reasons = [] for value in data: + # no need to call parse_rr_text here as it's a noop if value == '': reasons.append('empty value') elif value is None: @@ -836,9 +852,14 @@ class _IpAddress(str): return [cls(v) if v != '' else '' for v in values] def __new__(cls, v): + # no need to call parse_rr_text here as it's a noop v = str(cls._address_type(v)) return super().__new__(cls, v) + @property + def rr_text(self): + return self + class Ipv4Address(_IpAddress): _address_type = _IPv4Address @@ -889,12 +910,32 @@ Record.register_type(AliasRecord) class CaaValue(EqualityTupleMixin, dict): # https://tools.ietf.org/html/rfc6844#page-5 + @classmethod + def parse_rr_text(cls, value): + try: + flags, tag, value = value.split(' ') + except ValueError: + raise RrParseError() + try: + flags = int(flags) + except ValueError: + pass + return {'flags': flags, 'tag': tag, 'value': value} + @classmethod def validate(cls, data, _type): if not isinstance(data, (list, tuple)): data = (data,) reasons = [] for value in data: + if isinstance(value, str): + # it's hopefully RR formatted, give parsing a try + try: + value = cls.parse_rr_text(value) + except RrParseError as e: + reasons.append(str(e)) + # not a dict so no point in continuing + continue try: flags = int(value.get('flags', 0)) if flags < 0 or flags > 255: @@ -913,6 +954,8 @@ class CaaValue(EqualityTupleMixin, dict): return [cls(v) for v in values] def __init__(self, value): + if isinstance(value, str): + value = self.parse_rr_text(value) super().__init__( { 'flags': int(value.get('flags', 0)), @@ -949,6 +992,10 @@ class CaaValue(EqualityTupleMixin, dict): def data(self): return self + @property + def rr_text(self): + return f'{self.flags} {self.tag} {self.value}' + def _equality_tuple(self): return (self.flags, self.tag, self.value) @@ -989,7 +1036,85 @@ Record.register_type(DnameRecord) class LocValue(EqualityTupleMixin, dict): - # TODO: work out how to do defaults per RFC + # TODO: this does not really match the RFC, but it's stuck using the details + # of how the type was impelemented. Would be nice to rework things to match + # while maintaining backwards compatibility. + # https://www.rfc-editor.org/rfc/rfc1876.html + + @classmethod + def parse_rr_text(cls, value): + try: + value = value.replace('m', '') + ( + lat_degrees, + lat_minutes, + lat_seconds, + lat_direction, + long_degrees, + long_minutes, + long_seconds, + long_direction, + altitude, + size, + precision_horz, + precision_vert, + ) = value.split(' ') + except ValueError: + raise RrParseError() + try: + lat_degrees = int(lat_degrees) + except ValueError: + pass + try: + lat_minutes = int(lat_minutes) + except ValueError: + pass + try: + long_degrees = int(long_degrees) + except ValueError: + pass + try: + long_minutes = int(long_minutes) + except ValueError: + pass + try: + lat_seconds = float(lat_seconds) + except ValueError: + pass + try: + long_seconds = float(long_seconds) + except ValueError: + pass + try: + altitude = float(altitude) + except ValueError: + pass + try: + size = float(size) + except ValueError: + pass + try: + precision_horz = float(precision_horz) + except ValueError: + pass + try: + precision_vert = float(precision_vert) + except ValueError: + pass + return { + 'lat_degrees': lat_degrees, + 'lat_minutes': lat_minutes, + 'lat_seconds': lat_seconds, + 'lat_direction': lat_direction, + 'long_degrees': long_degrees, + 'long_minutes': long_minutes, + 'long_seconds': long_seconds, + 'long_direction': long_direction, + 'altitude': altitude, + 'size': size, + 'precision_horz': precision_horz, + 'precision_vert': precision_vert, + } @classmethod def validate(cls, data, _type): @@ -1015,6 +1140,14 @@ class LocValue(EqualityTupleMixin, dict): data = (data,) reasons = [] for value in data: + if isinstance(value, str): + # it's hopefully RR formatted, give parsing a try + try: + value = cls.parse_rr_text(value) + except RrParseError as e: + reasons.append(str(e)) + # not a dict so no point in continuing + continue for key in int_keys: try: int(value[key]) @@ -1087,6 +1220,8 @@ class LocValue(EqualityTupleMixin, dict): return [cls(v) for v in values] def __init__(self, value): + if isinstance(value, str): + value = self.parse_rr_text(value) super().__init__( { 'lat_degrees': int(value['lat_degrees']), @@ -1204,6 +1339,10 @@ class LocValue(EqualityTupleMixin, dict): def data(self): return self + @property + def rr_text(self): + return f'{self.lat_degrees} {self.lat_minutes} {self.lat_seconds} {self.lat_direction} {self.long_degrees} {self.long_minutes} {self.long_seconds} {self.long_direction} {self.altitude}m {self.size}m {self.precision_horz}m {self.precision_vert}m' + def __hash__(self): return hash( ( @@ -1259,11 +1398,11 @@ Record.register_type(LocRecord) class MxValue(EqualityTupleMixin, dict): @classmethod - def parse_rr_text(self, value): + def parse_rr_text(cls, value): try: preference, exchange = value.split(' ') except ValueError: - raise RrParseError('failed to parse string value as RR text') + raise RrParseError() try: preference = int(preference) except ValueError: @@ -1377,7 +1516,7 @@ class NaptrValue(EqualityTupleMixin, dict): VALID_FLAGS = ('S', 'A', 'U', 'P') @classmethod - def parse_rr_text(self, value): + def parse_rr_text(cls, value): try: ( order, @@ -1388,7 +1527,7 @@ class NaptrValue(EqualityTupleMixin, dict): replacement, ) = value.split(' ') except ValueError: - raise RrParseError('failed to parse string value as RR text') + raise RrParseError() try: order = int(order) preference = int(preference) @@ -1549,6 +1688,10 @@ Record.register_type(NaptrRecord) class _NsValue(str): + @classmethod + def parse_rr_text(cls, value): + return value + @classmethod def validate(cls, data, _type): if not data: @@ -1557,6 +1700,7 @@ class _NsValue(str): data = (data,) reasons = [] for value in data: + # no need to consider parse_rr_text as it's a noop if not FQDN(str(value), allow_underscores=True).is_valid: reasons.append( f'Invalid NS value "{value}" is not a valid FQDN.' @@ -1569,6 +1713,10 @@ class _NsValue(str): def process(cls, values): return [cls(v) for v in values] + @property + def rr_text(self): + return self + class NsRecord(ValuesMixin, Record): _type = 'NS' @@ -1590,7 +1738,7 @@ class PtrValue(_TargetValue): reasons.append('missing values') for value in values: - reasons.extend(super(PtrValue, cls).validate(value, _type)) + reasons.extend(super().validate(value, _type)) return reasons @@ -1618,12 +1766,40 @@ class SshfpValue(EqualityTupleMixin, dict): VALID_ALGORITHMS = (1, 2, 3, 4) VALID_FINGERPRINT_TYPES = (1, 2) + @classmethod + def parse_rr_text(self, value): + try: + algorithm, fingerprint_type, fingerprint = value.split(' ') + except ValueError: + raise RrParseError() + try: + algorithm = int(algorithm) + except ValueError: + pass + try: + fingerprint_type = int(fingerprint_type) + except ValueError: + pass + return { + 'algorithm': algorithm, + 'fingerprint_type': fingerprint_type, + 'fingerprint': fingerprint, + } + @classmethod def validate(cls, data, _type): if not isinstance(data, (list, tuple)): data = (data,) reasons = [] for value in data: + if isinstance(value, str): + # it's hopefully RR formatted, give parsing a try + try: + value = cls.parse_rr_text(value) + except RrParseError as e: + reasons.append(str(e)) + # not a dict so no point in continuing + continue try: algorithm = int(value['algorithm']) if algorithm not in cls.VALID_ALGORITHMS: @@ -1653,6 +1829,8 @@ class SshfpValue(EqualityTupleMixin, dict): return [cls(v) for v in values] def __init__(self, value): + if isinstance(value, str): + value = self.parse_rr_text(value) super().__init__( { 'algorithm': int(value['algorithm']), @@ -1689,6 +1867,10 @@ class SshfpValue(EqualityTupleMixin, dict): def data(self): return self + @property + def rr_text(self): + return f'{self.algorithm} {self.fingerprint_type} {self.fingerprint}' + def __hash__(self): return hash(self.__repr__()) @@ -1731,6 +1913,10 @@ class _ChunkedValuesMixin(ValuesMixin): class _ChunkedValue(str): _unescaped_semicolon_re = re.compile(r'\w;') + @classmethod + def parse_rr_text(cls, value): + return value + @classmethod def validate(cls, data, _type): if not data: @@ -1739,6 +1925,7 @@ class _ChunkedValue(str): data = (data,) reasons = [] for value in data: + # no need to try parse_rr_text here as it's a noop if cls._unescaped_semicolon_re.search(value): reasons.append(f'unescaped ; in "{value}"') return reasons @@ -1752,6 +1939,10 @@ class _ChunkedValue(str): ret.append(cls(v.replace('" "', ''))) return ret + @property + def rr_text(self): + return self + class SpfRecord(_ChunkedValuesMixin, Record): _type = 'SPF' @@ -1762,6 +1953,18 @@ Record.register_type(SpfRecord) class SrvValue(EqualityTupleMixin, dict): + @classmethod + def preprocess_value(self, value): + if isinstance(value, str): + priority, weight, port, target = value.split(' ', 3) + return { + 'priority': priority, + 'weight': weight, + 'port': port, + 'target': target, + } + return value + @classmethod def validate(cls, data, _type): if not isinstance(data, (list, tuple)): @@ -1880,6 +2083,23 @@ Record.register_type(SrvRecord) class TlsaValue(EqualityTupleMixin, dict): + @classmethod + def preprocess_value(self, value): + if isinstance(value, str): + ( + certificate_usage, + certificate_association_data, + matching_type, + certificate_association_data, + ) = value.split(' ', 3) + return { + 'certificate_usage': certificate_usage, + 'certificate_association_data': certificate_association_data, + 'matching_type': matching_type, + 'certificate_association_data': certificate_association_data, + } + return value + @classmethod def validate(cls, data, _type): if not isinstance(data, (list, tuple)): @@ -2012,6 +2232,11 @@ class UrlfwdValue(EqualityTupleMixin, dict): VALID_MASKS = (0, 1, 2) VALID_QUERY = (0, 1) + @classmethod + def preprocess_value(self, value): + # TODO: + return value + @classmethod def validate(cls, data, _type): if not isinstance(data, (list, tuple)): diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 1fc9db6..e65e031 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -22,6 +22,7 @@ from octodns.record import ( Create, Delete, GeoValue, + Ipv4Address, LocRecord, LocValue, MxRecord, @@ -45,11 +46,12 @@ from octodns.record import ( UrlfwdRecord, UrlfwdValue, ValidationError, + ValuesMixin, + _ChunkedValue, _Dynamic, _DynamicPool, _DynamicRule, _NsValue, - ValuesMixin, ) from octodns.zone import Zone @@ -212,6 +214,29 @@ class TestRecord(TestCase): DummyRecord().__repr__() + def test_ip_address_rr_text(self): + + # anything goes, we're a noop + for s in ( + None, + '', + 'word', + 42, + 42.43, + '1.2.3', + 'some.words.that.here', + '1.2.word.4', + '1.2.3.4', + ): + self.assertEqual(s, Ipv4Address.parse_rr_text(s)) + + # since we're a noop there's no need/way to check whether validate or + # __init__ call parse_rr_text + + zone = Zone('unit.tests.', []) + a = ARecord(zone, 'a', {'ttl': 42, 'value': '1.2.3.4'}) + self.assertEqual('1.2.3.4', a.values[0].rr_text) + def test_values_mixin_data(self): # no values, no value or values in data a = ARecord(self.zone, '', {'type': 'A', 'ttl': 600, 'values': []}) @@ -380,6 +405,29 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() + def test_target_rr_text(self): + + # anything goes, we're a noop + for s in ( + None, + '', + 'word', + 42, + 42.43, + '1.2.3', + 'some.words.that.here', + '1.2.word.4', + '1.2.3.4', + ): + self.assertEqual(s, Ipv4Address.parse_rr_text(s)) + + # since we're a noop there's no need/way to check whether validate or + # __init__ call parse_rr_text + + zone = Zone('unit.tests.', []) + a = AliasRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) + self.assertEqual('some.target.', a.value.rr_text) + def test_caa(self): a_values = [ CaaValue({'flags': 0, 'tag': 'issue', 'value': 'ca.example.net'}), @@ -440,6 +488,71 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() + def test_caa_value_rr_text(self): + # empty string won't parse + with self.assertRaises(RrParseError): + CaaValue.parse_rr_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + CaaValue.parse_rr_text('nope') + + # 2nd word won't parse + with self.assertRaises(RrParseError): + CaaValue.parse_rr_text('0 tag') + + # 4th word won't parse + with self.assertRaises(RrParseError): + CaaValue.parse_rr_text('1 tag value another') + + # flags not an int, will parse + self.assertEqual( + {'flags': 'one', 'tag': 'tag', 'value': 'value'}, + CaaValue.parse_rr_text('one tag value'), + ) + + # valid + self.assertEqual( + {'flags': 0, 'tag': 'tag', 'value': '99148c81'}, + CaaValue.parse_rr_text('0 tag 99148c81'), + ) + + # make sure that validate is using parse_rr_text when passed string + # value(s) + reasons = CaaRecord.validate( + 'caa', 'caa.unit.tests.', {'ttl': 32, 'value': ''} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = CaaRecord.validate( + 'caa', 'caa.unit.tests.', {'ttl': 32, 'values': ['nope']} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = CaaRecord.validate( + 'caa', 'caa.unit.tests.', {'ttl': 32, 'value': '0 tag 99148c81'} + ) + self.assertFalse(reasons) + + # make sure that the cstor is using parse_rr_text + zone = Zone('unit.tests.', []) + a = CaaRecord(zone, 'caa', {'ttl': 32, 'value': '0 tag 99148c81'}) + self.assertEqual(0, a.values[0].flags) + self.assertEqual('tag', a.values[0].tag) + self.assertEqual('99148c81', a.values[0].value) + self.assertEqual('0 tag 99148c81', a.values[0].rr_text) + a = CaaRecord( + zone, + 'caa', + {'ttl': 32, 'values': ['1 tag1 99148c81', '2 tag2 99148c44']}, + ) + self.assertEqual(1, a.values[0].flags) + self.assertEqual('tag1', a.values[0].tag) + self.assertEqual('99148c81', a.values[0].value) + self.assertEqual('1 tag1 99148c81', a.values[0].rr_text) + self.assertEqual(2, a.values[1].flags) + self.assertEqual('tag2', a.values[1].tag) + self.assertEqual('99148c44', a.values[1].value) + self.assertEqual('2 tag2 99148c44', a.values[1].rr_text) + def test_cname(self): self.assertSingleValue(CnameRecord, 'target.foo.com.', 'other.foo.com.') @@ -542,6 +655,60 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() + def test_loc_value_rr_text(self): + # only the exact correct number of words + for i in tuple(range(0, 12)) + (13,): + s = ''.join(['word'] * i) + with self.assertRaises(RrParseError): + LocValue.parse_rr_text(s) + + # valid + s = '0 1 2.2 N 3 4 5.5 E 6.6m 7.7m 8.8m 9.9m' + self.assertEqual( + { + 'altitude': 6.6, + 'lat_degrees': 0, + 'lat_direction': 'N', + 'lat_minutes': 1, + 'lat_seconds': 2.2, + 'long_degrees': 3, + 'long_direction': 'E', + 'long_minutes': 4, + 'long_seconds': 5.5, + 'precision_horz': 8.8, + 'precision_vert': 9.9, + 'size': 7.7, + }, + LocValue.parse_rr_text(s), + ) + + # make sure validate is using parse_rr_text when passed string values + reasons = LocRecord.validate( + 'loc', 'loc.unit.tests', {'ttl': 42, 'value': ''} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = LocRecord.validate( + 'loc', 'loc.unit.tests', {'ttl': 42, 'value': s} + ) + self.assertFalse(reasons) + + # make sure that the cstor is using parse_rr_text + zone = Zone('unit.tests.', []) + a = LocRecord(zone, 'mx', {'ttl': 32, 'value': s}) + self.assertEqual(0, a.values[0].lat_degrees) + self.assertEqual(1, a.values[0].lat_minutes) + self.assertEqual(2.2, a.values[0].lat_seconds) + self.assertEqual('N', a.values[0].lat_direction) + self.assertEqual(3, a.values[0].long_degrees) + self.assertEqual(4, a.values[0].long_minutes) + self.assertEqual(5.5, a.values[0].long_seconds) + self.assertEqual('E', a.values[0].long_direction) + self.assertEqual(6.6, a.values[0].altitude) + self.assertEqual(7.7, a.values[0].size) + self.assertEqual(8.8, a.values[0].precision_horz) + self.assertEqual(9.9, a.values[0].precision_vert) + self.assertEqual(s, a.values[0].rr_text) + def test_mx(self): a_values = [ MxValue({'preference': 10, 'exchange': 'smtp1.'}), @@ -593,7 +760,7 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() - def test_mx_rr_text(self): + def test_mx_value_rr_text(self): # empty string won't parse with self.assertRaises(RrParseError): @@ -607,18 +774,18 @@ class TestRecord(TestCase): with self.assertRaises(RrParseError): MxValue.parse_rr_text('10 mx.unit.tests. another') - # preference not an int, will parse - self.assertEqual( - {'preference': 10, 'exchange': 'mx.unit.tests.'}, - MxValue.parse_rr_text('10 mx.unit.tests.'), - ) - # preference not an int self.assertEqual( {'preference': 'abc', 'exchange': 'mx.unit.tests.'}, MxValue.parse_rr_text('abc mx.unit.tests.'), ) + # valid + self.assertEqual( + {'preference': 10, 'exchange': 'mx.unit.tests.'}, + MxValue.parse_rr_text('10 mx.unit.tests.'), + ) + # make sure that validate is using parse_rr_text when passed string # value(s) reasons = MxRecord.validate( @@ -945,7 +1112,7 @@ class TestRecord(TestCase): o.replacement = '1' self.assertEqual('1', o.replacement) - def test_naptr_rr_text(self): + def test_naptr_value_rr_text(self): # things with the wrong number of words won't parse for v in ( '', @@ -1030,6 +1197,28 @@ class TestRecord(TestCase): self.assertEqual([b_value], b.values) self.assertEqual(b_data, b.data) + def test_ns_value_rr_text(self): + # anything goes, we're a noop + for s in ( + None, + '', + 'word', + 42, + 42.43, + '1.2.3', + 'some.words.that.here', + '1.2.word.4', + '1.2.3.4', + ): + self.assertEqual(s, _NsValue.parse_rr_text(s)) + + # since we're a noop there's no need/way to check whether validate or + # __init__ call parse_rr_text + + zone = Zone('unit.tests.', []) + a = NsRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) + self.assertEqual('some.target.', a.values[0].rr_text) + def test_sshfp(self): a_values = [ SshfpValue( @@ -1098,11 +1287,86 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() + def test_sshfp_value_rr_text(self): + + # empty string won't parse + with self.assertRaises(RrParseError): + SshfpValue.parse_rr_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + SshfpValue.parse_rr_text('nope') + + # 3rd word won't parse + with self.assertRaises(RrParseError): + SshfpValue.parse_rr_text('0 1 00479b27 another') + + # algorithm and fingerprint_type not ints + self.assertEqual( + { + 'algorithm': 'one', + 'fingerprint_type': 'two', + 'fingerprint': '00479b27', + }, + SshfpValue.parse_rr_text('one two 00479b27'), + ) + + # valid + self.assertEqual( + {'algorithm': 1, 'fingerprint_type': 2, 'fingerprint': '00479b27'}, + SshfpValue.parse_rr_text('1 2 00479b27'), + ) + + # make sure that validate is using parse_rr_text when passed string + # value(s) + reasons = SshfpRecord.validate( + 'sshfp', 'sshfp.unit.tests.', {'ttl': 32, 'value': ''} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = SshfpRecord.validate( + 'sshfp', 'sshfp.unit.tests.', {'ttl': 32, 'values': ['nope']} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = SshfpRecord.validate( + 'sshfp', 'sshfp.unit.tests.', {'ttl': 32, 'value': '1 2 00479b27'} + ) + self.assertFalse(reasons) + + # make sure that the cstor is using parse_rr_text + zone = Zone('unit.tests.', []) + a = SshfpRecord(zone, 'sshfp', {'ttl': 32, 'value': '1 2 00479b27'}) + self.assertEqual(1, a.values[0].algorithm) + self.assertEqual(2, a.values[0].fingerprint_type) + self.assertEqual('00479b27', a.values[0].fingerprint) + self.assertEqual('1 2 00479b27', a.values[0].rr_text) + def test_spf(self): a_values = ['spf1 -all', 'spf1 -hrm'] b_value = 'spf1 -other' self.assertMultipleValues(SpfRecord, a_values, b_value) + def test_chunked_value_rr_text(self): + # anything goes, we're a noop + for s in ( + None, + '', + 'word', + 42, + 42.43, + '1.2.3', + 'some.words.that.here', + '1.2.word.4', + '1.2.3.4', + ): + self.assertEqual(s, _ChunkedValue.parse_rr_text(s)) + + # since we're a noop there's no need/way to check whether validate or + # __init__ call parse_rr_text + + zone = Zone('unit.tests.', []) + a = SpfRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) + self.assertEqual('some.target.', a.values[0].rr_text) + def test_srv(self): a_values = [ SrvValue( From e6944ff5ae0682a2af2cecd2dacf0474a11fe2c2 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Thu, 8 Sep 2022 10:37:26 -0700 Subject: [PATCH 04/12] finish up rr text Record coverage (hopefully) --- octodns/record/__init__.py | 135 +++++++++++++++---- tests/test_octodns_record.py | 242 +++++++++++++++++++++++++++++++++-- 2 files changed, 341 insertions(+), 36 deletions(-) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index b2f07be..6091398 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -1954,16 +1954,29 @@ Record.register_type(SpfRecord) class SrvValue(EqualityTupleMixin, dict): @classmethod - def preprocess_value(self, value): - if isinstance(value, str): - priority, weight, port, target = value.split(' ', 3) - return { - 'priority': priority, - 'weight': weight, - 'port': port, - 'target': target, - } - return value + def parse_rr_text(self, value): + try: + priority, weight, port, target = value.split(' ') + except ValueError: + raise RrParseError() + try: + priority = int(priority) + except ValueError: + pass + try: + weight = int(weight) + except ValueError: + pass + try: + port = int(port) + except ValueError: + pass + return { + 'priority': priority, + 'weight': weight, + 'port': port, + 'target': target, + } @classmethod def validate(cls, data, _type): @@ -1971,6 +1984,14 @@ class SrvValue(EqualityTupleMixin, dict): data = (data,) reasons = [] for value in data: + if isinstance(value, str): + # it's hopefully RR formatted, give parsing a try + try: + value = cls.parse_rr_text(value) + except RrParseError as e: + reasons.append(str(e)) + # not a dict so no point in continuing + continue # TODO: validate algorithm and fingerprint_type values try: int(value['priority']) @@ -2010,6 +2031,8 @@ class SrvValue(EqualityTupleMixin, dict): return [cls(v) for v in values] def __init__(self, value): + if isinstance(value, str): + value = self.parse_rr_text(value) super().__init__( { 'priority': int(value['priority']), @@ -2084,21 +2107,34 @@ Record.register_type(SrvRecord) class TlsaValue(EqualityTupleMixin, dict): @classmethod - def preprocess_value(self, value): - if isinstance(value, str): + def parse_rr_text(self, value): + try: ( certificate_usage, - certificate_association_data, + selector, matching_type, certificate_association_data, - ) = value.split(' ', 3) - return { - 'certificate_usage': certificate_usage, - 'certificate_association_data': certificate_association_data, - 'matching_type': matching_type, - 'certificate_association_data': certificate_association_data, - } - return value + ) = value.split(' ') + except ValueError: + raise RrParseError() + try: + certificate_usage = int(certificate_usage) + except ValueError: + pass + try: + selector = int(selector) + except ValueError: + pass + try: + matching_type = int(matching_type) + except ValueError: + pass + return { + 'certificate_usage': certificate_usage, + 'selector': selector, + 'matching_type': matching_type, + 'certificate_association_data': certificate_association_data, + } @classmethod def validate(cls, data, _type): @@ -2106,6 +2142,14 @@ class TlsaValue(EqualityTupleMixin, dict): data = (data,) reasons = [] for value in data: + if isinstance(value, str): + # it's hopefully RR formatted, give parsing a try + try: + value = cls.parse_rr_text(value) + except RrParseError as e: + reasons.append(str(e)) + # not a dict so no point in continuing + continue try: certificate_usage = int(value.get('certificate_usage', 0)) if certificate_usage < 0 or certificate_usage > 3: @@ -2149,6 +2193,8 @@ class TlsaValue(EqualityTupleMixin, dict): return [cls(v) for v in values] def __init__(self, value): + if isinstance(value, str): + value = self.parse_rr_text(value) super().__init__( { 'certificate_usage': int(value.get('certificate_usage', 0)), @@ -2192,6 +2238,10 @@ class TlsaValue(EqualityTupleMixin, dict): def certificate_association_data(self, value): self['certificate_association_data'] = value + @property + def rr_text(self): + return f'{self.certificate_usage} {self.selector} {self.matching_type} {self.certificate_association_data}' + def _equality_tuple(self): return ( self.certificate_usage, @@ -2233,9 +2283,30 @@ class UrlfwdValue(EqualityTupleMixin, dict): VALID_QUERY = (0, 1) @classmethod - def preprocess_value(self, value): - # TODO: - return value + def parse_rr_text(self, value): + try: + code, masking, query, path, target = value.split(' ') + except ValueError: + raise RrParseError() + try: + code = int(code) + except ValueError: + pass + try: + masking = int(masking) + except ValueError: + pass + try: + query = int(query) + except ValueError: + pass + return { + 'code': code, + 'masking': masking, + 'query': query, + 'path': path, + 'target': target, + } @classmethod def validate(cls, data, _type): @@ -2243,6 +2314,14 @@ class UrlfwdValue(EqualityTupleMixin, dict): data = (data,) reasons = [] for value in data: + if isinstance(value, str): + # it's hopefully RR formatted, give parsing a try + try: + value = cls.parse_rr_text(value) + except RrParseError as e: + reasons.append(str(e)) + # not a dict so no point in continuing + continue try: code = int(value['code']) if code not in cls.VALID_CODES: @@ -2277,6 +2356,8 @@ class UrlfwdValue(EqualityTupleMixin, dict): return [UrlfwdValue(v) for v in values] def __init__(self, value): + if isinstance(value, str): + value = self.parse_rr_text(value) super().__init__( { 'path': value['path'], @@ -2327,6 +2408,12 @@ class UrlfwdValue(EqualityTupleMixin, dict): def query(self, value): self['query'] = value + @property + def rr_text(self): + return ( + f'{self.code} {self.masking} {self.query} {self.path} {self.target}' + ) + def _equality_tuple(self): return (self.path, self.target, self.code, self.masking, self.query) diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index e65e031..01eea90 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -52,6 +52,7 @@ from octodns.record import ( _DynamicPool, _DynamicRule, _NsValue, + _TargetValue, ) from octodns.zone import Zone @@ -419,7 +420,7 @@ class TestRecord(TestCase): '1.2.word.4', '1.2.3.4', ): - self.assertEqual(s, Ipv4Address.parse_rr_text(s)) + self.assertEqual(s, _TargetValue.parse_rr_text(s)) # since we're a noop there's no need/way to check whether validate or # __init__ call parse_rr_text @@ -656,12 +657,33 @@ class TestRecord(TestCase): a.__repr__() def test_loc_value_rr_text(self): - # only the exact correct number of words + # only the exact correct number of words is allowed for i in tuple(range(0, 12)) + (13,): s = ''.join(['word'] * i) with self.assertRaises(RrParseError): LocValue.parse_rr_text(s) + # type conversions are best effort + self.assertEqual( + { + 'altitude': 'six', + 'lat_degrees': 'zero', + 'lat_direction': 'S', + 'lat_minutes': 'one', + 'lat_seconds': 'two', + 'long_degrees': 'three', + 'long_direction': 'W', + 'long_minutes': 'four', + 'long_seconds': 'five', + 'precision_horz': 'eight', + 'precision_vert': 'nine', + 'size': 'seven', + }, + LocValue.parse_rr_text( + 'zero one two S three four five W six seven eight nine' + ), + ) + # valid s = '0 1 2.2 N 3 4 5.5 E 6.6m 7.7m 8.8m 9.9m' self.assertEqual( @@ -1385,16 +1407,6 @@ class TestRecord(TestCase): self.assertEqual(a_values[0]['weight'], a.values[0].weight) self.assertEqual(a_values[0]['port'], a.values[0].port) self.assertEqual(a_values[0]['target'], a.values[0].target) - from pprint import pprint - - pprint( - { - 'a_values': a_values, - 'self': a_data, - 'other': a.data, - 'a.values': a.values, - } - ) self.assertEqual(a_data, a.data) b_value = SrvValue( @@ -1441,6 +1453,73 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() + def test_srv_value_rr_text(self): + + # empty string won't parse + with self.assertRaises(RrParseError): + SrvValue.parse_rr_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + SrvValue.parse_rr_text('nope') + + # 2nd word won't parse + with self.assertRaises(RrParseError): + SrvValue.parse_rr_text('1 2') + + # 3rd word won't parse + with self.assertRaises(RrParseError): + SrvValue.parse_rr_text('1 2 3') + + # 5th word won't parse + with self.assertRaises(RrParseError): + SrvValue.parse_rr_text('1 2 3 4 5') + + # priority weight and port not ints + self.assertEqual( + { + 'priority': 'one', + 'weight': 'two', + 'port': 'three', + 'target': 'srv.unit.tests.', + }, + SrvValue.parse_rr_text('one two three srv.unit.tests.'), + ) + + # valid + self.assertEqual( + { + 'priority': 1, + 'weight': 2, + 'port': 3, + 'target': 'srv.unit.tests.', + }, + SrvValue.parse_rr_text('1 2 3 srv.unit.tests.'), + ) + + # make sure that validate is using parse_rr_text when passed string + # value(s) + reasons = SrvRecord.validate( + '_srv._tcp', '_srv._tcp.unit.tests.', {'ttl': 32, 'value': ''} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = SrvRecord.validate( + '_srv._tcp', + '_srv._tcp.unit.tests.', + {'ttl': 32, 'value': '1 2 3 srv.unit.tests.'}, + ) + self.assertFalse(reasons) + + # make sure that the cstor is using parse_rr_text + zone = Zone('unit.tests.', []) + a = SrvRecord( + zone, '_srv._tcp', {'ttl': 32, 'value': '1 2 3 srv.unit.tests.'} + ) + self.assertEqual(1, a.values[0].priority) + self.assertEqual(2, a.values[0].weight) + self.assertEqual(3, a.values[0].port) + self.assertEqual('srv.unit.tests.', a.values[0].target) + def test_tlsa(self): a_values = [ TlsaValue( @@ -1542,6 +1621,70 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() + def test_tsla_value_rr_text(self): + + # empty string won't parse + with self.assertRaises(RrParseError): + TlsaValue.parse_rr_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + TlsaValue.parse_rr_text('nope') + + # 2nd word won't parse + with self.assertRaises(RrParseError): + TlsaValue.parse_rr_text('1 2') + + # 3rd word won't parse + with self.assertRaises(RrParseError): + TlsaValue.parse_rr_text('1 2 3') + + # 5th word won't parse + with self.assertRaises(RrParseError): + TlsaValue.parse_rr_text('1 2 3 abcd another') + + # non-ints + self.assertEqual( + { + 'certificate_usage': 'one', + 'selector': 'two', + 'matching_type': 'three', + 'certificate_association_data': 'abcd', + }, + TlsaValue.parse_rr_text('one two three abcd'), + ) + + # valid + self.assertEqual( + { + 'certificate_usage': 1, + 'selector': 2, + 'matching_type': 3, + 'certificate_association_data': 'abcd', + }, + TlsaValue.parse_rr_text('1 2 3 abcd'), + ) + + # make sure that validate is using parse_rr_text when passed string + # value(s) + reasons = TlsaRecord.validate( + 'tlsa', 'tlsa.unit.tests.', {'ttl': 32, 'value': ''} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = TlsaRecord.validate( + 'tlsa', 'tlsa.unit.tests.', {'ttl': 32, 'value': '2 1 0 abcd'} + ) + self.assertFalse(reasons) + + # make sure that the cstor is using parse_rr_text + zone = Zone('unit.tests.', []) + a = TlsaRecord(zone, 'tlsa', {'ttl': 32, 'value': '2 1 0 abcd'}) + self.assertEqual(2, a.values[0].certificate_usage) + self.assertEqual(1, a.values[0].selector) + self.assertEqual(0, a.values[0].matching_type) + self.assertEqual('abcd', a.values[0].certificate_association_data) + self.assertEqual('2 1 0 abcd', a.values[0].rr_text) + def test_txt(self): a_values = ['a one', 'a two'] b_value = 'b other' @@ -1666,6 +1809,81 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() + def test_urlfwd_value_rr_text(self): + + # empty string won't parse + with self.assertRaises(RrParseError): + UrlfwdValue.parse_rr_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + UrlfwdValue.parse_rr_text('nope') + + # 2nd word won't parse + with self.assertRaises(RrParseError): + UrlfwdValue.parse_rr_text('one two') + + # 3rd word won't parse + with self.assertRaises(RrParseError): + UrlfwdValue.parse_rr_text('one two three') + + # 4th word won't parse + with self.assertRaises(RrParseError): + UrlfwdValue.parse_rr_text('one two three four') + + # 6th word won't parse + with self.assertRaises(RrParseError): + UrlfwdValue.parse_rr_text('one two three four five size') + + # non-ints + self.assertEqual( + { + 'code': 'one', + 'masking': 'two', + 'query': 'three', + 'path': 'four', + 'target': 'five', + }, + UrlfwdValue.parse_rr_text('one two three four five'), + ) + + # valid + self.assertEqual( + { + 'code': 301, + 'masking': 0, + 'query': 1, + 'path': 'four', + 'target': 'five', + }, + UrlfwdValue.parse_rr_text('301 0 1 four five'), + ) + + # make sure that validate is using parse_rr_text when passed string + # value(s) + reasons = UrlfwdRecord.validate( + 'urlfwd', 'urlfwd.unit.tests.', {'ttl': 32, 'value': ''} + ) + self.assertEqual(['failed to parse string value as RR text'], reasons) + reasons = UrlfwdRecord.validate( + 'urlfwd', + 'urlfwd.unit.tests.', + {'ttl': 32, 'value': '301 0 1 four five'}, + ) + self.assertFalse(reasons) + + # make sure that the cstor is using parse_rr_text + zone = Zone('unit.tests.', []) + a = UrlfwdRecord( + zone, 'urlfwd', {'ttl': 32, 'value': '301 0 1 four five'} + ) + self.assertEqual(301, a.values[0].code) + self.assertEqual(0, a.values[0].masking) + self.assertEqual(1, a.values[0].query) + self.assertEqual('four', a.values[0].path) + self.assertEqual('five', a.values[0].target) + self.assertEqual('301 0 1 four five', a.values[0].rr_text) + def test_record_new(self): txt = Record.new( self.zone, 'txt', {'ttl': 44, 'type': 'TXT', 'value': 'some text'} From 1cbcfbf1d35e996477bf2a3714ff04bff517e0cf Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Thu, 8 Sep 2022 10:56:03 -0700 Subject: [PATCH 05/12] Urlfwd isn't a RFC type so it shouldn't have rr text support --- octodns/record/__init__.py | 42 -------------------- tests/test_octodns_record.py | 75 ------------------------------------ 2 files changed, 117 deletions(-) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 6091398..a3b53db 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -2282,46 +2282,12 @@ class UrlfwdValue(EqualityTupleMixin, dict): VALID_MASKS = (0, 1, 2) VALID_QUERY = (0, 1) - @classmethod - def parse_rr_text(self, value): - try: - code, masking, query, path, target = value.split(' ') - except ValueError: - raise RrParseError() - try: - code = int(code) - except ValueError: - pass - try: - masking = int(masking) - except ValueError: - pass - try: - query = int(query) - except ValueError: - pass - return { - 'code': code, - 'masking': masking, - 'query': query, - 'path': path, - 'target': target, - } - @classmethod def validate(cls, data, _type): if not isinstance(data, (list, tuple)): data = (data,) reasons = [] for value in data: - if isinstance(value, str): - # it's hopefully RR formatted, give parsing a try - try: - value = cls.parse_rr_text(value) - except RrParseError as e: - reasons.append(str(e)) - # not a dict so no point in continuing - continue try: code = int(value['code']) if code not in cls.VALID_CODES: @@ -2356,8 +2322,6 @@ class UrlfwdValue(EqualityTupleMixin, dict): return [UrlfwdValue(v) for v in values] def __init__(self, value): - if isinstance(value, str): - value = self.parse_rr_text(value) super().__init__( { 'path': value['path'], @@ -2408,12 +2372,6 @@ class UrlfwdValue(EqualityTupleMixin, dict): def query(self, value): self['query'] = value - @property - def rr_text(self): - return ( - f'{self.code} {self.masking} {self.query} {self.path} {self.target}' - ) - def _equality_tuple(self): return (self.path, self.target, self.code, self.masking, self.query) diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 01eea90..50b9160 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -1809,81 +1809,6 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() - def test_urlfwd_value_rr_text(self): - - # empty string won't parse - with self.assertRaises(RrParseError): - UrlfwdValue.parse_rr_text('') - - # single word won't parse - with self.assertRaises(RrParseError): - UrlfwdValue.parse_rr_text('nope') - - # 2nd word won't parse - with self.assertRaises(RrParseError): - UrlfwdValue.parse_rr_text('one two') - - # 3rd word won't parse - with self.assertRaises(RrParseError): - UrlfwdValue.parse_rr_text('one two three') - - # 4th word won't parse - with self.assertRaises(RrParseError): - UrlfwdValue.parse_rr_text('one two three four') - - # 6th word won't parse - with self.assertRaises(RrParseError): - UrlfwdValue.parse_rr_text('one two three four five size') - - # non-ints - self.assertEqual( - { - 'code': 'one', - 'masking': 'two', - 'query': 'three', - 'path': 'four', - 'target': 'five', - }, - UrlfwdValue.parse_rr_text('one two three four five'), - ) - - # valid - self.assertEqual( - { - 'code': 301, - 'masking': 0, - 'query': 1, - 'path': 'four', - 'target': 'five', - }, - UrlfwdValue.parse_rr_text('301 0 1 four five'), - ) - - # make sure that validate is using parse_rr_text when passed string - # value(s) - reasons = UrlfwdRecord.validate( - 'urlfwd', 'urlfwd.unit.tests.', {'ttl': 32, 'value': ''} - ) - self.assertEqual(['failed to parse string value as RR text'], reasons) - reasons = UrlfwdRecord.validate( - 'urlfwd', - 'urlfwd.unit.tests.', - {'ttl': 32, 'value': '301 0 1 four five'}, - ) - self.assertFalse(reasons) - - # make sure that the cstor is using parse_rr_text - zone = Zone('unit.tests.', []) - a = UrlfwdRecord( - zone, 'urlfwd', {'ttl': 32, 'value': '301 0 1 four five'} - ) - self.assertEqual(301, a.values[0].code) - self.assertEqual(0, a.values[0].masking) - self.assertEqual(1, a.values[0].query) - self.assertEqual('four', a.values[0].path) - self.assertEqual('five', a.values[0].target) - self.assertEqual('301 0 1 four five', a.values[0].rr_text) - def test_record_new(self): txt = Record.new( self.zone, 'txt', {'ttl': 44, 'type': 'TXT', 'value': 'some text'} From 0228e6822e3271f5d8f8c94c62754e6c686051dd Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Thu, 22 Sep 2022 10:13:19 -0700 Subject: [PATCH 06/12] Implmement Record.from_rrs and plug it together with parse_rdata_text --- octodns/record/__init__.py | 116 ++++++++++++++++++------- octodns/source/axfr.py | 160 ++++------------------------------- tests/test_octodns_record.py | 116 ++++++++++++------------- 3 files changed, 159 insertions(+), 233 deletions(-) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 041aa1e..e7d107f 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -2,6 +2,7 @@ # # +from collections import defaultdict from ipaddress import IPv4Address as _IPv4Address, IPv6Address as _IPv6Address from logging import getLogger import re @@ -84,6 +85,17 @@ class ValidationError(RecordException): self.reasons = reasons +class Rr(object): + def __init__(self, name, _type, ttl, rdata): + self.name = name + self._type = _type + self.ttl = ttl + self.rdata = rdata + + def __repr__(self): + return f'Rr<{self.name}, {self._type}, {self.ttl}, {self.rdata}' + + class Record(EqualityTupleMixin): log = getLogger('Record') @@ -171,6 +183,32 @@ class Record(EqualityTupleMixin): pass return reasons + @classmethod + def from_rrs(cls, zone, rrs, lenient=False): + from pprint import pprint + + pprint({'zone': zone, 'rrs': rrs, 'lenient': lenient}) + + grouped = defaultdict(list) + for rr in rrs: + grouped[(rr.name, rr._type)].append(rr) + + pprint({'grouped': grouped}) + + records = [] + for _, rrs in sorted(grouped.items()): + rr = rrs[0] + name = zone.hostname_from_fqdn(rr.name) + _class = cls._CLASSES[rr._type] + pprint({'rr': rr, 'name': name, 'class': _class}) + data = _class.data_from_rrs(rrs) + pprint({'data': data}) + record = Record.new(zone, name, data, lenient=lenient) + records.append(record) + + pprint({'records': records}) + return records + def __init__(self, zone, name, data, source=None): self.zone = zone if name: @@ -342,6 +380,15 @@ class ValuesMixin(object): return reasons + @classmethod + def data_from_rrs(cls, rrs): + values = [cls._value_type.parse_rdata_text(rr.rdata) for rr in rrs] + from pprint import pprint + + pprint({'values': values}) + rr = rrs[0] + return {'ttl': rr.ttl, 'type': rr._type, 'values': values} + def __init__(self, zone, name, data, source=None): super(ValuesMixin, self).__init__(zone, name, data, source=source) try: @@ -438,6 +485,15 @@ class ValueMixin(object): ) return reasons + @classmethod + def data_from_rrs(cls, rrs): + rr = rrs[0] + return { + 'ttl': rr.ttl, + 'type': rr._type, + 'value': cls._value_type.parse_rdata_text(rr.rdata), + } + def __init__(self, zone, name, data, source=None): super(ValueMixin, self).__init__(zone, name, data, source=source) self.value = self._value_type.process(data['value']) @@ -791,12 +847,12 @@ class _DynamicMixin(object): class _TargetValue(str): @classmethod - def parse_rr_text(self, value): + def parse_rdata_text(self, value): return value @classmethod def validate(cls, data, _type): - # no need to call parse_rr_text since it's a noop + # no need to call parse_rdata_text since it's a noop reasons = [] if data == '': reasons.append('empty value') @@ -812,7 +868,7 @@ class _TargetValue(str): @classmethod def process(cls, value): - # no need to call parse_rr_text since it's a noop + # no need to call parse_rdata_text since it's a noop if value: return cls(value.lower()) return None @@ -832,7 +888,7 @@ class DnameValue(_TargetValue): class _IpAddress(str): @classmethod - def parse_rr_text(cls, value): + def parse_rdata_text(cls, value): return value @classmethod @@ -843,7 +899,7 @@ class _IpAddress(str): return ['missing value(s)'] reasons = [] for value in data: - # no need to call parse_rr_text here as it's a noop + # no need to call parse_rdata_text here as it's a noop if value == '': reasons.append('empty value') elif value is None: @@ -866,7 +922,7 @@ class _IpAddress(str): return [cls(v) if v != '' else '' for v in values] def __new__(cls, v): - # no need to call parse_rr_text here as it's a noop + # no need to call parse_rdata_text here as it's a noop v = str(cls._address_type(v)) return super().__new__(cls, v) @@ -925,7 +981,7 @@ class CaaValue(EqualityTupleMixin, dict): # https://tools.ietf.org/html/rfc6844#page-5 @classmethod - def parse_rr_text(cls, value): + def parse_rdata_text(cls, value): try: flags, tag, value = value.split(' ') except ValueError: @@ -945,7 +1001,7 @@ class CaaValue(EqualityTupleMixin, dict): if isinstance(value, str): # it's hopefully RR formatted, give parsing a try try: - value = cls.parse_rr_text(value) + value = cls.parse_rdata_text(value) except RrParseError as e: reasons.append(str(e)) # not a dict so no point in continuing @@ -969,7 +1025,7 @@ class CaaValue(EqualityTupleMixin, dict): def __init__(self, value): if isinstance(value, str): - value = self.parse_rr_text(value) + value = self.parse_rdata_text(value) super().__init__( { 'flags': int(value.get('flags', 0)), @@ -1056,7 +1112,7 @@ class LocValue(EqualityTupleMixin, dict): # https://www.rfc-editor.org/rfc/rfc1876.html @classmethod - def parse_rr_text(cls, value): + def parse_rdata_text(cls, value): try: value = value.replace('m', '') ( @@ -1157,7 +1213,7 @@ class LocValue(EqualityTupleMixin, dict): if isinstance(value, str): # it's hopefully RR formatted, give parsing a try try: - value = cls.parse_rr_text(value) + value = cls.parse_rdata_text(value) except RrParseError as e: reasons.append(str(e)) # not a dict so no point in continuing @@ -1235,7 +1291,7 @@ class LocValue(EqualityTupleMixin, dict): def __init__(self, value): if isinstance(value, str): - value = self.parse_rr_text(value) + value = self.parse_rdata_text(value) super().__init__( { 'lat_degrees': int(value['lat_degrees']), @@ -1412,7 +1468,7 @@ Record.register_type(LocRecord) class MxValue(EqualityTupleMixin, dict): @classmethod - def parse_rr_text(cls, value): + def parse_rdata_text(cls, value): try: preference, exchange = value.split(' ') except ValueError: @@ -1432,7 +1488,7 @@ class MxValue(EqualityTupleMixin, dict): if isinstance(value, str): # it's hopefully RR formatted, give parsing a try try: - value = cls.parse_rr_text(value) + value = cls.parse_rdata_text(value) except RrParseError as e: reasons.append(str(e)) # not a dict so no point in continuing @@ -1469,7 +1525,7 @@ class MxValue(EqualityTupleMixin, dict): def __init__(self, value): if isinstance(value, str): - value = self.parse_rr_text(value) + value = self.parse_rdata_text(value) # RFC1035 says preference, half the providers use priority try: preference = value['preference'] @@ -1530,7 +1586,7 @@ class NaptrValue(EqualityTupleMixin, dict): VALID_FLAGS = ('S', 'A', 'U', 'P') @classmethod - def parse_rr_text(cls, value): + def parse_rdata_text(cls, value): try: ( order, @@ -1565,7 +1621,7 @@ class NaptrValue(EqualityTupleMixin, dict): if isinstance(value, str): # it's hopefully RR formatted, give parsing a try try: - value = cls.parse_rr_text(value) + value = cls.parse_rdata_text(value) except RrParseError as e: reasons.append(str(e)) # not a dict so no point in continuing @@ -1602,7 +1658,7 @@ class NaptrValue(EqualityTupleMixin, dict): def __init__(self, value): if isinstance(value, str): - value = self.parse_rr_text(value) + value = self.parse_rdata_text(value) super().__init__( { 'order': int(value['order']), @@ -1703,7 +1759,7 @@ Record.register_type(NaptrRecord) class _NsValue(str): @classmethod - def parse_rr_text(cls, value): + def parse_rdata_text(cls, value): return value @classmethod @@ -1714,7 +1770,7 @@ class _NsValue(str): data = (data,) reasons = [] for value in data: - # no need to consider parse_rr_text as it's a noop + # no need to consider parse_rdata_text as it's a noop if not FQDN(str(value), allow_underscores=True).is_valid: reasons.append( f'Invalid NS value "{value}" is not a valid FQDN.' @@ -1781,7 +1837,7 @@ class SshfpValue(EqualityTupleMixin, dict): VALID_FINGERPRINT_TYPES = (1, 2) @classmethod - def parse_rr_text(self, value): + def parse_rdata_text(self, value): try: algorithm, fingerprint_type, fingerprint = value.split(' ') except ValueError: @@ -1809,7 +1865,7 @@ class SshfpValue(EqualityTupleMixin, dict): if isinstance(value, str): # it's hopefully RR formatted, give parsing a try try: - value = cls.parse_rr_text(value) + value = cls.parse_rdata_text(value) except RrParseError as e: reasons.append(str(e)) # not a dict so no point in continuing @@ -1844,7 +1900,7 @@ class SshfpValue(EqualityTupleMixin, dict): def __init__(self, value): if isinstance(value, str): - value = self.parse_rr_text(value) + value = self.parse_rdata_text(value) super().__init__( { 'algorithm': int(value['algorithm']), @@ -1939,7 +1995,7 @@ class _ChunkedValue(str): data = (data,) reasons = [] for value in data: - # no need to try parse_rr_text here as it's a noop + # no need to try parse_rdata_text here as it's a noop if cls._unescaped_semicolon_re.search(value): reasons.append(f'unescaped ; in "{value}"') return reasons @@ -1968,7 +2024,7 @@ Record.register_type(SpfRecord) class SrvValue(EqualityTupleMixin, dict): @classmethod - def parse_rr_text(self, value): + def parse_rdata_text(self, value): try: priority, weight, port, target = value.split(' ') except ValueError: @@ -2001,7 +2057,7 @@ class SrvValue(EqualityTupleMixin, dict): if isinstance(value, str): # it's hopefully RR formatted, give parsing a try try: - value = cls.parse_rr_text(value) + value = cls.parse_rdata_text(value) except RrParseError as e: reasons.append(str(e)) # not a dict so no point in continuing @@ -2046,7 +2102,7 @@ class SrvValue(EqualityTupleMixin, dict): def __init__(self, value): if isinstance(value, str): - value = self.parse_rr_text(value) + value = self.parse_rdata_text(value) super().__init__( { 'priority': int(value['priority']), @@ -2121,7 +2177,7 @@ Record.register_type(SrvRecord) class TlsaValue(EqualityTupleMixin, dict): @classmethod - def parse_rr_text(self, value): + def parse_rdata_text(self, value): try: ( certificate_usage, @@ -2159,7 +2215,7 @@ class TlsaValue(EqualityTupleMixin, dict): if isinstance(value, str): # it's hopefully RR formatted, give parsing a try try: - value = cls.parse_rr_text(value) + value = cls.parse_rdata_text(value) except RrParseError as e: reasons.append(str(e)) # not a dict so no point in continuing @@ -2208,7 +2264,7 @@ class TlsaValue(EqualityTupleMixin, dict): def __init__(self, value): if isinstance(value, str): - value = self.parse_rr_text(value) + value = self.parse_rdata_text(value) super().__init__( { 'certificate_usage': int(value.get('certificate_usage', 0)), diff --git a/octodns/source/axfr.py b/octodns/source/axfr.py index 164a466..5476bdd 100644 --- a/octodns/source/axfr.py +++ b/octodns/source/axfr.py @@ -9,12 +9,11 @@ import dns.rdatatype from dns.exception import DNSException -from collections import defaultdict from os import listdir from os.path import join import logging -from ..record import Record +from ..record import Record, Rr from .base import BaseSource @@ -42,110 +41,6 @@ class AxfrBaseSource(BaseSource): def __init__(self, id): super(AxfrBaseSource, self).__init__(id) - def _data_for_multiple(self, _type, records): - return { - 'ttl': records[0]['ttl'], - 'type': _type, - 'values': [r['value'] for r in records], - } - - _data_for_A = _data_for_multiple - _data_for_AAAA = _data_for_multiple - _data_for_NS = _data_for_multiple - _data_for_PTR = _data_for_multiple - - def _data_for_CAA(self, _type, records): - values = [] - for record in records: - flags, tag, value = record['value'].split(' ', 2) - values.append( - {'flags': flags, 'tag': tag, 'value': value.replace('"', '')} - ) - return {'ttl': records[0]['ttl'], 'type': _type, 'values': values} - - def _data_for_LOC(self, _type, records): - values = [] - for record in records: - ( - lat_degrees, - lat_minutes, - lat_seconds, - lat_direction, - long_degrees, - long_minutes, - long_seconds, - long_direction, - altitude, - size, - precision_horz, - precision_vert, - ) = (record['value'].replace('m', '').split(' ', 11)) - values.append( - { - 'lat_degrees': lat_degrees, - 'lat_minutes': lat_minutes, - 'lat_seconds': lat_seconds, - 'lat_direction': lat_direction, - 'long_degrees': long_degrees, - 'long_minutes': long_minutes, - 'long_seconds': long_seconds, - 'long_direction': long_direction, - 'altitude': altitude, - 'size': size, - 'precision_horz': precision_horz, - 'precision_vert': precision_vert, - } - ) - return {'ttl': records[0]['ttl'], 'type': _type, 'values': values} - - def _data_for_MX(self, _type, records): - values = [] - for record in records: - preference, exchange = record['value'].split(' ', 1) - values.append({'preference': preference, 'exchange': exchange}) - return {'ttl': records[0]['ttl'], 'type': _type, 'values': values} - - def _data_for_TXT(self, _type, records): - values = [value['value'].replace(';', '\\;') for value in records] - return {'ttl': records[0]['ttl'], 'type': _type, 'values': values} - - _data_for_SPF = _data_for_TXT - - def _data_for_single(self, _type, records): - record = records[0] - return {'ttl': record['ttl'], 'type': _type, 'value': record['value']} - - _data_for_CNAME = _data_for_single - - def _data_for_SRV(self, _type, records): - values = [] - for record in records: - priority, weight, port, target = record['value'].split(' ', 3) - values.append( - { - 'priority': priority, - 'weight': weight, - 'port': port, - 'target': target, - } - ) - return {'type': _type, 'ttl': records[0]['ttl'], 'values': values} - - def _data_for_SSHFP(self, _type, records): - values = [] - for record in records: - algorithm, fingerprint_type, fingerprint = record['value'].split( - ' ', 2 - ) - values.append( - { - 'algorithm': algorithm, - 'fingerprint_type': fingerprint_type, - 'fingerprint': fingerprint, - } - ) - 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', @@ -154,26 +49,10 @@ class AxfrBaseSource(BaseSource): lenient, ) - values = defaultdict(lambda: defaultdict(list)) - for record in self.zone_records(zone): - _type = record['type'] - if _type not in self.SUPPORTS: - continue - name = zone.hostname_from_fqdn(record['name']) - values[name][record['type']].append(record) - before = len(zone.records) - for name, types in values.items(): - for _type, records in types.items(): - data_for = getattr(self, f'_data_for_{_type}') - record = Record.new( - zone, - name, - data_for(_type, records), - source=self, - lenient=lenient, - ) - zone.add_record(record, lenient=lenient) + rrs = self.zone_records(zone) + for record in Record.from_rrs(zone, rrs, lenient=lenient): + zone.add_record(record, lenient=lenient) self.log.info( 'populate: found %s records', len(zone.records) - before @@ -220,14 +99,8 @@ class AxfrSource(AxfrBaseSource): for (name, ttl, rdata) in z.iterate_rdatas(): rdtype = dns.rdatatype.to_text(rdata.rdtype) - records.append( - { - "name": name.to_text(), - "ttl": ttl, - "type": rdtype, - "value": rdata.to_text(), - } - ) + if rdtype in self.SUPPORTS: + records.append(Rr(name.to_text(), rdtype, ttl, rdata.to_text())) return records @@ -304,20 +177,17 @@ class ZoneFileSource(AxfrBaseSource): if zone.name not in self._zone_records: try: z = self._load_zone_file(zone.name) - records = [] - for (name, ttl, rdata) in z.iterate_rdatas(): - rdtype = dns.rdatatype.to_text(rdata.rdtype) + except ZoneFileSourceNotFound: + return [] + + records = [] + for (name, ttl, rdata) in z.iterate_rdatas(): + rdtype = dns.rdatatype.to_text(rdata.rdtype) + if rdtype in self.SUPPORTS: records.append( - { - "name": name.to_text(), - "ttl": ttl, - "type": rdtype, - "value": rdata.to_text(), - } + Rr(name.to_text(), rdtype, ttl, rdata.to_text()) ) - self._zone_records[zone.name] = records - except ZoneFileSourceNotFound: - return [] + self._zone_records[zone.name] = records return self._zone_records[zone.name] diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 4f8967d..f91efb9 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -235,10 +235,10 @@ class TestRecord(TestCase): '1.2.word.4', '1.2.3.4', ): - self.assertEqual(s, Ipv4Address.parse_rr_text(s)) + self.assertEqual(s, Ipv4Address.parse_rdata_text(s)) # since we're a noop there's no need/way to check whether validate or - # __init__ call parse_rr_text + # __init__ call parse_rdata_text zone = Zone('unit.tests.', []) a = ARecord(zone, 'a', {'ttl': 42, 'value': '1.2.3.4'}) @@ -426,10 +426,10 @@ class TestRecord(TestCase): '1.2.word.4', '1.2.3.4', ): - self.assertEqual(s, _TargetValue.parse_rr_text(s)) + self.assertEqual(s, _TargetValue.parse_rdata_text(s)) # since we're a noop there's no need/way to check whether validate or - # __init__ call parse_rr_text + # __init__ call parse_rdata_text zone = Zone('unit.tests.', []) a = AliasRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) @@ -498,33 +498,33 @@ class TestRecord(TestCase): def test_caa_value_rr_text(self): # empty string won't parse with self.assertRaises(RrParseError): - CaaValue.parse_rr_text('') + CaaValue.parse_rdata_text('') # single word won't parse with self.assertRaises(RrParseError): - CaaValue.parse_rr_text('nope') + CaaValue.parse_rdata_text('nope') # 2nd word won't parse with self.assertRaises(RrParseError): - CaaValue.parse_rr_text('0 tag') + CaaValue.parse_rdata_text('0 tag') # 4th word won't parse with self.assertRaises(RrParseError): - CaaValue.parse_rr_text('1 tag value another') + CaaValue.parse_rdata_text('1 tag value another') # flags not an int, will parse self.assertEqual( {'flags': 'one', 'tag': 'tag', 'value': 'value'}, - CaaValue.parse_rr_text('one tag value'), + CaaValue.parse_rdata_text('one tag value'), ) # valid self.assertEqual( {'flags': 0, 'tag': 'tag', 'value': '99148c81'}, - CaaValue.parse_rr_text('0 tag 99148c81'), + CaaValue.parse_rdata_text('0 tag 99148c81'), ) - # make sure that validate is using parse_rr_text when passed string + # make sure that validate is using parse_rdata_text when passed string # value(s) reasons = CaaRecord.validate( 'caa', 'caa.unit.tests.', {'ttl': 32, 'value': ''} @@ -539,7 +539,7 @@ class TestRecord(TestCase): ) self.assertFalse(reasons) - # make sure that the cstor is using parse_rr_text + # make sure that the cstor is using parse_rdata_text zone = Zone('unit.tests.', []) a = CaaRecord(zone, 'caa', {'ttl': 32, 'value': '0 tag 99148c81'}) self.assertEqual(0, a.values[0].flags) @@ -667,7 +667,7 @@ class TestRecord(TestCase): for i in tuple(range(0, 12)) + (13,): s = ''.join(['word'] * i) with self.assertRaises(RrParseError): - LocValue.parse_rr_text(s) + LocValue.parse_rdata_text(s) # type conversions are best effort self.assertEqual( @@ -685,7 +685,7 @@ class TestRecord(TestCase): 'precision_vert': 'nine', 'size': 'seven', }, - LocValue.parse_rr_text( + LocValue.parse_rdata_text( 'zero one two S three four five W six seven eight nine' ), ) @@ -707,10 +707,10 @@ class TestRecord(TestCase): 'precision_vert': 9.9, 'size': 7.7, }, - LocValue.parse_rr_text(s), + LocValue.parse_rdata_text(s), ) - # make sure validate is using parse_rr_text when passed string values + # make sure validate is using parse_rdata_text when passed string values reasons = LocRecord.validate( 'loc', 'loc.unit.tests', {'ttl': 42, 'value': ''} ) @@ -720,7 +720,7 @@ class TestRecord(TestCase): ) self.assertFalse(reasons) - # make sure that the cstor is using parse_rr_text + # make sure that the cstor is using parse_rdata_text zone = Zone('unit.tests.', []) a = LocRecord(zone, 'mx', {'ttl': 32, 'value': s}) self.assertEqual(0, a.values[0].lat_degrees) @@ -792,29 +792,29 @@ class TestRecord(TestCase): # empty string won't parse with self.assertRaises(RrParseError): - MxValue.parse_rr_text('') + MxValue.parse_rdata_text('') # single word won't parse with self.assertRaises(RrParseError): - MxValue.parse_rr_text('nope') + MxValue.parse_rdata_text('nope') # 3rd word won't parse with self.assertRaises(RrParseError): - MxValue.parse_rr_text('10 mx.unit.tests. another') + MxValue.parse_rdata_text('10 mx.unit.tests. another') # preference not an int self.assertEqual( {'preference': 'abc', 'exchange': 'mx.unit.tests.'}, - MxValue.parse_rr_text('abc mx.unit.tests.'), + MxValue.parse_rdata_text('abc mx.unit.tests.'), ) # valid self.assertEqual( {'preference': 10, 'exchange': 'mx.unit.tests.'}, - MxValue.parse_rr_text('10 mx.unit.tests.'), + MxValue.parse_rdata_text('10 mx.unit.tests.'), ) - # make sure that validate is using parse_rr_text when passed string + # make sure that validate is using parse_rdata_text when passed string # value(s) reasons = MxRecord.validate( 'mx', 'mx.unit.tests.', {'ttl': 32, 'value': ''} @@ -829,7 +829,7 @@ class TestRecord(TestCase): ) self.assertFalse(reasons) - # make sure that the cstor is using parse_rr_text + # make sure that the cstor is using parse_rdata_text zone = Zone('unit.tests.', []) a = MxRecord(zone, 'mx', {'ttl': 32, 'value': '10 mail.unit.tests.'}) self.assertEqual(10, a.values[0].preference) @@ -1152,7 +1152,7 @@ class TestRecord(TestCase): 'one two three four five six seven', ): with self.assertRaises(RrParseError): - NaptrValue.parse_rr_text(v) + NaptrValue.parse_rdata_text(v) # we don't care if the types of things are correct when parsing rr text self.assertEqual( @@ -1164,7 +1164,7 @@ class TestRecord(TestCase): 'regexp': 'five', 'replacement': 'six', }, - NaptrValue.parse_rr_text('one two three four five six'), + NaptrValue.parse_rdata_text('one two three four five six'), ) # order and preference will be converted to int's when possible @@ -1177,10 +1177,10 @@ class TestRecord(TestCase): 'regexp': 'five', 'replacement': 'six', }, - NaptrValue.parse_rr_text('1 2 three four five six'), + NaptrValue.parse_rdata_text('1 2 three four five six'), ) - # make sure that validate is using parse_rr_text when passed string + # make sure that validate is using parse_rdata_text when passed string # value(s) reasons = NaptrRecord.validate( 'naptr', 'naptr.unit.tests.', {'ttl': 32, 'value': ''} @@ -1197,7 +1197,7 @@ class TestRecord(TestCase): ) self.assertFalse(reasons) - # make sure that the cstor is using parse_rr_text + # make sure that the cstor is using parse_rdata_text zone = Zone('unit.tests.', []) s = '1 2 S service regexp replacement' a = NaptrRecord(zone, 'naptr', {'ttl': 32, 'value': s}) @@ -1238,10 +1238,10 @@ class TestRecord(TestCase): '1.2.word.4', '1.2.3.4', ): - self.assertEqual(s, _NsValue.parse_rr_text(s)) + self.assertEqual(s, _NsValue.parse_rdata_text(s)) # since we're a noop there's no need/way to check whether validate or - # __init__ call parse_rr_text + # __init__ call parse_rdata_text zone = Zone('unit.tests.', []) a = NsRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) @@ -1319,15 +1319,15 @@ class TestRecord(TestCase): # empty string won't parse with self.assertRaises(RrParseError): - SshfpValue.parse_rr_text('') + SshfpValue.parse_rdata_text('') # single word won't parse with self.assertRaises(RrParseError): - SshfpValue.parse_rr_text('nope') + SshfpValue.parse_rdata_text('nope') # 3rd word won't parse with self.assertRaises(RrParseError): - SshfpValue.parse_rr_text('0 1 00479b27 another') + SshfpValue.parse_rdata_text('0 1 00479b27 another') # algorithm and fingerprint_type not ints self.assertEqual( @@ -1336,16 +1336,16 @@ class TestRecord(TestCase): 'fingerprint_type': 'two', 'fingerprint': '00479b27', }, - SshfpValue.parse_rr_text('one two 00479b27'), + SshfpValue.parse_rdata_text('one two 00479b27'), ) # valid self.assertEqual( {'algorithm': 1, 'fingerprint_type': 2, 'fingerprint': '00479b27'}, - SshfpValue.parse_rr_text('1 2 00479b27'), + SshfpValue.parse_rdata_text('1 2 00479b27'), ) - # make sure that validate is using parse_rr_text when passed string + # make sure that validate is using parse_rdata_text when passed string # value(s) reasons = SshfpRecord.validate( 'sshfp', 'sshfp.unit.tests.', {'ttl': 32, 'value': ''} @@ -1360,7 +1360,7 @@ class TestRecord(TestCase): ) self.assertFalse(reasons) - # make sure that the cstor is using parse_rr_text + # make sure that the cstor is using parse_rdata_text zone = Zone('unit.tests.', []) a = SshfpRecord(zone, 'sshfp', {'ttl': 32, 'value': '1 2 00479b27'}) self.assertEqual(1, a.values[0].algorithm) @@ -1386,10 +1386,10 @@ class TestRecord(TestCase): '1.2.word.4', '1.2.3.4', ): - self.assertEqual(s, _ChunkedValue.parse_rr_text(s)) + self.assertEqual(s, _ChunkedValue.parse_rdata_text(s)) # since we're a noop there's no need/way to check whether validate or - # __init__ call parse_rr_text + # __init__ call parse_rdata_text zone = Zone('unit.tests.', []) a = SpfRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) @@ -1463,23 +1463,23 @@ class TestRecord(TestCase): # empty string won't parse with self.assertRaises(RrParseError): - SrvValue.parse_rr_text('') + SrvValue.parse_rdata_text('') # single word won't parse with self.assertRaises(RrParseError): - SrvValue.parse_rr_text('nope') + SrvValue.parse_rdata_text('nope') # 2nd word won't parse with self.assertRaises(RrParseError): - SrvValue.parse_rr_text('1 2') + SrvValue.parse_rdata_text('1 2') # 3rd word won't parse with self.assertRaises(RrParseError): - SrvValue.parse_rr_text('1 2 3') + SrvValue.parse_rdata_text('1 2 3') # 5th word won't parse with self.assertRaises(RrParseError): - SrvValue.parse_rr_text('1 2 3 4 5') + SrvValue.parse_rdata_text('1 2 3 4 5') # priority weight and port not ints self.assertEqual( @@ -1489,7 +1489,7 @@ class TestRecord(TestCase): 'port': 'three', 'target': 'srv.unit.tests.', }, - SrvValue.parse_rr_text('one two three srv.unit.tests.'), + SrvValue.parse_rdata_text('one two three srv.unit.tests.'), ) # valid @@ -1500,10 +1500,10 @@ class TestRecord(TestCase): 'port': 3, 'target': 'srv.unit.tests.', }, - SrvValue.parse_rr_text('1 2 3 srv.unit.tests.'), + SrvValue.parse_rdata_text('1 2 3 srv.unit.tests.'), ) - # make sure that validate is using parse_rr_text when passed string + # make sure that validate is using parse_rdata_text when passed string # value(s) reasons = SrvRecord.validate( '_srv._tcp', '_srv._tcp.unit.tests.', {'ttl': 32, 'value': ''} @@ -1516,7 +1516,7 @@ class TestRecord(TestCase): ) self.assertFalse(reasons) - # make sure that the cstor is using parse_rr_text + # make sure that the cstor is using parse_rdata_text zone = Zone('unit.tests.', []) a = SrvRecord( zone, '_srv._tcp', {'ttl': 32, 'value': '1 2 3 srv.unit.tests.'} @@ -1631,23 +1631,23 @@ class TestRecord(TestCase): # empty string won't parse with self.assertRaises(RrParseError): - TlsaValue.parse_rr_text('') + TlsaValue.parse_rdata_text('') # single word won't parse with self.assertRaises(RrParseError): - TlsaValue.parse_rr_text('nope') + TlsaValue.parse_rdata_text('nope') # 2nd word won't parse with self.assertRaises(RrParseError): - TlsaValue.parse_rr_text('1 2') + TlsaValue.parse_rdata_text('1 2') # 3rd word won't parse with self.assertRaises(RrParseError): - TlsaValue.parse_rr_text('1 2 3') + TlsaValue.parse_rdata_text('1 2 3') # 5th word won't parse with self.assertRaises(RrParseError): - TlsaValue.parse_rr_text('1 2 3 abcd another') + TlsaValue.parse_rdata_text('1 2 3 abcd another') # non-ints self.assertEqual( @@ -1657,7 +1657,7 @@ class TestRecord(TestCase): 'matching_type': 'three', 'certificate_association_data': 'abcd', }, - TlsaValue.parse_rr_text('one two three abcd'), + TlsaValue.parse_rdata_text('one two three abcd'), ) # valid @@ -1668,10 +1668,10 @@ class TestRecord(TestCase): 'matching_type': 3, 'certificate_association_data': 'abcd', }, - TlsaValue.parse_rr_text('1 2 3 abcd'), + TlsaValue.parse_rdata_text('1 2 3 abcd'), ) - # make sure that validate is using parse_rr_text when passed string + # make sure that validate is using parse_rdata_text when passed string # value(s) reasons = TlsaRecord.validate( 'tlsa', 'tlsa.unit.tests.', {'ttl': 32, 'value': ''} @@ -1682,7 +1682,7 @@ class TestRecord(TestCase): ) self.assertFalse(reasons) - # make sure that the cstor is using parse_rr_text + # make sure that the cstor is using parse_rdata_text zone = Zone('unit.tests.', []) a = TlsaRecord(zone, 'tlsa', {'ttl': 32, 'value': '2 1 0 abcd'}) self.assertEqual(2, a.values[0].certificate_usage) From ace2fdf4e189cb37699fef55521696fdfaef391a Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Thu, 22 Sep 2022 10:26:21 -0700 Subject: [PATCH 07/12] Make sure _ChunkedValue.parse_rdata_text escapes ; --- octodns/record/__init__.py | 27 ++++++++++-------- tests/test_octodns_record.py | 54 +++++++++++++++++++----------------- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index e7d107f..6bd1810 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -874,7 +874,7 @@ class _TargetValue(str): return None @property - def rr_text(self): + def rdata_text(self): return self @@ -927,7 +927,7 @@ class _IpAddress(str): return super().__new__(cls, v) @property - def rr_text(self): + def rdata_text(self): return self @@ -1063,7 +1063,7 @@ class CaaValue(EqualityTupleMixin, dict): return self @property - def rr_text(self): + def rdata_text(self): return f'{self.flags} {self.tag} {self.value}' def _equality_tuple(self): @@ -1410,7 +1410,7 @@ class LocValue(EqualityTupleMixin, dict): return self @property - def rr_text(self): + def rdata_text(self): return f'{self.lat_degrees} {self.lat_minutes} {self.lat_seconds} {self.lat_direction} {self.long_degrees} {self.long_minutes} {self.long_seconds} {self.long_direction} {self.altitude}m {self.size}m {self.precision_horz}m {self.precision_vert}m' def __hash__(self): @@ -1561,7 +1561,7 @@ class MxValue(EqualityTupleMixin, dict): return self @property - def rr_text(self): + def rdata_text(self): return f'{self.preference} {self.exchange}' def __hash__(self): @@ -1723,7 +1723,7 @@ class NaptrValue(EqualityTupleMixin, dict): return self @property - def rr_text(self): + def rdata_text(self): return f'{self.order} {self.preference} {self.flags} {self.service} {self.regexp} {self.replacement}' def __hash__(self): @@ -1784,7 +1784,7 @@ class _NsValue(str): return [cls(v) for v in values] @property - def rr_text(self): + def rdata_text(self): return self @@ -1938,7 +1938,7 @@ class SshfpValue(EqualityTupleMixin, dict): return self @property - def rr_text(self): + def rdata_text(self): return f'{self.algorithm} {self.fingerprint_type} {self.fingerprint}' def __hash__(self): @@ -1984,8 +1984,11 @@ class _ChunkedValue(str): _unescaped_semicolon_re = re.compile(r'\w;') @classmethod - def parse_rr_text(cls, value): - return value + def parse_rdata_text(cls, value): + try: + return value.replace(';', '\\;') + except AttributeError: + return value @classmethod def validate(cls, data, _type): @@ -2010,7 +2013,7 @@ class _ChunkedValue(str): return ret @property - def rr_text(self): + def rdata_text(self): return self @@ -2309,7 +2312,7 @@ class TlsaValue(EqualityTupleMixin, dict): self['certificate_association_data'] = value @property - def rr_text(self): + def rdata_text(self): return f'{self.certificate_usage} {self.selector} {self.matching_type} {self.certificate_association_data}' def _equality_tuple(self): diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index f91efb9..c80b7c5 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -221,7 +221,7 @@ class TestRecord(TestCase): DummyRecord().__repr__() - def test_ip_address_rr_text(self): + def test_ip_address_rdata_text(self): # anything goes, we're a noop for s in ( @@ -242,7 +242,7 @@ class TestRecord(TestCase): zone = Zone('unit.tests.', []) a = ARecord(zone, 'a', {'ttl': 42, 'value': '1.2.3.4'}) - self.assertEqual('1.2.3.4', a.values[0].rr_text) + self.assertEqual('1.2.3.4', a.values[0].rdata_text) def test_values_mixin_data(self): # no values, no value or values in data @@ -412,7 +412,7 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() - def test_target_rr_text(self): + def test_target_rdata_text(self): # anything goes, we're a noop for s in ( @@ -433,7 +433,7 @@ class TestRecord(TestCase): zone = Zone('unit.tests.', []) a = AliasRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) - self.assertEqual('some.target.', a.value.rr_text) + self.assertEqual('some.target.', a.value.rdata_text) def test_caa(self): a_values = [ @@ -495,7 +495,7 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() - def test_caa_value_rr_text(self): + def test_caa_value_rdata_text(self): # empty string won't parse with self.assertRaises(RrParseError): CaaValue.parse_rdata_text('') @@ -545,7 +545,7 @@ class TestRecord(TestCase): self.assertEqual(0, a.values[0].flags) self.assertEqual('tag', a.values[0].tag) self.assertEqual('99148c81', a.values[0].value) - self.assertEqual('0 tag 99148c81', a.values[0].rr_text) + self.assertEqual('0 tag 99148c81', a.values[0].rdata_text) a = CaaRecord( zone, 'caa', @@ -554,11 +554,11 @@ class TestRecord(TestCase): self.assertEqual(1, a.values[0].flags) self.assertEqual('tag1', a.values[0].tag) self.assertEqual('99148c81', a.values[0].value) - self.assertEqual('1 tag1 99148c81', a.values[0].rr_text) + self.assertEqual('1 tag1 99148c81', a.values[0].rdata_text) self.assertEqual(2, a.values[1].flags) self.assertEqual('tag2', a.values[1].tag) self.assertEqual('99148c44', a.values[1].value) - self.assertEqual('2 tag2 99148c44', a.values[1].rr_text) + self.assertEqual('2 tag2 99148c44', a.values[1].rdata_text) def test_cname(self): self.assertSingleValue(CnameRecord, 'target.foo.com.', 'other.foo.com.') @@ -662,7 +662,7 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() - def test_loc_value_rr_text(self): + def test_loc_value_rdata_text(self): # only the exact correct number of words is allowed for i in tuple(range(0, 12)) + (13,): s = ''.join(['word'] * i) @@ -735,7 +735,7 @@ class TestRecord(TestCase): self.assertEqual(7.7, a.values[0].size) self.assertEqual(8.8, a.values[0].precision_horz) self.assertEqual(9.9, a.values[0].precision_vert) - self.assertEqual(s, a.values[0].rr_text) + self.assertEqual(s, a.values[0].rdata_text) def test_mx(self): a_values = [ @@ -788,7 +788,7 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() - def test_mx_value_rr_text(self): + def test_mx_value_rdata_text(self): # empty string won't parse with self.assertRaises(RrParseError): @@ -834,7 +834,7 @@ class TestRecord(TestCase): a = MxRecord(zone, 'mx', {'ttl': 32, 'value': '10 mail.unit.tests.'}) self.assertEqual(10, a.values[0].preference) self.assertEqual('mail.unit.tests.', a.values[0].exchange) - self.assertEqual('10 mail.unit.tests.', a.values[0].rr_text) + self.assertEqual('10 mail.unit.tests.', a.values[0].rdata_text) a = MxRecord( zone, 'mx', @@ -845,10 +845,10 @@ class TestRecord(TestCase): ) self.assertEqual(11, a.values[0].preference) self.assertEqual('mail1.unit.tests.', a.values[0].exchange) - self.assertEqual('11 mail1.unit.tests.', a.values[0].rr_text) + self.assertEqual('11 mail1.unit.tests.', a.values[0].rdata_text) self.assertEqual(12, a.values[1].preference) self.assertEqual('mail2.unit.tests.', a.values[1].exchange) - self.assertEqual('12 mail2.unit.tests.', a.values[1].rr_text) + self.assertEqual('12 mail2.unit.tests.', a.values[1].rdata_text) def test_naptr(self): a_values = [ @@ -1140,7 +1140,7 @@ class TestRecord(TestCase): o.replacement = '1' self.assertEqual('1', o.replacement) - def test_naptr_value_rr_text(self): + def test_naptr_value_rdata_text(self): # things with the wrong number of words won't parse for v in ( '', @@ -1207,7 +1207,7 @@ class TestRecord(TestCase): self.assertEqual('service', a.values[0].service) self.assertEqual('regexp', a.values[0].regexp) self.assertEqual('replacement', a.values[0].replacement) - self.assertEqual(s, a.values[0].rr_text) + self.assertEqual(s, a.values[0].rdata_text) def test_ns(self): a_values = ['5.6.7.8.', '6.7.8.9.', '7.8.9.0.'] @@ -1225,7 +1225,7 @@ class TestRecord(TestCase): self.assertEqual([b_value], b.values) self.assertEqual(b_data, b.data) - def test_ns_value_rr_text(self): + def test_ns_value_rdata_text(self): # anything goes, we're a noop for s in ( None, @@ -1245,7 +1245,7 @@ class TestRecord(TestCase): zone = Zone('unit.tests.', []) a = NsRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) - self.assertEqual('some.target.', a.values[0].rr_text) + self.assertEqual('some.target.', a.values[0].rdata_text) def test_sshfp(self): a_values = [ @@ -1315,7 +1315,7 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() - def test_sshfp_value_rr_text(self): + def test_sshfp_value_rdata_text(self): # empty string won't parse with self.assertRaises(RrParseError): @@ -1366,7 +1366,7 @@ class TestRecord(TestCase): self.assertEqual(1, a.values[0].algorithm) self.assertEqual(2, a.values[0].fingerprint_type) self.assertEqual('00479b27', a.values[0].fingerprint) - self.assertEqual('1 2 00479b27', a.values[0].rr_text) + self.assertEqual('1 2 00479b27', a.values[0].rdata_text) def test_spf(self): a_values = ['spf1 -all', 'spf1 -hrm'] @@ -1374,7 +1374,6 @@ class TestRecord(TestCase): self.assertMultipleValues(SpfRecord, a_values, b_value) def test_chunked_value_rr_text(self): - # anything goes, we're a noop for s in ( None, '', @@ -1388,12 +1387,17 @@ class TestRecord(TestCase): ): self.assertEqual(s, _ChunkedValue.parse_rdata_text(s)) - # since we're a noop there's no need/way to check whether validate or - # __init__ call parse_rdata_text + # semi-colons are escaped + self.assertEqual( + 'Hello\\; World!', _ChunkedValue.parse_rdata_text('Hello; World!') + ) + + # since we're always a string validate and __init__ don't + # parse_rdata_text zone = Zone('unit.tests.', []) a = SpfRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) - self.assertEqual('some.target.', a.values[0].rr_text) + self.assertEqual('some.target.', a.values[0].rdata_text) def test_srv(self): a_values = [ @@ -1689,7 +1693,7 @@ class TestRecord(TestCase): self.assertEqual(1, a.values[0].selector) self.assertEqual(0, a.values[0].matching_type) self.assertEqual('abcd', a.values[0].certificate_association_data) - self.assertEqual('2 1 0 abcd', a.values[0].rr_text) + self.assertEqual('2 1 0 abcd', a.values[0].rdata_text) def test_txt(self): a_values = ['a one', 'a two'] From cea1c65305cdec4cec1d762c35af198dea61e063 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Thu, 22 Sep 2022 18:58:16 -0700 Subject: [PATCH 08/12] Remove pprints and add some comments/doc --- octodns/record/__init__.py | 27 ++++++++++++++------------- tests/test_octodns_record.py | 5 +++++ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 6bd1810..d3214c2 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -86,6 +86,12 @@ class ValidationError(RecordException): class Rr(object): + ''' + Simple object intended to be used with Record.from_rrs to allow providers + that work with RFC formatted rdata to share centralized parsing/encoding + code + ''' + def __init__(self, name, _type, ttl, rdata): self.name = name self._type = _type @@ -185,28 +191,23 @@ class Record(EqualityTupleMixin): @classmethod def from_rrs(cls, zone, rrs, lenient=False): - from pprint import pprint - - pprint({'zone': zone, 'rrs': rrs, 'lenient': lenient}) - + # group records by name & type so that multiple rdatas can be combined + # into a single record when needed grouped = defaultdict(list) for rr in rrs: grouped[(rr.name, rr._type)].append(rr) - pprint({'grouped': grouped}) - records = [] + # walk the grouped rrs converting each one to data and then create a + # record with that data for _, rrs in sorted(grouped.items()): rr = rrs[0] name = zone.hostname_from_fqdn(rr.name) _class = cls._CLASSES[rr._type] - pprint({'rr': rr, 'name': name, 'class': _class}) data = _class.data_from_rrs(rrs) - pprint({'data': data}) record = Record.new(zone, name, data, lenient=lenient) records.append(record) - pprint({'records': records}) return records def __init__(self, zone, name, data, source=None): @@ -382,11 +383,10 @@ class ValuesMixin(object): @classmethod def data_from_rrs(cls, rrs): - values = [cls._value_type.parse_rdata_text(rr.rdata) for rr in rrs] - from pprint import pprint - - pprint({'values': values}) + # type and TTL come from the first rr rr = rrs[0] + # values come from parsing the rdata portion of all rrs + values = [cls._value_type.parse_rdata_text(rr.rdata) for rr in rrs] return {'ttl': rr.ttl, 'type': rr._type, 'values': values} def __init__(self, zone, name, data, source=None): @@ -487,6 +487,7 @@ class ValueMixin(object): @classmethod def data_from_rrs(cls, rrs): + # single value, so single rr only... rr = rrs[0] return { 'ttl': rr.ttl, diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index c80b7c5..19a0f00 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -27,6 +27,7 @@ from octodns.record import ( PtrRecord, Record, RecordException, + Rr, RrParseError, SshfpRecord, SshfpValue, @@ -2503,6 +2504,10 @@ class TestRecord(TestCase): values.add(b) self.assertTrue(b in values) + def test_rr(self): + # nothing much to test, just make sure that things don't blow up + Rr('name', 'type', 42, 'Hello World!').__repr__() + class TestRecordValidation(TestCase): zone = Zone('unit.tests.', []) From 7f0cfb08a24fc62a66da72d59ef5e93e58cff213 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Thu, 22 Sep 2022 20:26:25 -0700 Subject: [PATCH 09/12] Implement Record.rrs --- octodns/record/__init__.py | 13 +++++++++++++ tests/test_octodns_record.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index d3214c2..2d544cc 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -417,6 +417,15 @@ class ValuesMixin(object): return ret + @property + def rrs(self): + return ( + self.fqdn, + self._type, + self.ttl, + [v.rdata_text for v in self.values], + ) + def __repr__(self): values = "', '".join([str(v) for v in self.values]) klass = self.__class__.__name__ @@ -510,6 +519,10 @@ class ValueMixin(object): ret['value'] = getattr(self.value, 'data', self.value) return ret + @property + def rrs(self): + return self.fqdn, self._type, self.ttl, [self.value.rdata_text] + def __repr__(self): klass = self.__class__.__name__ return f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, {self.value}>' diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 19a0f00..b716f43 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -2508,6 +2508,26 @@ class TestRecord(TestCase): # nothing much to test, just make sure that things don't blow up Rr('name', 'type', 42, 'Hello World!').__repr__() + zone = Zone('unit.tests.', []) + record = Record.new( + zone, + 'a', + {'ttl': 42, 'type': 'A', 'values': ['1.2.3.4', '2.3.4.5']}, + ) + self.assertEqual( + ('a.unit.tests.', 'A', 42, ['1.2.3.4', '2.3.4.5']), record.rrs + ) + + record = Record.new( + zone, + 'cname', + {'ttl': 43, 'type': 'CNAME', 'value': 'target.unit.tests.'}, + ) + self.assertEqual( + ('cname.unit.tests.', 'CNAME', 43, ['target.unit.tests.']), + record.rrs, + ) + class TestRecordValidation(TestCase): zone = Zone('unit.tests.', []) From 2c5d8ad10194855bbd93823b552471f03ccd96d7 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Thu, 22 Sep 2022 20:29:08 -0700 Subject: [PATCH 10/12] Flip ttl and type return order --- octodns/record/__init__.py | 4 ++-- tests/test_octodns_record.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 2d544cc..fc504a1 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -421,8 +421,8 @@ class ValuesMixin(object): def rrs(self): return ( self.fqdn, - self._type, self.ttl, + self._type, [v.rdata_text for v in self.values], ) @@ -521,7 +521,7 @@ class ValueMixin(object): @property def rrs(self): - return self.fqdn, self._type, self.ttl, [self.value.rdata_text] + return self.fqdn, self.ttl, self._type, [self.value.rdata_text] def __repr__(self): klass = self.__class__.__name__ diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index b716f43..455fbe6 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -2515,7 +2515,7 @@ class TestRecord(TestCase): {'ttl': 42, 'type': 'A', 'values': ['1.2.3.4', '2.3.4.5']}, ) self.assertEqual( - ('a.unit.tests.', 'A', 42, ['1.2.3.4', '2.3.4.5']), record.rrs + ('a.unit.tests.', 42, 'A', ['1.2.3.4', '2.3.4.5']), record.rrs ) record = Record.new( @@ -2524,7 +2524,7 @@ class TestRecord(TestCase): {'ttl': 43, 'type': 'CNAME', 'value': 'target.unit.tests.'}, ) self.assertEqual( - ('cname.unit.tests.', 'CNAME', 43, ['target.unit.tests.']), + ('cname.unit.tests.', 43, 'CNAME', ['target.unit.tests.']), record.rrs, ) From 8b82159ee0b09fcdffde47812a53c087f7adfe82 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Tue, 27 Sep 2022 07:16:54 -0700 Subject: [PATCH 11/12] WIP/expiriments with auto-arpa --- octodns/auto_arpa.py | 51 ++++++++++++++++++++++++ octodns/manager.py | 75 +++++++++++++++++++++++++++++------ tests/config/simple.yaml | 6 +++ tests/test_octodns_manager.py | 11 ++--- 4 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 octodns/auto_arpa.py diff --git a/octodns/auto_arpa.py b/octodns/auto_arpa.py new file mode 100644 index 0000000..20f8a24 --- /dev/null +++ b/octodns/auto_arpa.py @@ -0,0 +1,51 @@ +# +# +# + +from collections import defaultdict +from ipaddress import ip_address +from logging import getLogger + +from .processor.base import BaseProcessor +from .record import Record +from .source.base import BaseSource + + +class AutoArpa(BaseProcessor, BaseSource): + SUPPORTS = set(('PTR',)) + SUPPORTS_GEO = False + + log = getLogger('AutoArpa') + + def __init__(self, ttl=3600): + super().__init__('auto-arpa') + self.ttl = ttl + + self._addrs = defaultdict(list) + + def process_source_zone(self, desired, sources): + for record in desired.records: + if record._type in ('A', 'AAAA'): + for value in record.values: + addr = ip_address(value) + self._addrs[f'{addr.reverse_pointer}.'].append(record.fqdn) + + return desired + + def populate(self, zone, target=False, lenient=False): + self.log.debug('populate: zone=%s', zone.name) + before = len(zone.records) + + name = zone.name + for arpa, fqdns in self._addrs.items(): + if arpa.endswith(name): + record = Record.new( + zone, + zone.hostname_from_fqdn(arpa), + {'ttl': self.ttl, 'type': 'PTR', 'values': fqdns}, + ) + zone.add_record(record) + + self.log.info( + 'populate: found %s records', len(zone.records) - before + ) diff --git a/octodns/manager.py b/octodns/manager.py index 5677e6d..b4b7c0f 100644 --- a/octodns/manager.py +++ b/octodns/manager.py @@ -10,6 +10,7 @@ from sys import stdout import logging from . import __VERSION__ +from .auto_arpa import AutoArpa from .idna import IdnaDict, idna_decode, idna_encode from .provider.base import BaseProvider from .provider.plan import Plan @@ -62,7 +63,7 @@ class _AggregateTarget(object): raise AttributeError(f'{klass} object has no attribute {name}') -class MakeThreadFuture(object): +class FakeThreadFuture(object): def __init__(self, func, args, kwargs): self.func = func self.args = args @@ -82,7 +83,7 @@ class MainThreadExecutor(object): ''' def submit(self, func, *args, **kwargs): - return MakeThreadFuture(func, args, kwargs) + return FakeThreadFuture(func, args, kwargs) class ManagerException(Exception): @@ -99,7 +100,9 @@ class Manager(object): # TODO: all of this should get broken up, mainly so that it's not so huge # and each bit can be cleanly tested independently - def __init__(self, config_file, max_workers=None, include_meta=False): + def __init__( + self, config_file, max_workers=None, include_meta=False, auto_arpa=False + ): version = self._try_version('octodns', version=__VERSION__) self.log.info( '__init__: config_file=%s (octoDNS %s)', config_file, version @@ -119,6 +122,7 @@ class Manager(object): self.include_meta = self._config_include_meta( manager_config, include_meta ) + self.auto_arpa = self._config_auto_arpa(manager_config, auto_arpa) self.global_processors = manager_config.get('processors', []) self.log.info('__init__: global_processors=%s', self.global_processors) @@ -174,6 +178,15 @@ class Manager(object): self.log.info('_config_include_meta: include_meta=%s', include_meta) return include_meta + def _config_auto_arpa(self, manager_config, auto_arpa=False): + auto_arpa = auto_arpa or manager_config.get('auto-arpa', False) + self.log.info('_config_auto_arpa: auto_arpa=%s', auto_arpa) + if auto_arpa: + if not isinstance(auto_arpa, dict): + auto_arpa = {} + return AutoArpa(**auto_arpa) + return None + def _config_providers(self, providers_config): self.log.debug('_config_providers: configuring providers') providers = {} @@ -202,6 +215,9 @@ class Manager(object): 'Incorrect provider config for ' + provider_name ) + if self.auto_arpa: + providers['auto-arpa'] = self.auto_arpa + return providers def _config_processors(self, processors_config): @@ -229,6 +245,10 @@ class Manager(object): raise ManagerException( 'Incorrect processor config for ' + processor_name ) + + if self.auto_arpa: + processors['auto-arpa'] = self.auto_arpa + return processors def _config_plan_outputs(self, plan_outputs_config): @@ -477,6 +497,7 @@ class Manager(object): aliased_zones = {} futures = [] + arpa_kwargs = [] for zone_name, config in zones.items(): decoded_zone_name = idna_decode(zone_name) self.log.info('sync: zone=%s', decoded_zone_name) @@ -537,6 +558,9 @@ class Manager(object): collected = [] for processor in self.global_processors + processors: collected.append(self.processors[processor]) + # always goes last + if self.auto_arpa: + collected.append(self.auto_arpa) processors = collected except KeyError: raise ManagerException( @@ -572,16 +596,25 @@ class Manager(object): f'Zone {decoded_zone_name}, unknown ' f'target: {target}' ) - futures.append( - self._executor.submit( - self._populate_and_plan, - zone_name, - processors, - sources, - targets, - lenient=lenient, + kwargs = { + 'zone_name': zone_name, + 'processors': processors, + 'sources': sources, + 'targets': targets, + 'lenient': lenient, + } + if self.auto_arpa and ( + zone_name.endswith('in-addr.arpa.') + or zone_name.endswith('ip6.arpa.') + ): + # auto arpa is enabled so we need to defer processing all arpa + # zones until after general ones have completed (async) so that + # they'll have access to all the recorded A/AAAA values + arpa_kwargs.append(kwargs) + else: + futures.append( + self._executor.submit(self._populate_and_plan, **kwargs) ) - ) # Wait on all results and unpack/flatten the plans and store the # desired states in case we need them below @@ -621,6 +654,24 @@ class Manager(object): # as these are aliased zones plans += [p for f in futures for p in f.result()[0]] + if self.auto_arpa and arpa_kwargs: + self.log.info( + 'sync: processing %d arpa reverse dns zones', len(arpa_kwargs) + ) + # all the general zones are done and we've recorded the A/AAAA + # records with the AutoPtr. We can no process ptr zones + futures = [] + for kwargs in arpa_kwargs: + futures.append( + self._executor.submit(self._populate_and_plan, **kwargs) + ) + + for future in futures: + ps, d = future.result() + desired[d.name] = d + for plan in ps: + plans.append(plan) + # Best effort sort plans children first so that we create/update # children zones before parents which should allow us to more safely # extract things into sub-zones. Combining a child back into a parent diff --git a/tests/config/simple.yaml b/tests/config/simple.yaml index 5040298..561139b 100644 --- a/tests/config/simple.yaml +++ b/tests/config/simple.yaml @@ -1,4 +1,5 @@ manager: + auto-arpa: true max_workers: 2 providers: in: @@ -44,3 +45,8 @@ zones: - in targets: - dump + 3.2.1.in-addr.arpa.: + sources: + - auto-arpa + targets: + - dump diff --git a/tests/test_octodns_manager.py b/tests/test_octodns_manager.py index 1a36723..e34f2a9 100644 --- a/tests/test_octodns_manager.py +++ b/tests/test_octodns_manager.py @@ -136,7 +136,7 @@ class TestManager(TestCase): environ['YAML_TMP_DIR'] = tmpdir.dirname environ['YAML_TMP_DIR2'] = tmpdir.dirname tc = Manager(get_config_filename('simple.yaml')).sync(dry_run=False) - self.assertEqual(28, tc) + self.assertEqual(30, tc) # try with just one of the zones tc = Manager(get_config_filename('simple.yaml')).sync( @@ -160,13 +160,13 @@ class TestManager(TestCase): tc = Manager(get_config_filename('simple.yaml')).sync( dry_run=False, force=True ) - self.assertEqual(28, tc) + self.assertEqual(30, tc) # Again with max_workers = 1 tc = Manager( get_config_filename('simple.yaml'), max_workers=1 ).sync(dry_run=False, force=True) - self.assertEqual(28, tc) + self.assertEqual(30, tc) # Include meta tc = Manager( @@ -174,7 +174,7 @@ class TestManager(TestCase): max_workers=1, include_meta=True, ).sync(dry_run=False, force=True) - self.assertEqual(33, tc) + self.assertEqual(36, tc) def test_idna_eligible_zones(self): # loading w/simple, but we'll be blowing it away and doing some manual @@ -186,9 +186,6 @@ class TestManager(TestCase): manager.config['zones'] = manager._config_zones( {'déjà.vu.': {}, 'deja.vu.': {}, idna_encode('こんにちは.jp.'): {}} ) - from pprint import pprint - - pprint(manager.config['zones']) # refer to them with utf-8 with self.assertRaises(ManagerException) as ctx: From 1f2bb8860a48feef3670f007e0127fdc4f16ee26 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Thu, 29 Sep 2022 14:35:28 -0700 Subject: [PATCH 12/12] Revert "WIP/expiriments with auto-arpa" This reverts commit 8b82159ee0b09fcdffde47812a53c087f7adfe82. --- octodns/auto_arpa.py | 51 ------------------------ octodns/manager.py | 75 ++++++----------------------------- tests/config/simple.yaml | 6 --- tests/test_octodns_manager.py | 11 +++-- 4 files changed, 19 insertions(+), 124 deletions(-) delete mode 100644 octodns/auto_arpa.py diff --git a/octodns/auto_arpa.py b/octodns/auto_arpa.py deleted file mode 100644 index 20f8a24..0000000 --- a/octodns/auto_arpa.py +++ /dev/null @@ -1,51 +0,0 @@ -# -# -# - -from collections import defaultdict -from ipaddress import ip_address -from logging import getLogger - -from .processor.base import BaseProcessor -from .record import Record -from .source.base import BaseSource - - -class AutoArpa(BaseProcessor, BaseSource): - SUPPORTS = set(('PTR',)) - SUPPORTS_GEO = False - - log = getLogger('AutoArpa') - - def __init__(self, ttl=3600): - super().__init__('auto-arpa') - self.ttl = ttl - - self._addrs = defaultdict(list) - - def process_source_zone(self, desired, sources): - for record in desired.records: - if record._type in ('A', 'AAAA'): - for value in record.values: - addr = ip_address(value) - self._addrs[f'{addr.reverse_pointer}.'].append(record.fqdn) - - return desired - - def populate(self, zone, target=False, lenient=False): - self.log.debug('populate: zone=%s', zone.name) - before = len(zone.records) - - name = zone.name - for arpa, fqdns in self._addrs.items(): - if arpa.endswith(name): - record = Record.new( - zone, - zone.hostname_from_fqdn(arpa), - {'ttl': self.ttl, 'type': 'PTR', 'values': fqdns}, - ) - zone.add_record(record) - - self.log.info( - 'populate: found %s records', len(zone.records) - before - ) diff --git a/octodns/manager.py b/octodns/manager.py index b4b7c0f..5677e6d 100644 --- a/octodns/manager.py +++ b/octodns/manager.py @@ -10,7 +10,6 @@ from sys import stdout import logging from . import __VERSION__ -from .auto_arpa import AutoArpa from .idna import IdnaDict, idna_decode, idna_encode from .provider.base import BaseProvider from .provider.plan import Plan @@ -63,7 +62,7 @@ class _AggregateTarget(object): raise AttributeError(f'{klass} object has no attribute {name}') -class FakeThreadFuture(object): +class MakeThreadFuture(object): def __init__(self, func, args, kwargs): self.func = func self.args = args @@ -83,7 +82,7 @@ class MainThreadExecutor(object): ''' def submit(self, func, *args, **kwargs): - return FakeThreadFuture(func, args, kwargs) + return MakeThreadFuture(func, args, kwargs) class ManagerException(Exception): @@ -100,9 +99,7 @@ class Manager(object): # TODO: all of this should get broken up, mainly so that it's not so huge # and each bit can be cleanly tested independently - def __init__( - self, config_file, max_workers=None, include_meta=False, auto_arpa=False - ): + def __init__(self, config_file, max_workers=None, include_meta=False): version = self._try_version('octodns', version=__VERSION__) self.log.info( '__init__: config_file=%s (octoDNS %s)', config_file, version @@ -122,7 +119,6 @@ class Manager(object): self.include_meta = self._config_include_meta( manager_config, include_meta ) - self.auto_arpa = self._config_auto_arpa(manager_config, auto_arpa) self.global_processors = manager_config.get('processors', []) self.log.info('__init__: global_processors=%s', self.global_processors) @@ -178,15 +174,6 @@ class Manager(object): self.log.info('_config_include_meta: include_meta=%s', include_meta) return include_meta - def _config_auto_arpa(self, manager_config, auto_arpa=False): - auto_arpa = auto_arpa or manager_config.get('auto-arpa', False) - self.log.info('_config_auto_arpa: auto_arpa=%s', auto_arpa) - if auto_arpa: - if not isinstance(auto_arpa, dict): - auto_arpa = {} - return AutoArpa(**auto_arpa) - return None - def _config_providers(self, providers_config): self.log.debug('_config_providers: configuring providers') providers = {} @@ -215,9 +202,6 @@ class Manager(object): 'Incorrect provider config for ' + provider_name ) - if self.auto_arpa: - providers['auto-arpa'] = self.auto_arpa - return providers def _config_processors(self, processors_config): @@ -245,10 +229,6 @@ class Manager(object): raise ManagerException( 'Incorrect processor config for ' + processor_name ) - - if self.auto_arpa: - processors['auto-arpa'] = self.auto_arpa - return processors def _config_plan_outputs(self, plan_outputs_config): @@ -497,7 +477,6 @@ class Manager(object): aliased_zones = {} futures = [] - arpa_kwargs = [] for zone_name, config in zones.items(): decoded_zone_name = idna_decode(zone_name) self.log.info('sync: zone=%s', decoded_zone_name) @@ -558,9 +537,6 @@ class Manager(object): collected = [] for processor in self.global_processors + processors: collected.append(self.processors[processor]) - # always goes last - if self.auto_arpa: - collected.append(self.auto_arpa) processors = collected except KeyError: raise ManagerException( @@ -596,25 +572,16 @@ class Manager(object): f'Zone {decoded_zone_name}, unknown ' f'target: {target}' ) - kwargs = { - 'zone_name': zone_name, - 'processors': processors, - 'sources': sources, - 'targets': targets, - 'lenient': lenient, - } - if self.auto_arpa and ( - zone_name.endswith('in-addr.arpa.') - or zone_name.endswith('ip6.arpa.') - ): - # auto arpa is enabled so we need to defer processing all arpa - # zones until after general ones have completed (async) so that - # they'll have access to all the recorded A/AAAA values - arpa_kwargs.append(kwargs) - else: - futures.append( - self._executor.submit(self._populate_and_plan, **kwargs) + futures.append( + self._executor.submit( + self._populate_and_plan, + zone_name, + processors, + sources, + targets, + lenient=lenient, ) + ) # Wait on all results and unpack/flatten the plans and store the # desired states in case we need them below @@ -654,24 +621,6 @@ class Manager(object): # as these are aliased zones plans += [p for f in futures for p in f.result()[0]] - if self.auto_arpa and arpa_kwargs: - self.log.info( - 'sync: processing %d arpa reverse dns zones', len(arpa_kwargs) - ) - # all the general zones are done and we've recorded the A/AAAA - # records with the AutoPtr. We can no process ptr zones - futures = [] - for kwargs in arpa_kwargs: - futures.append( - self._executor.submit(self._populate_and_plan, **kwargs) - ) - - for future in futures: - ps, d = future.result() - desired[d.name] = d - for plan in ps: - plans.append(plan) - # Best effort sort plans children first so that we create/update # children zones before parents which should allow us to more safely # extract things into sub-zones. Combining a child back into a parent diff --git a/tests/config/simple.yaml b/tests/config/simple.yaml index 561139b..5040298 100644 --- a/tests/config/simple.yaml +++ b/tests/config/simple.yaml @@ -1,5 +1,4 @@ manager: - auto-arpa: true max_workers: 2 providers: in: @@ -45,8 +44,3 @@ zones: - in targets: - dump - 3.2.1.in-addr.arpa.: - sources: - - auto-arpa - targets: - - dump diff --git a/tests/test_octodns_manager.py b/tests/test_octodns_manager.py index e34f2a9..1a36723 100644 --- a/tests/test_octodns_manager.py +++ b/tests/test_octodns_manager.py @@ -136,7 +136,7 @@ class TestManager(TestCase): environ['YAML_TMP_DIR'] = tmpdir.dirname environ['YAML_TMP_DIR2'] = tmpdir.dirname tc = Manager(get_config_filename('simple.yaml')).sync(dry_run=False) - self.assertEqual(30, tc) + self.assertEqual(28, tc) # try with just one of the zones tc = Manager(get_config_filename('simple.yaml')).sync( @@ -160,13 +160,13 @@ class TestManager(TestCase): tc = Manager(get_config_filename('simple.yaml')).sync( dry_run=False, force=True ) - self.assertEqual(30, tc) + self.assertEqual(28, tc) # Again with max_workers = 1 tc = Manager( get_config_filename('simple.yaml'), max_workers=1 ).sync(dry_run=False, force=True) - self.assertEqual(30, tc) + self.assertEqual(28, tc) # Include meta tc = Manager( @@ -174,7 +174,7 @@ class TestManager(TestCase): max_workers=1, include_meta=True, ).sync(dry_run=False, force=True) - self.assertEqual(36, tc) + self.assertEqual(33, tc) def test_idna_eligible_zones(self): # loading w/simple, but we'll be blowing it away and doing some manual @@ -186,6 +186,9 @@ class TestManager(TestCase): manager.config['zones'] = manager._config_zones( {'déjà.vu.': {}, 'deja.vu.': {}, idna_encode('こんにちは.jp.'): {}} ) + from pprint import pprint + + pprint(manager.config['zones']) # refer to them with utf-8 with self.assertRaises(ManagerException) as ctx: