From 86bd8ebff5db518ed15c0bd8dfb58030ffd3b436 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Fri, 8 Sep 2023 14:29:19 -0700 Subject: [PATCH 1/7] ZoneFileProvider, read and write support added --- CHANGELOG.md | 1 + octodns_bind/__init__.py | 190 ++++++++++++++++++++--- tests/test_provider_octodns_bind.py | 227 +++++++++++++++++++++++++++- 3 files changed, 396 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0deb1cb..43653c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## v0.0.5 - 2023-??-?? - ??? +- ZoneFileProvider, full support writing zone files out to disk - ZoneFileSource.list_zones added to support dynamic zone config ## v0.0.4 - 2023-05-23 - First Stop /etc/hosts diff --git a/octodns_bind/__init__.py b/octodns_bind/__init__.py index 116a107..41d6112 100644 --- a/octodns_bind/__init__.py +++ b/octodns_bind/__init__.py @@ -3,9 +3,11 @@ # import socket +from datetime import datetime from logging import getLogger -from os import listdir -from os.path import join +from os import listdir, makedirs +from os.path import isdir, join +from string import Template import dns.name import dns.query @@ -24,8 +26,11 @@ class RfcPopulate: - SUPPORTS_GEO = False SUPPORTS_DYNAMIC = False + SUPPORTS_GEO = False + SUPPORTS_MULTIVALUE_PTR = True + SUPPORTS_ROOT_NS = True + SUPPORTS = set( ( 'A', @@ -53,7 +58,7 @@ def populate(self, zone, target=False, lenient=False): ) before = len(zone.records) - rrs = self.zone_records(zone) + rrs = self.zone_records(zone, target=target) for record in Record.from_rrs(zone, rrs, lenient=lenient): zone.add_record(record, lenient=lenient) @@ -79,21 +84,64 @@ def __init__(self, error): super().__init__(str(error)) -class ZoneFileSource(RfcPopulate, BaseSource): - def __init__(self, id, directory, file_extension='.', check_origin=True): - self.log = getLogger(f'ZoneFileSource[{id}]') +class ZoneFileProvider(RfcPopulate, BaseProvider): + ''' + Provider that reads and writes BIND style zone files + + config: + class: octodns_bind.ZoneFileProvider + + # The location of zone files. Records are defined in a + # file named for the zone in this directory, e.g. + # something.com., including the trailing ., see `file_extension` below + # (required) + directory: ./config + + # The extension to use when working with zone files. It is appended onto + # the zone name to determine the file when reading or writing + # records. Some operating systems do not allow filenames ending with a . + # and this value may need to be changed when working on them, e.g. to + # .zone. The leading . should be included. + # (default: .) + file_extension: . + + # Wether the provider should check for the existence a root NS record + # when loading a zone + # (default: true) + check_origin: true + + # The email username or full address to be used when creating zonefiles. + # If this is just a username, no @[domain.com.], the current zone name + # will be appended to this value. If the value is a complete email + # address it will be used as-is. Note that the actual email address with + # a @ should be used and not the zone file format with the value + # replaced with a `.`. + # (default: webmaster) + hostmaster_email: webmaster + ''' + + def __init__( + self, + id, + directory, + file_extension='.', + check_origin=True, + hostmaster_email='webmaster', + ): + self.log = getLogger(f'ZoneFileProvider[{id}]') self.log.debug( - '__init__: id=%s, directory=%s, file_extension=%s, ' - 'check_origin=%s', + '__init__: id=%s, directory=%s, file_extension=%s, check_origin=%s, hostmaster_email=%s', id, directory, file_extension, check_origin, + hostmaster_email, ) super().__init__(id) self.directory = directory self.file_extension = file_extension self.check_origin = check_origin + self.hostmaster_email = hostmaster_email self._zone_records = {} @@ -105,7 +153,12 @@ def list_zones(self): filename = filename[:-n] yield filename - def _load_zone_file(self, zone_name): + def _load_zone_file(self, zone_name, target): + if target: + # if we're in target mode we assume nothing exists b/c we recreate + # everything every time, similar to YamlProvider + return None + zone_filename = f'{zone_name[:-1]}{self.file_extension}' zonefiles = listdir(self.directory) path = join(self.directory, zone_filename) @@ -124,22 +177,121 @@ def _load_zone_file(self, zone_name): return z - def zone_records(self, zone): + def zone_records(self, zone, target): if zone.name not in self._zone_records: - z = self._load_zone_file(zone.name) + z = self._load_zone_file(zone.name, target) records = [] - for name, ttl, rdata in z.iterate_rdatas(): - rdtype = dns.rdatatype.to_text(rdata.rdtype) - if rdtype in self.SUPPORTS: - records.append( - Rr(name.to_text(), rdtype, ttl, rdata.to_text()) - ) + if z: + for name, ttl, rdata in z.iterate_rdatas(): + rdtype = dns.rdatatype.to_text(rdata.rdtype) + if rdtype in self.SUPPORTS: + records.append( + Rr(name.to_text(), rdtype, ttl, rdata.to_text()) + ) self._zone_records[zone.name] = records return self._zone_records[zone.name] + def _primary_nameserver(self, decoded_name, records): + for record in records: + if record.name == '' and record._type == 'NS': + return record.values[0] + self.log.warning( + '_primary_nameserver: unable to find a primary_nameserver for %s, using placeholder', + decoded_name, + ) + return f'ns.{decoded_name}' + + def _hostmaster_email(self, decoded_name): + pieces = self.hostmaster_email.split('@') + # escape any .'s in the email username + pieces[0] = pieces[0].replace('.', '\\.') + if len(pieces) == 2: + return '.'.join(pieces) + + return f'{pieces[0]}.{decoded_name}' + + def _longest_name(self, records): + try: + return sorted(len(r.name) for r in records)[-1] + except IndexError: + return 0 + + def _now(self): + return datetime.utcnow() + + def _serial(self): + # things wrap/reset at max int + return int(self._now().timestamp()) % 2147483647 + + def _apply(self, plan): + desired = plan.desired + name = desired.decoded_name + + if not isdir(self.directory): + makedirs(self.directory) + + records = sorted(c.record for c in plan.changes) + longest_name = self._longest_name(records) + + filename = join(self.directory, f'{name[:-1]}{self.file_extension}') + with open(filename, 'w') as fh: + template = Template( + '''$$ORIGIN $zone_name + +@ $default_ttl IN SOA $primary_nameserver $hostmaster_email ( + $serial ; Serial + 3600 ; Refresh (1 hour) + 600 ; Retry (10 minutes) + 604800 ; Expire (1 week) + 3600 ; NXDOMAIN ttl (1 hour) +) + +''' + ) + + primary_nameserver = self._primary_nameserver(name, records) + fh.write( + template.substitute( + { + 'hostmaster_email': self._hostmaster_email(name), + 'serial': self._serial(), + 'zone_name': name, + 'default_ttl': 3600, + 'primary_nameserver': primary_nameserver, + } + ) + ) + + prev_name = None + for record in records: + try: + values = record.values + except AttributeError: + values = [record.value] + for value in values: + name = '@' if record.name == '' else record.name + if name == prev_name: + name = '' + else: + prev_name = name + fh.write( + f'{name:<{longest_name}} {record.ttl:8d} IN {record._type:<8} ' + ) + fh.write(value.rdata_text) + fh.write('\n') + + self.log.debug( + '_apply: zone=%s, num_records=%d', name, len(plan.changes) + ) + + return True + + +ZoneFileSource = ZoneFileProvider + class AxfrSourceException(Exception): pass @@ -210,7 +362,7 @@ def _auth_params(self): params['keyalgorithm'] = self.key_algorithm return params - def zone_records(self, zone): + def zone_records(self, zone, target): auth_params = self._auth_params() try: z = dns.zone.from_xfr( diff --git a/tests/test_provider_octodns_bind.py b/tests/test_provider_octodns_bind.py index 4267cec..16029f3 100644 --- a/tests/test_provider_octodns_bind.py +++ b/tests/test_provider_octodns_bind.py @@ -3,8 +3,9 @@ # import socket -from os.path import exists -from shutil import copyfile +from os.path import exists, join +from shutil import copyfile, rmtree +from tempfile import mkdtemp from unittest import TestCase from unittest.mock import patch @@ -12,7 +13,8 @@ import dns.zone from dns.exception import DNSException -from octodns.record import Record, Rr, ValidationError +from octodns.provider.plan import Plan +from octodns.record import Create, Record, Rr, ValidationError from octodns.zone import Zone from octodns_bind import ( @@ -20,12 +22,28 @@ AxfrSourceZoneTransferFailed, Rfc2136Provider, Rfc2136ProviderUpdateFailed, + ZoneFileProvider, ZoneFileSource, ZoneFileSourceLoadFailure, ZoneFileSourceNotFound, ) +class TemporaryDirectory(object): + def __init__(self, delete_on_exit=True): + self.delete_on_exit = delete_on_exit + + def __enter__(self): + self.dirname = mkdtemp() + return self + + def __exit__(self, *args, **kwargs): + if self.delete_on_exit: + rmtree(self.dirname) + else: + raise Exception(self.dirname) + + class TestAxfrSource(TestCase): source = AxfrSource('test', '127.0.0.1') @@ -160,6 +178,209 @@ def test_list_zones(self): ['2.0.192.in-addr.arpa.', 'unit.tests.'], list(source.list_zones()) ) + @patch('octodns_bind.ZoneFileProvider._serial') + def test_apply(self, serial_mock): + serial_mock.side_effect = [424344, 454647] + + with TemporaryDirectory() as td: + provider = ZoneFileProvider('target', td.dirname) + + # no root NS + desired = Zone('unit.tests.', []) + + # populate as a target, shouldn't find anything, file wouldn't even + # exist + provider.populate(desired, target=True) + self.assertEqual(0, len(desired.records)) + + cname = Record.new( + desired, + 'cname', + {'type': 'CNAME', 'ttl': 42, 'value': 'target.unit.tests.'}, + ) + desired.add_record(cname) + + changes = [Create(cname)] + plan = Plan(None, desired, changes, True) + provider._apply(plan) + + with open(join(td.dirname, 'unit.tests.')) as fh: + self.assertEqual( + '''$ORIGIN unit.tests. + +@ 3600 IN SOA ns.unit.tests. webmaster.unit.tests. ( + 424344 ; Serial + 3600 ; Refresh (1 hour) + 600 ; Retry (10 minutes) + 604800 ; Expire (1 week) + 3600 ; NXDOMAIN ttl (1 hour) +) + +cname 42 IN CNAME target.unit.tests. +''', + fh.read(), + ) + + # add a subdirectory + provider.directory += '/subdir' + + # with a NS this time + ns = Record.new( + desired, + '', + { + 'type': 'NS', + 'ttl': 43, + 'values': ('ns1.unit.tests.', 'ns2.unit.tests.'), + }, + ) + desired.add_record(ns) + # and a second record with the same name (apex) + a = Record.new( + desired, '', {'type': 'A', 'ttl': 44, 'value': '1.2.3.4'} + ) + desired.add_record(a) + + plan.changes = [Create(a), Create(ns)] + plan.changes + provider._apply(plan) + + with open(join(td.dirname, 'subdir', 'unit.tests.')) as fh: + self.assertEqual( + '''$ORIGIN unit.tests. + +@ 3600 IN SOA ns1.unit.tests. webmaster.unit.tests. ( + 454647 ; Serial + 3600 ; Refresh (1 hour) + 600 ; Retry (10 minutes) + 604800 ; Expire (1 week) + 3600 ; NXDOMAIN ttl (1 hour) +) + +@ 44 IN A 1.2.3.4 + 43 IN NS ns1.unit.tests. + 43 IN NS ns2.unit.tests. +cname 42 IN CNAME target.unit.tests. +''', + fh.read(), + ) + + def test_primary_nameserver(self): + # no records (thus no root NS records) we get the placeholder + self.assertEqual( + 'ns.unit.tests.', self.source._primary_nameserver('unit.tests.', []) + ) + + class FakeNsRecord: + def __init__(self, name, values): + self.name = name + self.values = values + self._type = 'NS' + + # has non-root NS record, placeholder + self.assertEqual( + 'ns.unit.tests.', + self.source._primary_nameserver( + 'unit.tests.', [FakeNsRecord('not-root', ['xx.unit.tests.'])] + ), + ) + + # has root NS record + self.assertEqual( + 'ns1.unit.tests.', + self.source._primary_nameserver( + 'unit.tests.', + [ + FakeNsRecord('not-root', ['xx.unit.tests.']), + FakeNsRecord('', ['ns1.unit.tests.', 'ns2.unit.tests.']), + ], + ), + ) + + def test_hostmaster_email(self): + # default constructed + self.assertEqual( + 'webmaster.unit.tests.', + self.source._hostmaster_email('unit.tests.'), + ) + + # overridden just username + source = ZoneFileProvider('test', '.', hostmaster_email='altusername') + self.assertEqual( + 'altusername.unit.tests.', source._hostmaster_email('unit.tests.') + ) + + # overridden username with . + source = ZoneFileProvider('test', '.', hostmaster_email='alt.username') + self.assertEqual( + 'alt\\.username.other.tests.', + source._hostmaster_email('other.tests.'), + ) + + # overridden full email addr + source = ZoneFileProvider( + 'test', '.', hostmaster_email='root@some.com.' + ) + self.assertEqual( + 'root.some.com.', source._hostmaster_email('ignored.tests.') + ) + + # overridden full email addr no trailing . + source = ZoneFileProvider('test', '.', hostmaster_email='root@some.com') + self.assertEqual( + 'root.some.com', source._hostmaster_email('ignored.tests.') + ) + + def test_longest_name(self): + # make sure empty doesn't blow up and we get 0 + self.assertEqual(0, self.source._longest_name([])) + + class FakeRecord: + def __init__(self, name): + self.name = name + + self.assertEqual( + 4, + self.source._longest_name( + [ + FakeRecord(''), + FakeRecord('1'), + FakeRecord('12'), + FakeRecord('123'), + FakeRecord('1234'), + ] + ), + ) + + @patch('octodns_bind.ZoneFileProvider._now') + def test_serial(self, now_mock): + class FakeDatetime: + def __init__(self, timestamp): + self._timestamp = timestamp + + def timestamp(self): + return self._timestamp + + now_mock.side_effect = [ + # simple + FakeDatetime(42), + # real + FakeDatetime(1694231210), + # max + FakeDatetime(2147483647), + # max + 1 + FakeDatetime(2147483647 + 1), + # max + 2 + FakeDatetime(2147483647 + 2), + ] + self.assertEqual(42, self.source._serial()) + self.assertEqual(1694231210, self.source._serial()) + self.assertEqual(0, self.source._serial()) + self.assertEqual(1, self.source._serial()) + + def test_now(self): + # smoke test + self.assertTrue(self.source._now()) + class TestRfc2136Provider(TestCase): def test_host_ip(self): From c95339d9f138422866d7422224a850e57c980f7a Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Fri, 8 Sep 2023 14:39:11 -0700 Subject: [PATCH 2/7] TXT rdata_text needs to be quoted --- octodns_bind/__init__.py | 9 ++++++--- tests/test_provider_octodns_bind.py | 31 ++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/octodns_bind/__init__.py b/octodns_bind/__init__.py index 41d6112..c5e74c6 100644 --- a/octodns_bind/__init__.py +++ b/octodns_bind/__init__.py @@ -272,16 +272,19 @@ def _apply(self, plan): except AttributeError: values = [record.value] for value in values: + if record._type == 'TXT': + # TXT values need to be quoted + value = f'"{value.rdata_text}"' + else: + value = value.rdata_text name = '@' if record.name == '' else record.name if name == prev_name: name = '' else: prev_name = name fh.write( - f'{name:<{longest_name}} {record.ttl:8d} IN {record._type:<8} ' + f'{name:<{longest_name}} {record.ttl:8d} IN {record._type:<8} {value}\n' ) - fh.write(value.rdata_text) - fh.write('\n') self.log.debug( '_apply: zone=%s, num_records=%d', name, len(plan.changes) diff --git a/tests/test_provider_octodns_bind.py b/tests/test_provider_octodns_bind.py index 16029f3..533304f 100644 --- a/tests/test_provider_octodns_bind.py +++ b/tests/test_provider_octodns_bind.py @@ -180,7 +180,7 @@ def test_list_zones(self): @patch('octodns_bind.ZoneFileProvider._serial') def test_apply(self, serial_mock): - serial_mock.side_effect = [424344, 454647] + serial_mock.side_effect = [424344, 454647, 484950] with TemporaryDirectory() as td: provider = ZoneFileProvider('target', td.dirname) @@ -260,6 +260,35 @@ def test_apply(self, serial_mock): 43 IN NS ns1.unit.tests. 43 IN NS ns2.unit.tests. cname 42 IN CNAME target.unit.tests. +''', + fh.read(), + ) + + # TXT record rrdata's are quoted + txt = Record.new( + desired, + 'txt', + {'type': 'TXT', 'ttl': 45, 'value': 'hello world'}, + ) + desired.add_record(txt) + + plan.changes = [Create(txt), Create(ns)] + provider._apply(plan) + with open(join(td.dirname, 'subdir', 'unit.tests.')) as fh: + self.assertEqual( + '''$ORIGIN unit.tests. + +@ 3600 IN SOA ns1.unit.tests. webmaster.unit.tests. ( + 484950 ; Serial + 3600 ; Refresh (1 hour) + 600 ; Retry (10 minutes) + 604800 ; Expire (1 week) + 3600 ; NXDOMAIN ttl (1 hour) +) + +@ 43 IN NS ns1.unit.tests. + 43 IN NS ns2.unit.tests. +txt 45 IN TXT "hello world" ''', fh.read(), ) From 6c2ab80ad4f62dffb6920be9a8bd0e6c182f0e60 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Fri, 8 Sep 2023 14:42:58 -0700 Subject: [PATCH 3/7] Escape " in TXT records, quote SPF too" --- octodns_bind/__init__.py | 8 ++++---- tests/test_provider_octodns_bind.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/octodns_bind/__init__.py b/octodns_bind/__init__.py index c5e74c6..d566386 100644 --- a/octodns_bind/__init__.py +++ b/octodns_bind/__init__.py @@ -272,11 +272,11 @@ def _apply(self, plan): except AttributeError: values = [record.value] for value in values: - if record._type == 'TXT': + value = value.rdata_text + if record._type in ('SPF', 'TXT'): # TXT values need to be quoted - value = f'"{value.rdata_text}"' - else: - value = value.rdata_text + value = value.replace('"', '\\"') + value = f'"{value}"' name = '@' if record.name == '' else record.name if name == prev_name: name = '' diff --git a/tests/test_provider_octodns_bind.py b/tests/test_provider_octodns_bind.py index 533304f..9cd6c1e 100644 --- a/tests/test_provider_octodns_bind.py +++ b/tests/test_provider_octodns_bind.py @@ -268,7 +268,7 @@ def test_apply(self, serial_mock): txt = Record.new( desired, 'txt', - {'type': 'TXT', 'ttl': 45, 'value': 'hello world'}, + {'type': 'TXT', 'ttl': 45, 'value': 'hello " world'}, ) desired.add_record(txt) @@ -288,7 +288,7 @@ def test_apply(self, serial_mock): @ 43 IN NS ns1.unit.tests. 43 IN NS ns2.unit.tests. -txt 45 IN TXT "hello world" +txt 45 IN TXT "hello \\" world" ''', fh.read(), ) From c7f704832e9ec19848cf2d9d632877e459068a3a Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Tue, 12 Sep 2023 10:49:59 -0700 Subject: [PATCH 4/7] allow customizing the SOA record details --- octodns_bind/__init__.py | 33 ++++++++++++++++++++++++----- tests/test_provider_octodns_bind.py | 30 +++++++++++++++----------- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/octodns_bind/__init__.py b/octodns_bind/__init__.py index d566386..a785cc0 100644 --- a/octodns_bind/__init__.py +++ b/octodns_bind/__init__.py @@ -118,6 +118,13 @@ class ZoneFileProvider(RfcPopulate, BaseProvider): # replaced with a `.`. # (default: webmaster) hostmaster_email: webmaster + + # The details of the SOA record can be customized when creating + # zonefiles with the following options. + refresh: 3600 + retry: 600 + expire: 604800 + nxdomain: 3600 ''' def __init__( @@ -127,21 +134,33 @@ def __init__( file_extension='.', check_origin=True, hostmaster_email='webmaster', + refresh=3600, + retry=600, + expire=604800, + nxdomain=3600, ): self.log = getLogger(f'ZoneFileProvider[{id}]') self.log.debug( - '__init__: id=%s, directory=%s, file_extension=%s, check_origin=%s, hostmaster_email=%s', + '__init__: id=%s, directory=%s, file_extension=%s, check_origin=%s, hostmaster_email=%s, refresh=%d, retry=%d, expire=%d, nxdomain=%d', id, directory, file_extension, check_origin, hostmaster_email, + refresh, + retry, + expire, + nxdomain, ) super().__init__(id) self.directory = directory self.file_extension = file_extension self.check_origin = check_origin self.hostmaster_email = hostmaster_email + self.refresh = refresh + self.retry = retry + self.expire = expire + self.nxdomain = nxdomain self._zone_records = {} @@ -243,10 +262,10 @@ def _apply(self, plan): @ $default_ttl IN SOA $primary_nameserver $hostmaster_email ( $serial ; Serial - 3600 ; Refresh (1 hour) - 600 ; Retry (10 minutes) - 604800 ; Expire (1 week) - 3600 ; NXDOMAIN ttl (1 hour) + $refresh ; Refresh (1 hour) + $retry ; Retry (10 minutes) + $expire ; Expire (1 week) + $nxdomain ; NXDOMAIN ttl (1 hour) ) ''' @@ -261,6 +280,10 @@ def _apply(self, plan): 'zone_name': name, 'default_ttl': 3600, 'primary_nameserver': primary_nameserver, + 'refresh': self.refresh, + 'retry': self.retry, + 'expire': self.expire, + 'nxdomain': self.nxdomain, } ) ) diff --git a/tests/test_provider_octodns_bind.py b/tests/test_provider_octodns_bind.py index 9cd6c1e..2050e06 100644 --- a/tests/test_provider_octodns_bind.py +++ b/tests/test_provider_octodns_bind.py @@ -210,10 +210,10 @@ def test_apply(self, serial_mock): @ 3600 IN SOA ns.unit.tests. webmaster.unit.tests. ( 424344 ; Serial - 3600 ; Refresh (1 hour) - 600 ; Retry (10 minutes) - 604800 ; Expire (1 week) - 3600 ; NXDOMAIN ttl (1 hour) + 3600 ; Refresh (1 hour) + 600 ; Retry (10 minutes) + 604800 ; Expire (1 week) + 3600 ; NXDOMAIN ttl (1 hour) ) cname 42 IN CNAME target.unit.tests. @@ -250,10 +250,10 @@ def test_apply(self, serial_mock): @ 3600 IN SOA ns1.unit.tests. webmaster.unit.tests. ( 454647 ; Serial - 3600 ; Refresh (1 hour) - 600 ; Retry (10 minutes) - 604800 ; Expire (1 week) - 3600 ; NXDOMAIN ttl (1 hour) + 3600 ; Refresh (1 hour) + 600 ; Retry (10 minutes) + 604800 ; Expire (1 week) + 3600 ; NXDOMAIN ttl (1 hour) ) @ 44 IN A 1.2.3.4 @@ -272,6 +272,12 @@ def test_apply(self, serial_mock): ) desired.add_record(txt) + # test out customizing the SOA details + provider.refresh = 3601 + provider.retry = 601 + provider.expire = 604801 + provider.nxdomain = 3601 + plan.changes = [Create(txt), Create(ns)] provider._apply(plan) with open(join(td.dirname, 'subdir', 'unit.tests.')) as fh: @@ -280,10 +286,10 @@ def test_apply(self, serial_mock): @ 3600 IN SOA ns1.unit.tests. webmaster.unit.tests. ( 484950 ; Serial - 3600 ; Refresh (1 hour) - 600 ; Retry (10 minutes) - 604800 ; Expire (1 week) - 3600 ; NXDOMAIN ttl (1 hour) + 3601 ; Refresh (1 hour) + 601 ; Retry (10 minutes) + 604801 ; Expire (1 week) + 3601 ; NXDOMAIN ttl (1 hour) ) @ 43 IN NS ns1.unit.tests. From b5c1f567049931c82c93f99bb66f80f237a0f56d Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Tue, 12 Sep 2023 12:03:45 -0700 Subject: [PATCH 5/7] default_ttl param --- octodns_bind/__init__.py | 8 ++++++-- tests/test_provider_octodns_bind.py | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/octodns_bind/__init__.py b/octodns_bind/__init__.py index a785cc0..ec45919 100644 --- a/octodns_bind/__init__.py +++ b/octodns_bind/__init__.py @@ -121,6 +121,7 @@ class ZoneFileProvider(RfcPopulate, BaseProvider): # The details of the SOA record can be customized when creating # zonefiles with the following options. + default_ttl: 3600 refresh: 3600 retry: 600 expire: 604800 @@ -134,6 +135,7 @@ def __init__( file_extension='.', check_origin=True, hostmaster_email='webmaster', + default_ttl=3600, refresh=3600, retry=600, expire=604800, @@ -141,12 +143,13 @@ def __init__( ): self.log = getLogger(f'ZoneFileProvider[{id}]') self.log.debug( - '__init__: id=%s, directory=%s, file_extension=%s, check_origin=%s, hostmaster_email=%s, refresh=%d, retry=%d, expire=%d, nxdomain=%d', + '__init__: id=%s, directory=%s, file_extension=%s, check_origin=%s, hostmaster_email=%s, default_ttl=%d, refresh=%d, retry=%d, expire=%d, nxdomain=%d', id, directory, file_extension, check_origin, hostmaster_email, + default_ttl, refresh, retry, expire, @@ -157,6 +160,7 @@ def __init__( self.file_extension = file_extension self.check_origin = check_origin self.hostmaster_email = hostmaster_email + self.default_ttl = default_ttl self.refresh = refresh self.retry = retry self.expire = expire @@ -278,7 +282,7 @@ def _apply(self, plan): 'hostmaster_email': self._hostmaster_email(name), 'serial': self._serial(), 'zone_name': name, - 'default_ttl': 3600, + 'default_ttl': self.default_ttl, 'primary_nameserver': primary_nameserver, 'refresh': self.refresh, 'retry': self.retry, diff --git a/tests/test_provider_octodns_bind.py b/tests/test_provider_octodns_bind.py index 2050e06..88fd3c0 100644 --- a/tests/test_provider_octodns_bind.py +++ b/tests/test_provider_octodns_bind.py @@ -273,6 +273,7 @@ def test_apply(self, serial_mock): desired.add_record(txt) # test out customizing the SOA details + provider.default_ttl = 3602 provider.refresh = 3601 provider.retry = 601 provider.expire = 604801 @@ -284,7 +285,7 @@ def test_apply(self, serial_mock): self.assertEqual( '''$ORIGIN unit.tests. -@ 3600 IN SOA ns1.unit.tests. webmaster.unit.tests. ( +@ 3602 IN SOA ns1.unit.tests. webmaster.unit.tests. ( 484950 ; Serial 3601 ; Refresh (1 hour) 601 ; Retry (10 minutes) From 61e18972fcd67c86a103a83a7596e505e92ffdfb Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Tue, 12 Sep 2023 12:04:01 -0700 Subject: [PATCH 6/7] soa comments no longer apply --- octodns_bind/__init__.py | 8 ++++---- tests/test_provider_octodns_bind.py | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/octodns_bind/__init__.py b/octodns_bind/__init__.py index ec45919..8c5428d 100644 --- a/octodns_bind/__init__.py +++ b/octodns_bind/__init__.py @@ -266,10 +266,10 @@ def _apply(self, plan): @ $default_ttl IN SOA $primary_nameserver $hostmaster_email ( $serial ; Serial - $refresh ; Refresh (1 hour) - $retry ; Retry (10 minutes) - $expire ; Expire (1 week) - $nxdomain ; NXDOMAIN ttl (1 hour) + $refresh ; Refresh + $retry ; Retry + $expire ; Expire + $nxdomain ; NXDOMAIN ttl ) ''' diff --git a/tests/test_provider_octodns_bind.py b/tests/test_provider_octodns_bind.py index 88fd3c0..7d16c21 100644 --- a/tests/test_provider_octodns_bind.py +++ b/tests/test_provider_octodns_bind.py @@ -210,10 +210,10 @@ def test_apply(self, serial_mock): @ 3600 IN SOA ns.unit.tests. webmaster.unit.tests. ( 424344 ; Serial - 3600 ; Refresh (1 hour) - 600 ; Retry (10 minutes) - 604800 ; Expire (1 week) - 3600 ; NXDOMAIN ttl (1 hour) + 3600 ; Refresh + 600 ; Retry + 604800 ; Expire + 3600 ; NXDOMAIN ttl ) cname 42 IN CNAME target.unit.tests. @@ -250,10 +250,10 @@ def test_apply(self, serial_mock): @ 3600 IN SOA ns1.unit.tests. webmaster.unit.tests. ( 454647 ; Serial - 3600 ; Refresh (1 hour) - 600 ; Retry (10 minutes) - 604800 ; Expire (1 week) - 3600 ; NXDOMAIN ttl (1 hour) + 3600 ; Refresh + 600 ; Retry + 604800 ; Expire + 3600 ; NXDOMAIN ttl ) @ 44 IN A 1.2.3.4 @@ -287,10 +287,10 @@ def test_apply(self, serial_mock): @ 3602 IN SOA ns1.unit.tests. webmaster.unit.tests. ( 484950 ; Serial - 3601 ; Refresh (1 hour) - 601 ; Retry (10 minutes) - 604801 ; Expire (1 week) - 3601 ; NXDOMAIN ttl (1 hour) + 3601 ; Refresh + 601 ; Retry + 604801 ; Expire + 3601 ; NXDOMAIN ttl ) @ 43 IN NS ns1.unit.tests. From c9c7aeeec51a8f33fcd04abb0a47aa179e921048 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Tue, 12 Sep 2023 12:09:00 -0700 Subject: [PATCH 7/7] implement populate exists --- octodns_bind/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/octodns_bind/__init__.py b/octodns_bind/__init__.py index 8c5428d..028f751 100644 --- a/octodns_bind/__init__.py +++ b/octodns_bind/__init__.py @@ -6,7 +6,7 @@ from datetime import datetime from logging import getLogger from os import listdir, makedirs -from os.path import isdir, join +from os.path import exists, isdir, join from string import Template import dns.name @@ -66,8 +66,7 @@ def populate(self, zone, target=False, lenient=False): 'populate: found %s records', len(zone.records) - before ) - # TODO: how do we do exists - return True + return self.zone_exists(zone) class ZoneFileSourceException(Exception): @@ -200,6 +199,10 @@ def _load_zone_file(self, zone_name, target): return z + def zone_exists(self, zone): + zone_filename = f'{zone.name[:-1]}{self.file_extension}' + return exists(join(self.directory, zone_filename)) + def zone_records(self, zone, target): if zone.name not in self._zone_records: z = self._load_zone_file(zone.name, target) @@ -392,6 +395,10 @@ def _auth_params(self): params['keyalgorithm'] = self.key_algorithm return params + def zone_exists(self, zone): + # We can't create them so they have to already exist + return True + def zone_records(self, zone, target): auth_params = self._auth_params() try: