Browse Source

Merge pull request #1049 from octodns/zone-exception-context-support

Record's carry context, Zone exceptions make use of it to help with error messages
pull/1052/head
Ross McFarland 2 years ago
committed by GitHub
parent
commit
fa732a4fbc
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 154 additions and 22 deletions
  1. +11
    -6
      octodns/record/base.py
  2. +43
    -9
      octodns/zone.py
  3. +12
    -6
      tests/test_octodns_provider_yaml.py
  4. +10
    -0
      tests/test_octodns_record.py
  5. +78
    -1
      tests/test_octodns_zone.py

+ 11
- 6
octodns/record/base.py View File

@ -5,6 +5,7 @@
from collections import defaultdict from collections import defaultdict
from logging import getLogger from logging import getLogger
from ..context import ContextDict
from ..equality import EqualityTupleMixin from ..equality import EqualityTupleMixin
from ..idna import IdnaError, idna_decode, idna_encode from ..idna import IdnaError, idna_decode, idna_encode
from .change import Update from .change import Update
@ -73,7 +74,7 @@ class Record(EqualityTupleMixin):
) )
else: else:
raise ValidationError(fqdn, reasons, context) raise ValidationError(fqdn, reasons, context)
return _class(zone, name, data, source=source)
return _class(zone, name, data, source=source, context=context)
@classmethod @classmethod
def validate(cls, name, fqdn, data): def validate(cls, name, fqdn, data):
@ -136,7 +137,7 @@ class Record(EqualityTupleMixin):
def parse_rdata_texts(cls, rdatas): def parse_rdata_texts(cls, rdatas):
return [cls._value_type.parse_rdata_text(r) for r in rdatas] return [cls._value_type.parse_rdata_text(r) for r in rdatas]
def __init__(self, zone, name, data, source=None):
def __init__(self, zone, name, data, source=None, context=None):
self.zone = zone self.zone = zone
if name: if name:
# internally everything is idna # internally everything is idna
@ -152,11 +153,14 @@ class Record(EqualityTupleMixin):
self.decoded_name, self.decoded_name,
) )
self.source = source self.source = source
self.context = context
self.ttl = int(data['ttl']) self.ttl = int(data['ttl'])
self._octodns = data.get('octodns', {}) self._octodns = data.get('octodns', {})
def _data(self): def _data(self):
if self.context:
return ContextDict({'ttl': self.ttl}, context=self.context)
return {'ttl': self.ttl} return {'ttl': self.ttl}
@property @property
@ -225,6 +229,7 @@ class Record(EqualityTupleMixin):
return Update(self, other) return Update(self, other)
def copy(self, zone=None): def copy(self, zone=None):
# data, via _data(), will preserve context
data = self.data data = self.data
data['type'] = self._type data['type'] = self._type
data['octodns'] = self._octodns data['octodns'] = self._octodns
@ -271,8 +276,8 @@ class ValuesMixin(object):
values = [cls._value_type.parse_rdata_text(rr.rdata) for rr in rrs] values = [cls._value_type.parse_rdata_text(rr.rdata) for rr in rrs]
return {'ttl': rr.ttl, 'type': rr._type, 'values': values} return {'ttl': rr.ttl, 'type': rr._type, 'values': values}
def __init__(self, zone, name, data, source=None):
super().__init__(zone, name, data, source=source)
def __init__(self, zone, name, data, source=None, context=None):
super().__init__(zone, name, data, source=source, context=context)
try: try:
values = data['values'] values = data['values']
except KeyError: except KeyError:
@ -333,8 +338,8 @@ class ValueMixin(object):
'value': cls._value_type.parse_rdata_text(rr.rdata), 'value': cls._value_type.parse_rdata_text(rr.rdata),
} }
def __init__(self, zone, name, data, source=None):
super().__init__(zone, name, data, source=source)
def __init__(self, zone, name, data, source=None, context=None):
super().__init__(zone, name, data, source=source, context=context)
self.value = self._value_type.process(data['value']) self.value = self._value_type.process(data['value'])
def changes(self, other, target): def changes(self, other, target):


+ 43
- 9
octodns/zone.py View File

