diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4dff5..d0ab5d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## v1.3.0 - 2023-??-?? - ??? + +* Added ZoneNameFilter processor to enable ignoring/alerting on type-os like + octodns.com.octodns.com + ## v1.2.1 - 2023-09-29 - Now with fewer stale files * Update script/release to do clean room dist builds diff --git a/octodns/processor/filter.py b/octodns/processor/filter.py index f661dbf..4723073 100644 --- a/octodns/processor/filter.py +++ b/octodns/processor/filter.py @@ -4,10 +4,45 @@ from re import compile as re_compile +from ..record.exception import ValidationError from .base import BaseProcessor -class TypeAllowlistFilter(BaseProcessor): +class AllowsMixin: + def matches(self, zone, record): + pass + + def doesnt_match(self, zone, record): + zone.remove_record(record) + + +class RejectsMixin: + def matches(self, zone, record): + zone.remove_record(record) + + def doesnt_match(self, zone, record): + pass + + +class _TypeBaseFilter(BaseProcessor): + def __init__(self, name, _list): + super().__init__(name) + self._list = set(_list) + + def _process(self, zone, *args, **kwargs): + for record in zone.records: + if record._type in self._list: + self.matches(zone, record) + else: + self.doesnt_match(zone, record) + + return zone + + process_source_zone = _process + process_target_zone = _process + + +class TypeAllowlistFilter(_TypeBaseFilter, AllowsMixin): '''Only manage records of the specified type(s). Example usage: @@ -30,21 +65,10 @@ class TypeAllowlistFilter(BaseProcessor): ''' def __init__(self, name, allowlist): - super().__init__(name) - self.allowlist = set(allowlist) - - def _process(self, zone, *args, **kwargs): - for record in zone.records: - if record._type not in self.allowlist: - zone.remove_record(record) - - return zone - - process_source_zone = _process - process_target_zone = _process + super().__init__(name, allowlist) -class TypeRejectlistFilter(BaseProcessor): +class TypeRejectlistFilter(_TypeBaseFilter, RejectsMixin): '''Ignore records of the specified type(s). Example usage: @@ -66,18 +90,7 @@ class TypeRejectlistFilter(BaseProcessor): ''' def __init__(self, name, rejectlist): - super().__init__(name) - self.rejectlist = set(rejectlist) - - def _process(self, zone, *args, **kwargs): - for record in zone.records: - if record._type in self.rejectlist: - zone.remove_record(record) - - return zone - - process_source_zone = _process - process_target_zone = _process + super().__init__(name, rejectlist) class _NameBaseFilter(BaseProcessor): @@ -93,8 +106,25 @@ class _NameBaseFilter(BaseProcessor): self.exact = exact self.regex = regex + def _process(self, zone, *args, **kwargs): + for record in zone.records: + name = record.name + if name in self.exact: + self.matches(zone, record) + continue + elif any(r.search(name) for r in self.regex): + self.matches(zone, record) + continue + + self.doesnt_match(zone, record) + + return zone + + process_source_zone = _process + process_target_zone = _process -class NameAllowlistFilter(_NameBaseFilter): + +class NameAllowlistFilter(_NameBaseFilter, AllowsMixin): '''Only manage records with names that match the provider patterns Example usage: @@ -125,23 +155,8 @@ class NameAllowlistFilter(_NameBaseFilter): def __init__(self, name, allowlist): super().__init__(name, allowlist) - def _process(self, zone, *args, **kwargs): - for record in zone.records: - name = record.name - if name in self.exact: - continue - elif any(r.search(name) for r in self.regex): - continue - - zone.remove_record(record) - - return zone - - process_source_zone = _process - process_target_zone = _process - -class NameRejectlistFilter(_NameBaseFilter): +class NameRejectlistFilter(_NameBaseFilter, RejectsMixin): '''Reject managing records with names that match the provider patterns Example usage: @@ -172,17 +187,30 @@ class NameRejectlistFilter(_NameBaseFilter): def __init__(self, name, rejectlist): super().__init__(name, rejectlist) + +class IgnoreRootNsFilter(BaseProcessor): + '''Do not manage Root NS Records. + + Example usage: + + processors: + no-root-ns: + class: octodns.processor.filter.IgnoreRootNsFilter + + zones: + exxampled.com.: + sources: + - config + processors: + - no-root-ns + targets: + - ns1 + ''' + def _process(self, zone, *args, **kwargs): for record in zone.records: - name = record.name - if name in self.exact: + if record._type == 'NS' and not record.name: zone.remove_record(record) - continue - - for regex in self.regex: - if regex.search(name): - zone.remove_record(record) - break return zone @@ -190,29 +218,49 @@ class NameRejectlistFilter(_NameBaseFilter): process_target_zone = _process -class IgnoreRootNsFilter(BaseProcessor): - '''Do not manage Root NS Records. +class ZoneNameFilter(BaseProcessor): + '''Filter or error on record names that contain the zone name Example usage: processors: - no-root-ns: - class: octodns.processor.filter.IgnoreRootNsFilter + zone-name: + class: octodns.processor.filter.ZoneNameFilter + # If true a ValidationError will be throw when such records are + # encouterd, if false the records will just be ignored/omitted. + # (default: true) zones: exxampled.com.: sources: - config processors: - - no-root-ns + - zone-name targets: - - ns1 + - azure ''' + def __init__(self, name, error=True): + super().__init__(name) + self.error = error + def _process(self, zone, *args, **kwargs): + zone_name_with_dot = zone.name + zone_name_without_dot = zone_name_with_dot[:-1] for record in zone.records: - if record._type == 'NS' and not record.name: - zone.remove_record(record) + name = record.name + if name.endswith(zone_name_with_dot) or name.endswith( + zone_name_without_dot + ): + if self.error: + raise ValidationError( + record.fqdn, + ['record name ends with zone name'], + record.context, + ) + else: + # just remove it + zone.remove_record(record) return zone diff --git a/tests/test_octodns_processor_filter.py b/tests/test_octodns_processor_filter.py index a18eb51..9857926 100644 --- a/tests/test_octodns_processor_filter.py +++ b/tests/test_octodns_processor_filter.py @@ -10,8 +10,10 @@ from octodns.processor.filter import ( NameRejectlistFilter, TypeAllowlistFilter, TypeRejectlistFilter, + ZoneNameFilter, ) from octodns.record import Record +from octodns.record.exception import ValidationError from octodns.zone import Zone zone = Zone('unit.tests.', []) @@ -180,3 +182,70 @@ class TestIgnoreRootNsFilter(TestCase): [('A', ''), ('NS', 'sub')], sorted([(r._type, r.name) for r in filtered.records]), ) + + +class TestZoneNameFilter(TestCase): + def test_ends_with_zone(self): + zone_name_filter = ZoneNameFilter('zone-name', error=False) + + zone = Zone('unit.tests.', []) + + # something that doesn't come into play + zone.add_record( + Record.new( + zone, 'www', {'type': 'A', 'ttl': 43, 'value': '1.2.3.4'} + ) + ) + + # something that has the zone name, but doesn't end with it + zone.add_record( + Record.new( + zone, + f'{zone.name}more', + {'type': 'A', 'ttl': 43, 'value': '1.2.3.4'}, + ) + ) + + self.assertEqual(2, len(zone.records)) + filtered = zone_name_filter.process_source_zone(zone.copy()) + # get everything back + self.assertEqual(2, len(filtered.records)) + + with_dot = zone.copy() + with_dot.add_record( + Record.new( + zone, zone.name, {'type': 'A', 'ttl': 43, 'value': '1.2.3.4'} + ) + ) + self.assertEqual(3, len(with_dot.records)) + filtered = zone_name_filter.process_source_zone(with_dot.copy()) + # don't get the one that ends with the zone name + self.assertEqual(2, len(filtered.records)) + + without_dot = zone.copy() + without_dot.add_record( + Record.new( + zone, + zone.name[:-1], + {'type': 'A', 'ttl': 43, 'value': '1.2.3.4'}, + ) + ) + self.assertEqual(3, len(without_dot.records)) + filtered = zone_name_filter.process_source_zone(without_dot.copy()) + # don't get the one that ends with the zone name + self.assertEqual(2, len(filtered.records)) + + def test_error(self): + errors = ZoneNameFilter('zone-name', error=True) + + zone = Zone('unit.tests.', []) + zone.add_record( + Record.new( + zone, zone.name, {'type': 'A', 'ttl': 43, 'value': '1.2.3.4'} + ) + ) + with self.assertRaises(ValidationError) as ctx: + errors.process_source_zone(zone) + self.assertEqual( + ['record name ends with zone name'], ctx.exception.reasons + )