diff --git a/README.md b/README.md index 05cf979..1f103f1 100644 --- a/README.md +++ b/README.md @@ -150,12 +150,12 @@ The above command pulled the existing data out of Route53 and placed the results | Provider | Record Support | GeoDNS Support | Notes | |--|--|--|--| | [AzureProvider](/octodns/provider/azuredns.py) | A, AAAA, CNAME, MX, NS, PTR, SRV, TXT | No | | -| [CloudflareProvider](/octodns/provider/cloudflare.py) | A, AAAA, CNAME, MX, NS, SPF, TXT | No | | -| [DnsimpleProvider](/octodns/provider/dnsimple.py) | All | No | | +| [CloudflareProvider](/octodns/provider/cloudflare.py) | A, AAAA, CAA, CNAME, MX, NS, SPF, TXT | No | CAA tags restricted | +| [DnsimpleProvider](/octodns/provider/dnsimple.py) | All | No | CAA tags restricted | | [DynProvider](/octodns/provider/dyn.py) | All | Yes | | | [Ns1Provider](/octodns/provider/ns1.py) | All | No | | | [PowerDnsProvider](/octodns/provider/powerdns.py) | All | No | | -| [Route53](/octodns/provider/route53.py) | A, AAAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | Yes | | +| [Route53](/octodns/provider/route53.py) | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | Yes | | | [TinyDNSSource](/octodns/source/tinydns.py) | A, CNAME, MX, NS, PTR | No | read-only | | [YamlProvider](/octodns/provider/yaml.py) | All | Yes | config | diff --git a/octodns/provider/cloudflare.py b/octodns/provider/cloudflare.py index 2ee8f8b..a4fce9b 100644 --- a/octodns/provider/cloudflare.py +++ b/octodns/provider/cloudflare.py @@ -36,7 +36,7 @@ class CloudflareProvider(BaseProvider): ''' SUPPORTS_GEO = False # TODO: support SRV - SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'SPF', 'TXT')) + SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SPF', 'TXT')) MIN_TTL = 120 TIMEOUT = 15 @@ -104,6 +104,20 @@ class CloudflareProvider(BaseProvider): 'values': [r['content'].replace(';', '\;') for r in records], } + def _data_for_CAA(self, _type, records): + values = [] + for r in records: + values.append({ + 'flags': r['flags'], + 'tag': r['tag'], + 'value': r['content'], + }) + return { + 'ttl': records[0]['ttl'], + 'type': _type, + 'values': values, + } + def _data_for_CNAME(self, _type, records): only = records[0] return { @@ -197,6 +211,14 @@ class CloudflareProvider(BaseProvider): _contents_for_NS = _contents_for_multiple _contents_for_SPF = _contents_for_multiple + def _contents_for_CAA(self, record): + for value in record.values: + yield { + 'flags': value.flags, + 'tag': value.tag, + 'value': value.value, + } + def _contents_for_TXT(self, record): for value in record.values: yield {'content': value.replace('\;', ';')} diff --git a/octodns/provider/dnsimple.py b/octodns/provider/dnsimple.py index dc44d1b..43b5b9b 100644 --- a/octodns/provider/dnsimple.py +++ b/octodns/provider/dnsimple.py @@ -91,8 +91,8 @@ class DnsimpleProvider(BaseProvider): account: 42 ''' SUPPORTS_GEO = False - SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', - 'SPF', 'SRV', 'SSHFP', 'TXT')) + SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS', + 'PTR', 'SPF', 'SRV', 'SSHFP', 'TXT')) def __init__(self, id, token, account, *args, **kwargs): self.log = logging.getLogger('DnsimpleProvider[{}]'.format(id)) @@ -114,6 +114,21 @@ class DnsimpleProvider(BaseProvider): _data_for_SPF = _data_for_multiple _data_for_TXT = _data_for_multiple + def _data_for_CAA(self, _type, records): + values = [] + for record in records: + flags, tag, value = record['content'].split(' ') + values.append({ + 'flags': flags, + 'tag': tag, + 'value': value[1:-1], + }) + return { + 'ttl': records[0]['ttl'], + 'type': _type, + 'values': values + } + def _data_for_CNAME(self, _type, records): record = records[0] return { @@ -275,6 +290,16 @@ class DnsimpleProvider(BaseProvider): _params_for_SPF = _params_for_multiple _params_for_TXT = _params_for_multiple + def _params_for_CAA(self, record): + for value in record.values: + yield { + 'content': '{} {} "{}"'.format(value.flags, value.tag, + value.value), + 'name': record.name, + 'ttl': record.ttl, + 'type': record._type + } + def _params_for_single(self, record): yield { 'content': record.value, diff --git a/octodns/provider/dyn.py b/octodns/provider/dyn.py index e21b93e..3b7b9ea 100644 --- a/octodns/provider/dyn.py +++ b/octodns/provider/dyn.py @@ -111,6 +111,7 @@ class DynProvider(BaseProvider): 'a_records': 'A', 'aaaa_records': 'AAAA', 'alias_records': 'ALIAS', + 'caa_records': 'CAA', 'cname_records': 'CNAME', 'mx_records': 'MX', 'naptr_records': 'NAPTR', @@ -194,6 +195,14 @@ class DynProvider(BaseProvider): 'value': record.alias } + def _data_for_CAA(self, _type, records): + return { + 'type': _type, + 'ttl': records[0].ttl, + 'values': [{'flags': r.flags, 'tag': r.tag, 'value': r.value} + for r in records], + } + def _data_for_CNAME(self, _type, records): record = records[0] return { @@ -382,6 +391,13 @@ class DynProvider(BaseProvider): _kwargs_for_AAAA = _kwargs_for_A + def _kwargs_for_CAA(self, record): + return [{ + 'flags': v.flags, + 'tag': v.tag, + 'value': v.value, + } for v in record.values] + def _kwargs_for_CNAME(self, record): return [{ 'cname': record.value, diff --git a/octodns/provider/ns1.py b/octodns/provider/ns1.py index 7757812..f7cbef1 100644 --- a/octodns/provider/ns1.py +++ b/octodns/provider/ns1.py @@ -23,8 +23,8 @@ class Ns1Provider(BaseProvider): api_key: env/NS1_API_KEY ''' SUPPORTS_GEO = False - SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', - 'SPF', 'SRV', 'TXT')) + SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS', + 'PTR', 'SPF', 'SRV', 'TXT')) ZONE_NOT_FOUND_MESSAGE = 'server error: zone not found' @@ -53,6 +53,21 @@ class Ns1Provider(BaseProvider): _data_for_TXT = _data_for_SPF + def _data_for_CAA(self, _type, record): + values = [] + for answer in record['short_answers']: + flags, tag, value = answer.split(' ', 2) + values.append({ + 'flags': flags, + 'tag': tag, + 'value': value, + }) + return { + 'ttl': record['ttl'], + 'type': _type, + 'values': values, + } + def _data_for_CNAME(self, _type, record): return { 'ttl': record['ttl'], @@ -159,6 +174,10 @@ class Ns1Provider(BaseProvider): _params_for_TXT = _params_for_SPF + def _params_for_CAA(self, record): + values = [(v.flags, v.tag, v.value) for v in record.values] + return {'answers': values, 'ttl': record.ttl} + def _params_for_CNAME(self, record): return {'answers': [record.value], 'ttl': record.ttl} diff --git a/octodns/provider/powerdns.py b/octodns/provider/powerdns.py index 62b6fd8..20cfe8b 100644 --- a/octodns/provider/powerdns.py +++ b/octodns/provider/powerdns.py @@ -14,8 +14,8 @@ from .base import BaseProvider class PowerDnsBaseProvider(BaseProvider): SUPPORTS_GEO = False - SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', - 'SPF', 'SSHFP', 'SRV', 'TXT')) + SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS', + 'PTR', 'SPF', 'SSHFP', 'SRV', 'TXT')) TIMEOUT = 5 def __init__(self, id, host, api_key, port=8081, scheme="http", *args, @@ -61,6 +61,21 @@ class PowerDnsBaseProvider(BaseProvider): _data_for_AAAA = _data_for_multiple _data_for_NS = _data_for_multiple + def _data_for_CAA(self, rrset): + values = [] + for record in rrset['records']: + flags, tag, value = record['content'].split(' ', 2) + values.append({ + 'flags': flags, + 'tag': tag, + 'value': value[1:-1], + }) + return { + 'type': rrset['type'], + 'values': values, + 'ttl': rrset['ttl'] + } + def _data_for_single(self, rrset): return { 'type': rrset['type'], @@ -194,6 +209,12 @@ class PowerDnsBaseProvider(BaseProvider): _records_for_AAAA = _records_for_multiple _records_for_NS = _records_for_multiple + def _records_for_CAA(self, record): + return [{ + 'content': '{} {} "{}"'.format(v.flags, v.tag, v.value), + 'disabled': False + } for v in record.values] + def _records_for_single(self, record): return [{'content': record.value, 'disabled': False}] diff --git a/octodns/provider/route53.py b/octodns/provider/route53.py index 6f9adc2..0600511 100644 --- a/octodns/provider/route53.py +++ b/octodns/provider/route53.py @@ -90,6 +90,10 @@ class _Route53Record(object): _values_for_AAAA = _values_for_values _values_for_NS = _values_for_values + def _values_for_CAA(self, record): + return ['{} {} "{}"'.format(v.flags, v.tag, v.value) + for v in record.values] + def _values_for_value(self, record): return [record.value] @@ -222,8 +226,8 @@ class Route53Provider(BaseProvider): In general the account used will need full permissions on Route53. ''' SUPPORTS_GEO = True - SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', 'SPF', - 'SRV', 'TXT')) + SUPPORTS = set(('A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', + 'SPF', 'SRV', 'TXT')) # This should be bumped when there are underlying changes made to the # health check config. @@ -319,6 +323,21 @@ class Route53Provider(BaseProvider): _data_for_A = _data_for_geo _data_for_AAAA = _data_for_geo + def _data_for_CAA(self, rrset): + values = [] + for rr in rrset['ResourceRecords']: + flags, tag, value = rr['Value'].split(' ') + values.append({ + 'flags': flags, + 'tag': tag, + 'value': value[1:-1], + }) + return { + 'type': rrset['Type'], + 'values': values, + 'ttl': int(rrset['TTL']) + } + def _data_for_single(self, rrset): return { 'type': rrset['Type'], diff --git a/octodns/record.py b/octodns/record.py index 6ee9dff..8ef80be 100644 --- a/octodns/record.py +++ b/octodns/record.py @@ -81,29 +81,16 @@ class Record(object): 'A': ARecord, 'AAAA': AaaaRecord, 'ALIAS': AliasRecord, - # cert + 'CAA': CaaRecord, 'CNAME': CnameRecord, - # dhcid - # dname - # dnskey - # ds - # ipseckey - # key - # kx - # loc 'MX': MxRecord, 'NAPTR': NaptrRecord, 'NS': NsRecord, - # nsap 'PTR': PtrRecord, - # px - # rp - # soa - would it even make sense? 'SPF': SpfRecord, 'SRV': SrvRecord, 'SSHFP': SshfpRecord, 'TXT': TxtRecord, - # url }[_type] except KeyError: raise Exception('Unknown record type: "{}"'.format(_type)) @@ -398,6 +385,61 @@ class AliasRecord(_ValueMixin, Record): return value +class CaaValue(object): + # https://tools.ietf.org/html/rfc6844#page-5 + + @classmethod + def _validate_value(cls, value): + reasons = [] + try: + flags = int(value.get('flags', 0)) + if flags < 0 or flags > 255: + reasons.append('invalid flags "{}"'.format(flags)) + except ValueError: + reasons.append('invalid flags "{}"'.format(value['flags'])) + + if 'tag' not in value: + reasons.append('missing tag') + if 'value' not in value: + reasons.append('missing value') + + return reasons + + def __init__(self, value): + self.flags = int(value.get('flags', 0)) + self.tag = value['tag'] + self.value = value['value'] + + @property + def data(self): + return { + 'flags': self.flags, + 'tag': self.tag, + 'value': self.value, + } + + def __cmp__(self, other): + if self.flags == other.flags: + if self.tag == other.tag: + return cmp(self.value, other.value) + return cmp(self.tag, other.tag) + return cmp(self.flags, other.flags) + + def __repr__(self): + return '{} {} "{}"'.format(self.flags, self.tag, self.value) + + +class CaaRecord(_ValuesMixin, Record): + _type = 'CAA' + + @classmethod + def _validate_value(cls, value): + return CaaValue._validate_value(value) + + def _process_values(self, values): + return [CaaValue(v) for v in values] + + class CnameRecord(_ValueMixin, Record): _type = 'CNAME' diff --git a/requirements.txt b/requirements.txt index 2aec6d0..d2be70f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,10 +4,10 @@ PyYaml==3.12 azure-mgmt-dns==1.0.1 azure-common==1.1.6 boto3==1.4.6 -botocore==1.6.0 +botocore==1.6.8 dnspython==1.15.0 docutils==0.14 -dyn==1.7.10 +dyn==1.8.0 futures==3.1.1 incf.countryutils==1.0 ipaddress==1.0.18 diff --git a/tests/config/unit.tests.yaml b/tests/config/unit.tests.yaml index 8be1614..5241406 100644 --- a/tests/config/unit.tests.yaml +++ b/tests/config/unit.tests.yaml @@ -31,6 +31,11 @@ values: - 6.2.3.4. - 7.2.3.4. + - type: CAA + values: + - flags: 0 + tag: issue + value: ca.unit.tests _srv._tcp: ttl: 600 type: SRV diff --git a/tests/fixtures/cloudflare-dns_records-page-2.json b/tests/fixtures/cloudflare-dns_records-page-2.json index 24c49d5..9800155 100644 --- a/tests/fixtures/cloudflare-dns_records-page-2.json +++ b/tests/fixtures/cloudflare-dns_records-page-2.json @@ -118,14 +118,33 @@ "meta": { "auto_added": false } + }, + { + "id": "fc223b34cd5611334422ab3322997667", + "type": "CAA", + "name": "unit.tests", + "content": "ca.unit.tests", + "flags": 0, + "tag": "issue", + "proxiable": false, + "proxied": false, + "ttl": 3600, + "locked": false, + "zone_id": "ff12ab34cd5611334422ab3322997650", + "zone_name": "unit.tests", + "modified_on": "2017-03-11T18:01:42.961566Z", + "created_on": "2017-03-11T18:01:42.961566Z", + "meta": { + "auto_added": false + } } ], "result_info": { "page": 2, "per_page": 10, "total_pages": 2, - "count": 7, - "total_count": 17 + "count": 8, + "total_count": 19 }, "success": true, "errors": [], diff --git a/tests/fixtures/dnsimple-page-2.json b/tests/fixtures/dnsimple-page-2.json index f50704b..40aaa48 100644 --- a/tests/fixtures/dnsimple-page-2.json +++ b/tests/fixtures/dnsimple-page-2.json @@ -159,12 +159,28 @@ "system_record": false, "created_at": "2017-03-09T15:55:09Z", "updated_at": "2017-03-09T15:55:09Z" + }, + { + "id": 12188803, + "zone_id": "unit.tests", + "parent_id": null, + "name": "", + "content": "0 issue \"ca.unit.tests\"", + "ttl": 3600, + "priority": null, + "type": "CAA", + "regions": [ + "global" + ], + "system_record": false, + "created_at": "2017-03-09T15:55:09Z", + "updated_at": "2017-03-09T15:55:09Z" } ], "pagination": { "current_page": 2, "per_page": 20, - "total_entries": 29, + "total_entries": 30, "total_pages": 2 } } diff --git a/tests/fixtures/powerdns-full-data.json b/tests/fixtures/powerdns-full-data.json index 72ce016..b8f8bf3 100644 --- a/tests/fixtures/powerdns-full-data.json +++ b/tests/fixtures/powerdns-full-data.json @@ -230,6 +230,18 @@ ], "ttl": 300, "type": "A" + }, + { + "comments": [], + "name": "unit.tests.", + "records": [ + { + "content": "0 issue \"ca.unit.tests\"", + "disabled": false + } + ], + "ttl": 3600, + "type": "CAA" } ], "serial": 2017012803, diff --git a/tests/test_octodns_provider_cloudflare.py b/tests/test_octodns_provider_cloudflare.py index 5dcae30..04a46e0 100644 --- a/tests/test_octodns_provider_cloudflare.py +++ b/tests/test_octodns_provider_cloudflare.py @@ -118,7 +118,7 @@ class TestCloudflareProvider(TestCase): zone = Zone('unit.tests.', []) provider.populate(zone) - self.assertEquals(9, len(zone.records)) + self.assertEquals(10, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) @@ -126,7 +126,7 @@ class TestCloudflareProvider(TestCase): # re-populating the same zone/records comes out of cache, no calls again = Zone('unit.tests.', []) provider.populate(again) - self.assertEquals(9, len(again.records)) + self.assertEquals(10, len(again.records)) def test_apply(self): provider = CloudflareProvider('test', 'email', 'token') @@ -140,12 +140,12 @@ class TestCloudflareProvider(TestCase): 'id': 42, } }, # zone create - ] + [None] * 16 # individual record creates + ] + [None] * 17 # individual record creates # non-existant zone, create everything plan = provider.plan(self.expected) - self.assertEquals(9, len(plan.changes)) - self.assertEquals(9, provider.apply(plan)) + self.assertEquals(10, len(plan.changes)) + self.assertEquals(10, provider.apply(plan)) provider._request.assert_has_calls([ # created the domain @@ -170,7 +170,7 @@ class TestCloudflareProvider(TestCase): }), ], True) # expected number of total calls - self.assertEquals(18, provider._request.call_count) + self.assertEquals(19, provider._request.call_count) provider._request.reset_mock() diff --git a/tests/test_octodns_provider_dnsimple.py b/tests/test_octodns_provider_dnsimple.py index aed1e8b..950d460 100644 --- a/tests/test_octodns_provider_dnsimple.py +++ b/tests/test_octodns_provider_dnsimple.py @@ -78,14 +78,14 @@ class TestDnsimpleProvider(TestCase): zone = Zone('unit.tests.', []) provider.populate(zone) - self.assertEquals(14, len(zone.records)) + self.assertEquals(15, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) - self.assertEquals(14, len(again.records)) + self.assertEquals(15, len(again.records)) # bust the cache del provider._zone_records[zone.name] @@ -147,7 +147,7 @@ class TestDnsimpleProvider(TestCase): }), ]) # expected number of total calls - self.assertEquals(26, provider._client._request.call_count) + self.assertEquals(27, provider._client._request.call_count) provider._client._request.reset_mock() diff --git a/tests/test_octodns_provider_dyn.py b/tests/test_octodns_provider_dyn.py index bebd3e3..9be253d 100644 --- a/tests/test_octodns_provider_dyn.py +++ b/tests/test_octodns_provider_dyn.py @@ -109,6 +109,14 @@ class TestDynProvider(TestCase): 'weight': 22, 'port': 20, 'target': 'foo-2.unit.tests.' + }]}), + ('', { + 'type': 'CAA', + 'ttl': 308, + 'values': [{ + 'flags': 0, + 'tag': 'issue', + 'value': 'ca.unit.tests' }]})): expected.add_record(Record.new(expected, name, data)) @@ -321,6 +329,16 @@ class TestDynProvider(TestCase): 'ttl': 307, 'zone': 'unit.tests', }], + 'caa_records': [{ + 'fqdn': 'unit.tests', + 'rdata': {'flags': 0, + 'tag': 'issue', + 'value': 'ca.unit.tests'}, + 'record_id': 12, + 'record_type': 'cAA', + 'ttl': 308, + 'zone': 'unit.tests', + }], }} ] got = Zone('unit.tests.', []) @@ -414,10 +432,10 @@ class TestDynProvider(TestCase): update_mock.assert_called() add_mock.assert_called() # Once for each dyn record (8 Records, 2 of which have dual values) - self.assertEquals(14, len(add_mock.call_args_list)) + self.assertEquals(15, len(add_mock.call_args_list)) execute_mock.assert_has_calls([call('/Zone/unit.tests/', 'GET', {}), call('/Zone/unit.tests/', 'GET', {})]) - self.assertEquals(9, len(plan.changes)) + self.assertEquals(10, len(plan.changes)) execute_mock.reset_mock() diff --git a/tests/test_octodns_provider_ns1.py b/tests/test_octodns_provider_ns1.py index 4df56b3..cde23b0 100644 --- a/tests/test_octodns_provider_ns1.py +++ b/tests/test_octodns_provider_ns1.py @@ -96,6 +96,15 @@ class TestNs1Provider(TestCase): 'type': 'NS', 'values': ['ns3.unit.tests.', 'ns4.unit.tests.'], })) + expected.add(Record.new(zone, '', { + 'ttl': 40, + 'type': 'CAA', + 'value': { + 'flags': 0, + 'tag': 'issue', + 'value': 'ca.unit.tests', + }, + })) nsone_records = [{ 'type': 'A', @@ -141,6 +150,11 @@ class TestNs1Provider(TestCase): 'ttl': 39, 'short_answers': ['ns3.unit.tests.', 'ns4.unit.tests.'], 'domain': 'sub.unit.tests.', + }, { + 'type': 'CAA', + 'ttl': 40, + 'short_answers': ['0 issue ca.unit.tests'], + 'domain': 'unit.tests.', }] @patch('nsone.NSONE.loadZone') diff --git a/tests/test_octodns_provider_powerdns.py b/tests/test_octodns_provider_powerdns.py index 5fcd80a..b6e02ff 100644 --- a/tests/test_octodns_provider_powerdns.py +++ b/tests/test_octodns_provider_powerdns.py @@ -79,7 +79,7 @@ class TestPowerDnsProvider(TestCase): source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) expected_n = len(expected.records) - 1 - self.assertEquals(14, expected_n) + self.assertEquals(15, expected_n) # No diffs == no changes with requests_mock() as mock: @@ -87,7 +87,7 @@ class TestPowerDnsProvider(TestCase): zone = Zone('unit.tests.', []) provider.populate(zone) - self.assertEquals(14, len(zone.records)) + self.assertEquals(15, len(zone.records)) changes = expected.changes(zone, provider) self.assertEquals(0, len(changes)) @@ -167,7 +167,7 @@ class TestPowerDnsProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) - self.assertEquals(15, len(expected.records)) + self.assertEquals(16, len(expected.records)) # A small change to a single record with requests_mock() as mock: diff --git a/tests/test_octodns_provider_route53.py b/tests/test_octodns_provider_route53.py index 97dae4f..1cd4548 100644 --- a/tests/test_octodns_provider_route53.py +++ b/tests/test_octodns_provider_route53.py @@ -77,6 +77,12 @@ class TestRoute53Provider(TestCase): {'ttl': 67, 'type': 'NS', 'values': ['8.2.3.4.', '9.2.3.4.']}), ('sub', {'ttl': 68, 'type': 'NS', 'values': ['5.2.3.4.', '6.2.3.4.']}), + ('', + {'ttl': 69, 'type': 'CAA', 'value': { + 'flags': 0, + 'tag': 'issue', + 'value': 'ca.unit.tests' + }}), ): record = Record.new(expected, name, data) expected.add_record(record) @@ -300,6 +306,13 @@ class TestRoute53Provider(TestCase): 'Value': 'ns1.unit.tests.', }], 'TTL': 69, + }, { + 'Name': 'unit.tests.', + 'Type': 'CAA', + 'ResourceRecords': [{ + 'Value': '0 issue "ca.unit.tests"', + }], + 'TTL': 69, }], 'IsTruncated': False, 'MaxItems': '100', @@ -347,7 +360,7 @@ class TestRoute53Provider(TestCase): {'HostedZoneId': 'z42'}) plan = provider.plan(self.expected) - self.assertEquals(8, len(plan.changes)) + self.assertEquals(9, len(plan.changes)) for change in plan.changes: self.assertIsInstance(change, Create) stubber.assert_no_pending_responses() @@ -366,7 +379,7 @@ class TestRoute53Provider(TestCase): 'SubmittedAt': '2017-01-29T01:02:03Z', }}, {'HostedZoneId': 'z42', 'ChangeBatch': ANY}) - self.assertEquals(8, provider.apply(plan)) + self.assertEquals(9, provider.apply(plan)) stubber.assert_no_pending_responses() # Delete by monkey patching in a populate that includes an extra record @@ -579,7 +592,7 @@ class TestRoute53Provider(TestCase): {}) plan = provider.plan(self.expected) - self.assertEquals(8, len(plan.changes)) + self.assertEquals(9, len(plan.changes)) for change in plan.changes: self.assertIsInstance(change, Create) stubber.assert_no_pending_responses() @@ -626,7 +639,7 @@ class TestRoute53Provider(TestCase): 'SubmittedAt': '2017-01-29T01:02:03Z', }}, {'HostedZoneId': 'z42', 'ChangeBatch': ANY}) - self.assertEquals(8, provider.apply(plan)) + self.assertEquals(9, provider.apply(plan)) stubber.assert_no_pending_responses() def test_health_checks_pagination(self): @@ -1174,16 +1187,16 @@ class TestRoute53Provider(TestCase): @patch('octodns.provider.route53.Route53Provider._really_apply') def test_apply_1(self, really_apply_mock): - # 17 RRs with max of 18 should only get applied in one call - provider, plan = self._get_test_plan(18) + # 18 RRs with max of 19 should only get applied in one call + provider, plan = self._get_test_plan(19) provider.apply(plan) really_apply_mock.assert_called_once() @patch('octodns.provider.route53.Route53Provider._really_apply') def test_apply_2(self, really_apply_mock): - # 17 RRs with max of 17 should only get applied in two calls - provider, plan = self._get_test_plan(17) + # 18 RRs with max of 17 should only get applied in two calls + provider, plan = self._get_test_plan(18) provider.apply(plan) self.assertEquals(2, really_apply_mock.call_count) diff --git a/tests/test_octodns_provider_yaml.py b/tests/test_octodns_provider_yaml.py index 9438f01..36cd8d6 100644 --- a/tests/test_octodns_provider_yaml.py +++ b/tests/test_octodns_provider_yaml.py @@ -30,7 +30,7 @@ class TestYamlProvider(TestCase): # without it we see everything source.populate(zone) - self.assertEquals(15, len(zone.records)) + self.assertEquals(16, len(zone.records)) # Assumption here is that a clean round-trip means that everything # worked as expected, data that went in came back out and could be diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 1d64081..51676a3 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -7,10 +7,10 @@ from __future__ import absolute_import, division, print_function, \ from unittest import TestCase -from octodns.record import ARecord, AaaaRecord, AliasRecord, CnameRecord, \ - Create, Delete, GeoValue, MxRecord, NaptrRecord, NaptrValue, NsRecord, \ - Record, SshfpRecord, SpfRecord, SrvRecord, TxtRecord, Update, \ - ValidationError +from octodns.record import ARecord, AaaaRecord, AliasRecord, CaaRecord, \ + CnameRecord, Create, Delete, GeoValue, MxRecord, NaptrRecord, \ + NaptrValue, NsRecord, Record, SshfpRecord, SpfRecord, SrvRecord, \ + TxtRecord, Update, ValidationError from octodns.zone import Zone from helpers import GeoProvider, SimpleProvider @@ -206,6 +206,66 @@ class TestRecord(TestCase): # __repr__ doesn't blow up a.__repr__() + def test_caa(self): + a_values = [{ + 'flags': 0, + 'tag': 'issue', + 'value': 'ca.example.net', + }, { + 'flags': 128, + 'tag': 'iodef', + 'value': 'mailto:security@example.com', + }] + a_data = {'ttl': 30, 'values': a_values} + a = CaaRecord(self.zone, 'a', a_data) + self.assertEquals('a', a.name) + self.assertEquals('a.unit.tests.', a.fqdn) + self.assertEquals(30, a.ttl) + self.assertEquals(a_values[0]['flags'], a.values[0].flags) + self.assertEquals(a_values[0]['tag'], a.values[0].tag) + self.assertEquals(a_values[0]['value'], a.values[0].value) + self.assertEquals(a_values[1]['flags'], a.values[1].flags) + self.assertEquals(a_values[1]['tag'], a.values[1].tag) + self.assertEquals(a_values[1]['value'], a.values[1].value) + self.assertEquals(a_data, a.data) + + b_value = { + 'tag': 'iodef', + 'value': 'http://iodef.example.com/', + } + b_data = {'ttl': 30, 'value': b_value} + b = CaaRecord(self.zone, 'b', b_data) + self.assertEquals(0, b.values[0].flags) + self.assertEquals(b_value['tag'], b.values[0].tag) + self.assertEquals(b_value['value'], b.values[0].value) + b_data['value']['flags'] = 0 + self.assertEquals(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in flags causes change + other = CaaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].flags = 128 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in tag causes change + other.values[0].flags = a.values[0].flags + other.values[0].tag = 'foo' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in value causes change + other.values[0].tag = a.values[0].tag + other.values[0].value = 'bar' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # __repr__ doesn't blow up + a.__repr__() + def test_cname(self): self.assertSingleValue(CnameRecord, 'target.foo.com.', 'other.foo.com.') @@ -861,6 +921,75 @@ class TestRecordValidation(TestCase): }) self.assertEquals(['missing trailing .'], ctx.exception.reasons) + def test_CAA(self): + # doesn't blow up + Record.new(self.zone, '', { + 'type': 'CAA', + 'ttl': 600, + 'value': { + 'flags': 128, + 'tag': 'iodef', + 'value': 'http://foo.bar.com/' + } + }) + + # invalid flags + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'CAA', + 'ttl': 600, + 'value': { + 'flags': -42, + 'tag': 'iodef', + 'value': 'http://foo.bar.com/', + } + }) + self.assertEquals(['invalid flags "-42"'], ctx.exception.reasons) + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'CAA', + 'ttl': 600, + 'value': { + 'flags': 442, + 'tag': 'iodef', + 'value': 'http://foo.bar.com/', + } + }) + self.assertEquals(['invalid flags "442"'], ctx.exception.reasons) + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'CAA', + 'ttl': 600, + 'value': { + 'flags': 'nope', + 'tag': 'iodef', + 'value': 'http://foo.bar.com/', + } + }) + self.assertEquals(['invalid flags "nope"'], ctx.exception.reasons) + + # missing tag + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'CAA', + 'ttl': 600, + 'value': { + 'value': 'http://foo.bar.com/', + } + }) + self.assertEquals(['missing tag'], ctx.exception.reasons) + + # missing value + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'CAA', + 'ttl': 600, + 'value': { + 'tag': 'iodef', + } + }) + self.assertEquals(['missing value'], ctx.exception.reasons) + def test_CNAME(self): # doesn't blow up Record.new(self.zone, 'www', {