@ -11,15 +11,45 @@ from .record import Create, Delete
class SubzoneRecordException(Exception): class SubzoneRecordException(Exception):
pass
def __init__(self, msg, record):
self.record = record
if record.context:
msg += f', {record.context}'
super().__init__(msg)
class DuplicateRecordException(Exception): class DuplicateRecordException(Exception):
pass
def __init__(self, msg, existing, new):
self.existing = existing
self.new = new
if existing.context:
if new.context:
# both have context
msg += f'\n existing: {existing.context}\n new: {new.context}'
else:
# only existing has context
msg += (
f'\n existing: {existing.context}\n new: [UNKNOWN]'
)
elif new.context:
# only new has context
msg += f'\n existing: [UNKNOWN]\n new: {new.context}'
# else no one has context
super().__init__(msg)
class InvalidNodeException(Exception): class InvalidNodeException(Exception):
pass
def __init__(self, msg, record):
self.record = record
if record.context:
msg += f', {record.context}'
super().__init__(msg)
class Zone(object): class Zone(object):
@ -113,7 +143,8 @@ class Zone(object):
if not record._type == 'NS': if not record._type == 'NS':
# and not a NS record, this should be in the sub # and not a NS record, this should be in the sub
raise SubzoneRecordException( raise SubzoneRecordException(
f'Record {record.fqdn} is a managed sub-zone and not of type NS'
f'Record {record.fqdn} is a managed sub-zone and not of type NS',
record,
) )
else: else:
# It's not an exact match so there has to be a `.` before the # It's not an exact match so there has to be a `.` before the
@ -122,7 +153,8 @@ class Zone(object):
if name.endswith(f'.{sub_zone}'): if name.endswith(f'.{sub_zone}'):
# this should be in a sub # this should be in a sub
raise SubzoneRecordException( raise SubzoneRecordException(
f'Record {record.fqdn} is under a managed subzone'
f'Record {record.fqdn} is under a managed subzone',
record,
) )
if replace: if replace:
@ -132,8 +164,11 @@ class Zone(object):
node = self._records[name] node = self._records[name]
if record in node: if record in node:
# We already have a record at this node of this type # We already have a record at this node of this type
existing = [c for c in node if c == record][0]
raise DuplicateRecordException( raise DuplicateRecordException(
f'Duplicate record {record.fqdn}, ' f'type {record._type}'
f'Duplicate record {record.fqdn}, type {record._type}',
existing,
record,
) )
elif not lenient and ( elif not lenient and (
(record._type == 'CNAME' and len(node) > 0) (record._type == 'CNAME' and len(node) > 0)
@ -142,9 +177,8 @@ class Zone(object):
# We're adding a CNAME to existing records or adding to an existing # We're adding a CNAME to existing records or adding to an existing
# CNAME # CNAME
raise InvalidNodeException( raise InvalidNodeException(
'Invalid state, CNAME at '
f'{record.fqdn} cannot coexist with '
'other records'
f'Invalid state, CNAME at {record.fqdn} cannot coexist with other records',
record,
) )
if record._type == 'NS' and record.name == '': if record._type == 'NS' and record.name == '':


+ 12
- 6
tests/test_octodns_provider_yaml.py View File

@ -260,10 +260,13 @@ xn--dj-kia8a:
zone = Zone('unit.tests.', ['sub']) zone = Zone('unit.tests.', ['sub'])
with self.assertRaises(SubzoneRecordException) as ctx: with self.assertRaises(SubzoneRecordException) as ctx:
source.populate(zone) source.populate(zone)
self.assertEqual(
'Record www.sub.unit.tests. is under a managed subzone',
str(ctx.exception),
msg = str(ctx.exception)
self.assertTrue(
msg.startswith(
'Record www.sub.unit.tests. is under a managed subzone'
)
) )
self.assertTrue(msg.endswith('unit.tests.yaml, line 201, column 3'))
def test_SUPPORTS(self): def test_SUPPORTS(self):
source = YamlProvider('test', join(dirname(__file__), 'config')) source = YamlProvider('test', join(dirname(__file__), 'config'))
@ -536,10 +539,13 @@ class TestSplitYamlProvider(TestCase):
zone = Zone('unit.tests.', ['sub']) zone = Zone('unit.tests.', ['sub'])
with self.assertRaises(SubzoneRecordException) as ctx: with self.assertRaises(SubzoneRecordException) as ctx:
source.populate(zone) source.populate(zone)
self.assertEqual(
'Record www.sub.unit.tests. is under a managed subzone',
str(ctx.exception),
msg = str(ctx.exception)
self.assertTrue(
msg.startswith(
'Record www.sub.unit.tests. is under a managed subzone'
)
) )
self.assertTrue(msg.endswith('www.sub.yaml, line 3, column 3'))
def test_copy(self): def test_copy(self):
# going to put some sentinal values in here to ensure, these aren't # going to put some sentinal values in here to ensure, these aren't


+ 10
- 0
tests/test_octodns_record.py View File

@ -628,3 +628,13 @@ class TestRecordValidation(TestCase):
ContextDict({'ttl': 42, 'value': '1.2.3.4'}, context='needle'), ContextDict({'ttl': 42, 'value': '1.2.3.4'}, context='needle'),
) )
self.assertTrue('needle' in str(ctx.exception)) self.assertTrue('needle' in str(ctx.exception))
def test_context_copied_to_record(self):
record = Record.new(
self.zone,
'www',
ContextDict(
{'ttl': 42, 'type': 'A', 'value': '1.2.3.4'}, context='needle'
),
)
self.assertEqual('needle', record.context)

+ 78
- 1
tests/test_octodns_zone.py View File

