From fd3de1e08b45cfd5324b7b9949e42914012c91d0 Mon Sep 17 00:00:00 2001 From: Adam Smith Date: Sat, 29 Sep 2018 16:23:20 -0700 Subject: [PATCH] add Zone File source, reads Bind compatible zone files --- README.md | 1 + octodns/source/axfr.py | 72 ++++++++++++++++++++++ tests/test_octodns_source_axfr.py | 35 ++++++++++- tests/zones/invalid.zone. | 8 +++ tests/zones/{unit.tests.db => unit.tests.} | 0 5 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 tests/zones/invalid.zone. rename tests/zones/{unit.tests.db => unit.tests.} (100%) diff --git a/README.md b/README.md index 7ef2f1c..8ecbfa9 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ The above command pulled the existing data out of Route53 and placed the results | [Rackspace](/octodns/provider/rackspace.py) | | A, AAAA, ALIAS, CNAME, MX, NS, PTR, SPF, TXT | No | | | [Route53](/octodns/provider/route53.py) | boto3 | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | Yes | | | [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only | +| [ZoneFileSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only | | [TinyDnsFileSource](/octodns/source/tinydns.py) | | A, CNAME, MX, NS, PTR | No | read-only | | [YamlProvider](/octodns/provider/yaml.py) | | All | Yes | config | diff --git a/octodns/source/axfr.py b/octodns/source/axfr.py index 42d5fc0..715a36b 100644 --- a/octodns/source/axfr.py +++ b/octodns/source/axfr.py @@ -13,6 +13,8 @@ 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 @@ -160,3 +162,73 @@ class AxfrSource(AxfrBaseSource): }) return records + + +class ZoneFileSourceException(Exception): + pass + + +class ZoneFileSourceNotFound(ZoneFileSourceException): + + def __init__(self): + super(ZoneFileSourceNotFound, self).__init__( + 'Zone file not found') + + +class ZoneFileSourceLoadFailure(ZoneFileSourceException): + + def __init__(self, error): + super(ZoneFileSourceLoadFailure, self).__init__( + error.message) + + +class ZoneFileSource(AxfrBaseSource): + ''' + Bind compatible zone file source + + zonefile: + class: octodns.source.axfr.ZoneFileSource + # The directory holding the zone files + # Filenames should match zone name (eg. example.com.) + directory: ./zonefiles + ''' + def __init__(self, id, directory): + self.log = logging.getLogger('ZoneFileSource[{}]'.format(id)) + self.log.debug('__init__: id=%s, directory=%s', id, directory) + super(ZoneFileSource, self).__init__(id) + self.directory = directory + + self._zone_records = {} + + def _load_zone_file(self, zone_name): + zonefiles = listdir(self.directory) + if zone_name in zonefiles: + try: + z = dns.zone.from_file(join(self.directory, zone_name), + zone_name, relativize=False) + except DNSException as error: + raise ZoneFileSourceLoadFailure(error) + else: + raise ZoneFileSourceNotFound() + + return z + + def zone_records(self, zone): + 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) + records.append({ + "name": name.to_text(), + "ttl": ttl, + "type": rdtype, + "value": rdata.to_text() + }) + + self._zone_records[zone.name] = records + except ZoneFileSourceNotFound: + return [] + + return self._zone_records[zone.name] diff --git a/tests/test_octodns_source_axfr.py b/tests/test_octodns_source_axfr.py index d90eb5b..9251113 100644 --- a/tests/test_octodns_source_axfr.py +++ b/tests/test_octodns_source_axfr.py @@ -11,14 +11,15 @@ from dns.exception import DNSException from mock import patch from unittest import TestCase -from octodns.source.axfr import AxfrSource, AxfrSourceZoneTransferFailed +from octodns.source.axfr import AxfrSource, AxfrSourceZoneTransferFailed, \ + ZoneFileSource, ZoneFileSourceLoadFailure from octodns.zone import Zone class TestAxfrSource(TestCase): source = AxfrSource('test', 'localhost') - forward_zonefile = dns.zone.from_file('./tests/zones/unit.tests.db', + forward_zonefile = dns.zone.from_file('./tests/zones/unit.tests.', 'unit.tests', relativize=False) @patch('dns.zone.from_xfr') @@ -38,3 +39,33 @@ class TestAxfrSource(TestCase): self.source.populate(zone) self.assertEquals('Unable to Perform Zone Transfer', ctx.exception.message) + + +class TestZoneFileSource(TestCase): + source = ZoneFileSource('test', './tests/zones') + + def test_populate(self): + # Valid zone file in directory + valid = Zone('unit.tests.', []) + self.source.populate(valid) + self.assertEquals(11, len(valid.records)) + + # 2nd populate does not read file again + again = Zone('unit.tests.', []) + self.source.populate(again) + self.assertEquals(11, len(again.records)) + + # bust the cache + del self.source._zone_records[valid.name] + + # No zone file in directory + missing = Zone('missing.zone.', []) + self.source.populate(missing) + self.assertEquals(0, len(missing.records)) + + # Zone file is not valid + with self.assertRaises(ZoneFileSourceLoadFailure) as ctx: + zone = Zone('invalid.zone.', []) + self.source.populate(zone) + self.assertEquals('The DNS zone has no NS RRset at its origin.', + ctx.exception.message) diff --git a/tests/zones/invalid.zone. b/tests/zones/invalid.zone. new file mode 100644 index 0000000..c814af6 --- /dev/null +++ b/tests/zones/invalid.zone. @@ -0,0 +1,8 @@ +$ORIGIN invalid.zone. +@ IN SOA ns1.invalid.zone. root.invalid.zone. ( + 2018071501 ; Serial + 3600 ; Refresh (1 hour) + 600 ; Retry (10 minutes) + 604800 ; Expire (1 week) + 3600 ; NXDOMAIN ttl (1 hour) + ) diff --git a/tests/zones/unit.tests.db b/tests/zones/unit.tests. similarity index 100% rename from tests/zones/unit.tests.db rename to tests/zones/unit.tests.