@ -6,6 +6,7 @@ from unittest import TestCase
from helpers import SimpleProvider from helpers import SimpleProvider
from octodns.context import ContextDict
from octodns.idna import idna_encode from octodns.idna import idna_encode
from octodns.record import ( from octodns.record import (
AaaaRecord, AaaaRecord,
@ -106,6 +107,62 @@ class TestZone(TestCase):
zone.add_record(b) zone.add_record(b)
self.assertEqual(zone.records, set([a, b])) self.assertEqual(zone.records, set([a, b]))
def test_duplicate_context_handling(self):
zone = Zone('unit.tests.', [])
# these will be ==, but one has context and the other doesn't
no_context = ARecord(zone, 'a', {'ttl': 42, 'value': '1.1.1.1'})
has_context = ARecord(
zone, 'a', {'ttl': 42, 'value': '1.1.1.1'}, context='hello world'
)
# both have context
zone.add_record(has_context)
with self.assertRaises(DuplicateRecordException) as ctx:
zone.add_record(has_context)
self.assertEqual(has_context, ctx.exception.existing)
self.assertEqual(has_context, ctx.exception.new)
zone.remove_record(has_context)
self.assertEqual(
[
'Duplicate record a.unit.tests., type A',
' existing: hello world',
' new: hello world',
],
str(ctx.exception).split('\n'),
)
# new has context
zone.add_record(no_context)
with self.assertRaises(DuplicateRecordException) as ctx:
zone.add_record(has_context)
self.assertEqual(no_context, ctx.exception.existing)
self.assertEqual(has_context, ctx.exception.new)
zone.remove_record(no_context)
self.assertEqual(
[
'Duplicate record a.unit.tests., type A',
' existing: [UNKNOWN]',
' new: hello world',
],
str(ctx.exception).split('\n'),
)
# existing has context
zone.add_record(has_context)
with self.assertRaises(DuplicateRecordException) as ctx:
zone.add_record(no_context)
self.assertEqual(has_context, ctx.exception.existing)
self.assertEqual(no_context, ctx.exception.new)
self.assertEqual(
[
'Duplicate record a.unit.tests., type A',
' existing: hello world',
' new: [UNKNOWN]',
],
str(ctx.exception).split('\n'),
)
def test_changes(self): def test_changes(self):
before = Zone('unit.tests.', []) before = Zone('unit.tests.', [])
a = ARecord(before, 'a', {'ttl': 42, 'value': '1.1.1.1'}) a = ARecord(before, 'a', {'ttl': 42, 'value': '1.1.1.1'})
@ -242,9 +299,11 @@ class TestZone(TestCase):
'sub', 'sub',
{'ttl': 3600, 'type': 'A', 'values': ['1.2.3.4', '2.3.4.5']}, {'ttl': 3600, 'type': 'A', 'values': ['1.2.3.4', '2.3.4.5']},
) )
record.context = 'added context'
with self.assertRaises(SubzoneRecordException) as ctx: with self.assertRaises(SubzoneRecordException) as ctx:
zone.add_record(record) zone.add_record(record)
self.assertTrue('not of type NS', str(ctx.exception)) self.assertTrue('not of type NS', str(ctx.exception))
self.assertTrue(', added context' in str(ctx.exception))
# Can add it w/lenient # Can add it w/lenient
zone.add_record(record, lenient=True) zone.add_record(record, lenient=True)
self.assertEqual(set([record]), zone.records) self.assertEqual(set([record]), zone.records)
@ -328,11 +387,13 @@ class TestZone(TestCase):
cname = Record.new( cname = Record.new(
zone, 'www', {'ttl': 60, 'type': 'CNAME', 'value': 'foo.bar.com.'} zone, 'www', {'ttl': 60, 'type': 'CNAME', 'value': 'foo.bar.com.'}
) )
cname.context = 'has some context'
# add cname to a # add cname to a
zone.add_record(a) zone.add_record(a)
with self.assertRaises(InvalidNodeException):
with self.assertRaises(InvalidNodeException) as ctx:
zone.add_record(cname) zone.add_record(cname)
self.assertTrue(', has some context' in str(ctx.exception))
self.assertEqual(set([a]), zone.records) self.assertEqual(set([a]), zone.records)
zone.add_record(cname, lenient=True) zone.add_record(cname, lenient=True)
self.assertEqual(set([a, cname]), zone.records) self.assertEqual(set([a, cname]), zone.records)
@ -501,6 +562,22 @@ class TestZone(TestCase):
# Doesn't the second # Doesn't the second
self.assertFalse(copy.hydrate()) self.assertFalse(copy.hydrate())
def test_copy_context(self):
zone = Zone('unit.tests.', [])
no_context = Record.new(
zone, 'a', {'ttl': 42, 'type': 'A', 'value': '1.1.1.1'}
)
self.assertFalse(no_context.context)
self.assertFalse(no_context.copy().context)
data = ContextDict(
{'ttl': 42, 'type': 'A', 'value': '1.1.1.1'}, context='hello world'
)
has_context = Record.new(zone, 'a', data)
self.assertTrue(has_context.context)
self.assertTrue(has_context.copy().context)
def test_root_ns(self): def test_root_ns(self):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])


Loading…
Cancel
Save