diff --git a/README.md b/README.md index 163c723..aa9950e 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ The above command pulled the existing data out of Route53 and placed the results | [DynProvider](/octodns/provider/dyn.py) | dyn | All | Both | | | [EtcHostsProvider](/octodns/provider/etc_hosts.py) | | A, AAAA, ALIAS, CNAME | No | | | [GoogleCloudProvider](/octodns/provider/googlecloud.py) | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | | +| [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | | | [Ns1Provider](/octodns/provider/ns1.py) | nsone | All | Partial Geo | No health checking for GeoDNS | | [OVH](/octodns/provider/ovh.py) | ovh | A, AAAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT, DKIM | No | | | [PowerDnsProvider](/octodns/provider/powerdns.py) | | All | No | | diff --git a/octodns/provider/mythicbeasts.py b/octodns/provider/mythicbeasts.py new file mode 100644 index 0000000..17029db --- /dev/null +++ b/octodns/provider/mythicbeasts.py @@ -0,0 +1,474 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +import re + +from requests import Session +from logging import getLogger + +from ..record import Record +from .base import BaseProvider + +from collections import defaultdict + + +def add_trailing_dot(value): + ''' + Add trailing dots to values + ''' + assert value, 'Missing value' + assert value[-1] != '.', 'Value already has trailing dot' + return value + '.' + + +def remove_trailing_dot(value): + ''' + Remove trailing dots from values + ''' + assert value, 'Missing value' + assert value[-1] == '.', 'Value already missing trailing dot' + return value[:-1] + + +class MythicBeastsUnauthorizedException(Exception): + def __init__(self, zone, *args): + self.zone = zone + self.message = 'Mythic Beasts unauthorized for zone: {}'.format( + self.zone + ) + + super(MythicBeastsUnauthorizedException, self).__init__( + self.message, self.zone, *args) + + +class MythicBeastsRecordException(Exception): + def __init__(self, zone, command, *args): + self.zone = zone + self.command = command + self.message = 'Mythic Beasts could not action command: {} {}'.format( + self.zone, + self.command, + ) + + super(MythicBeastsRecordException, self).__init__( + self.message, self.zone, self.command, *args) + + +class MythicBeastsProvider(BaseProvider): + ''' + Mythic Beasts DNS API Provider + + Config settings: + + --- + providers: + config: + ... + mythicbeasts: + class: octodns.provider.mythicbeasts.MythicBeastsProvider + passwords: + my.domain.: 'password' + + zones: + my.domain.: + targets: + - mythic + ''' + + RE_MX = re.compile(r'^(?P[0-9]+)\s+(?P\S+)$', + re.IGNORECASE) + + RE_SRV = re.compile(r'^(?P[0-9]+)\s+(?P[0-9]+)\s+' + r'(?P[0-9]+)\s+(?P\S+)$', + re.IGNORECASE) + + RE_SSHFP = re.compile(r'^(?P[0-9]+)\s+' + r'(?P[0-9]+)\s+' + r'(?P\S+)$', + re.IGNORECASE) + + RE_CAA = re.compile(r'^(?P[0-9]+)\s+' + r'(?Pissue|issuewild|iodef)\s+' + r'(?P\S+)$', + re.IGNORECASE) + + RE_POPLINE = re.compile(r'^(?P\S+)\s+(?P\d+)\s+' + r'(?P\S+)\s+(?P.*)$', + re.IGNORECASE) + + SUPPORTS_GEO = False + SUPPORTS_DYNAMIC = False + SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NS', + 'SRV', 'SSHFP', 'CAA', 'TXT')) + BASE = 'https://dnsapi.mythic-beasts.com/' + + def __init__(self, identifier, passwords, *args, **kwargs): + self.log = getLogger('MythicBeastsProvider[{}]'.format(identifier)) + + assert isinstance(passwords, dict), 'Passwords must be a dictionary' + + self.log.debug( + '__init__: id=%s, registered zones; %s', + identifier, + passwords.keys()) + super(MythicBeastsProvider, self).__init__(identifier, *args, **kwargs) + + self._passwords = passwords + sess = Session() + self._sess = sess + + def _request(self, method, path, data=None): + self.log.debug('_request: method=%s, path=%s data=%s', + method, path, data) + + resp = self._sess.request(method, path, data=data) + self.log.debug( + '_request: status=%d data=%s', + resp.status_code, + resp.text[:20]) + + if resp.status_code == 401: + raise MythicBeastsUnauthorizedException(data['domain']) + + if resp.status_code == 400: + raise MythicBeastsRecordException( + data['domain'], + data['command'] + ) + resp.raise_for_status() + return resp + + def _post(self, data=None): + return self._request('POST', self.BASE, data=data) + + def records(self, zone): + assert zone in self._passwords, 'Missing password for domain: {}' \ + .format(remove_trailing_dot(zone)) + + return self._post({ + 'domain': remove_trailing_dot(zone), + 'password': self._passwords[zone], + 'showall': 0, + 'command': 'LIST', + }) + + @staticmethod + def _data_for_single(_type, data): + return { + 'type': _type, + 'value': data['raw_values'][0]['value'], + 'ttl': data['raw_values'][0]['ttl'] + } + + @staticmethod + def _data_for_multiple(_type, data): + return { + 'type': _type, + 'values': + [raw_values['value'] for raw_values in data['raw_values']], + 'ttl': + max([raw_values['ttl'] for raw_values in data['raw_values']]), + } + + @staticmethod + def _data_for_TXT(_type, data): + return { + 'type': _type, + 'values': + [ + str(raw_values['value']).replace(';', '\\;') + for raw_values in data['raw_values'] + ], + 'ttl': + max([raw_values['ttl'] for raw_values in data['raw_values']]), + } + + @staticmethod + def _data_for_MX(_type, data): + ttl = max([raw_values['ttl'] for raw_values in data['raw_values']]) + values = [] + + for raw_value in \ + [raw_values['value'] for raw_values in data['raw_values']]: + match = MythicBeastsProvider.RE_MX.match(raw_value) + + assert match is not None, 'Unable to parse MX data' + + exchange = match.group('exchange') + + if not exchange.endswith('.'): + exchange = '{}.{}'.format(exchange, data['zone']) + + values.append({ + 'preference': match.group('preference'), + 'exchange': exchange, + }) + + return { + 'type': _type, + 'values': values, + 'ttl': ttl, + } + + @staticmethod + def _data_for_CNAME(_type, data): + ttl = data['raw_values'][0]['ttl'] + value = data['raw_values'][0]['value'] + if not value.endswith('.'): + value = '{}.{}'.format(value, data['zone']) + + return MythicBeastsProvider._data_for_single( + _type, + {'raw_values': [ + {'value': value, 'ttl': ttl} + ]}) + + @staticmethod + def _data_for_ANAME(_type, data): + ttl = data['raw_values'][0]['ttl'] + value = data['raw_values'][0]['value'] + return MythicBeastsProvider._data_for_single( + 'ALIAS', + {'raw_values': [ + {'value': value, 'ttl': ttl} + ]}) + + @staticmethod + def _data_for_SRV(_type, data): + ttl = max([raw_values['ttl'] for raw_values in data['raw_values']]) + values = [] + + for raw_value in \ + [raw_values['value'] for raw_values in data['raw_values']]: + + match = MythicBeastsProvider.RE_SRV.match(raw_value) + + assert match is not None, 'Unable to parse SRV data' + + target = match.group('target') + if not target.endswith('.'): + target = '{}.{}'.format(target, data['zone']) + + values.append({ + 'priority': match.group('priority'), + 'weight': match.group('weight'), + 'port': match.group('port'), + 'target': target, + }) + + return { + 'type': _type, + 'values': values, + 'ttl': ttl, + } + + @staticmethod + def _data_for_SSHFP(_type, data): + ttl = max([raw_values['ttl'] for raw_values in data['raw_values']]) + values = [] + + for raw_value in \ + [raw_values['value'] for raw_values in data['raw_values']]: + match = MythicBeastsProvider.RE_SSHFP.match(raw_value) + + assert match is not None, 'Unable to parse SSHFP data' + + values.append({ + 'algorithm': match.group('algorithm'), + 'fingerprint_type': match.group('fingerprint_type'), + 'fingerprint': match.group('fingerprint'), + }) + + return { + 'type': _type, + 'values': values, + 'ttl': ttl, + } + + @staticmethod + def _data_for_CAA(_type, data): + ttl = data['raw_values'][0]['ttl'] + raw_value = data['raw_values'][0]['value'] + + match = MythicBeastsProvider.RE_CAA.match(raw_value) + + assert match is not None, 'Unable to parse CAA data' + + value = { + 'flags': match.group('flags'), + 'tag': match.group('tag'), + 'value': match.group('value'), + } + + return MythicBeastsProvider._data_for_single( + 'CAA', + {'raw_values': [{'value': value, 'ttl': ttl}]}) + + _data_for_NS = _data_for_multiple + _data_for_A = _data_for_multiple + _data_for_AAAA = _data_for_multiple + + def populate(self, zone, target=False, lenient=False): + self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, + target, lenient) + + resp = self.records(zone.name) + + before = len(zone.records) + exists = False + data = defaultdict(lambda: defaultdict(lambda: { + 'raw_values': [], + 'name': None, + 'zone': None, + })) + + exists = True + for line in resp.content.splitlines(): + match = MythicBeastsProvider.RE_POPLINE.match(line) + + if match is None: + self.log.debug('failed to match line: %s', line) + continue + + if match.group(1) == '@': + _name = '' + else: + _name = match.group('name') + + _type = match.group('type') + _ttl = int(match.group('ttl')) + _value = match.group('value').strip() + + if hasattr(self, '_data_for_{}'.format(_type)): + if _name not in data[_type]: + data[_type][_name] = { + 'raw_values': [{'value': _value, 'ttl': _ttl}], + 'name': _name, + 'zone': zone.name, + } + + else: + data[_type][_name].get('raw_values').append( + {'value': _value, 'ttl': _ttl} + ) + else: + self.log.debug('skipping %s as not supported', _type) + + for _type in data: + for _name in data[_type]: + data_for = getattr(self, '_data_for_{}'.format(_type)) + + record = Record.new( + zone, + _name, + data_for(_type, data[_type][_name]), + source=self + ) + zone.add_record(record, lenient=lenient) + + self.log.debug('populate: found %s records, exists=%s', + len(zone.records) - before, exists) + + return exists + + def _compile_commands(self, action, record): + commands = [] + + hostname = remove_trailing_dot(record.fqdn) + ttl = record.ttl + _type = record._type + + if _type == 'ALIAS': + _type = 'ANAME' + + if hasattr(record, 'values'): + values = record.values + else: + values = [record.value] + + base = '{} {} {} {}'.format(action, hostname, ttl, _type) + + # Unescape TXT records + if _type == 'TXT': + values = [value.replace('\\;', ';') for value in values] + + # Handle specific types or default + if _type == 'SSHFP': + data = values[0].data + commands.append('{} {} {} {}'.format( + base, + data['algorithm'], + data['fingerprint_type'], + data['fingerprint'] + )) + + elif _type == 'SRV': + for value in values: + data = value.data + commands.append('{} {} {} {} {}'.format( + base, + data['priority'], + data['weight'], + data['port'], + data['target'])) + + elif _type == 'MX': + for value in values: + data = value.data + commands.append('{} {} {}'.format( + base, + data['preference'], + data['exchange'])) + + else: + if hasattr(self, '_data_for_{}'.format(_type)): + for value in values: + commands.append('{} {}'.format(base, value)) + else: + self.log.debug('skipping %s as not supported', _type) + + return commands + + def _apply_Create(self, change): + zone = change.new.zone + commands = self._compile_commands('ADD', change.new) + + for command in commands: + self._post({ + 'domain': remove_trailing_dot(zone.name), + 'origin': '.', + 'password': self._passwords[zone.name], + 'command': command, + }) + return True + + def _apply_Update(self, change): + self._apply_Delete(change) + self._apply_Create(change) + + def _apply_Delete(self, change): + zone = change.existing.zone + commands = self._compile_commands('DELETE', change.existing) + + for command in commands: + self._post({ + 'domain': remove_trailing_dot(zone.name), + 'origin': '.', + 'password': self._passwords[zone.name], + 'command': command, + }) + return True + + def _apply(self, plan): + desired = plan.desired + changes = plan.changes + self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name, + len(changes)) + + for change in changes: + class_name = change.__class__.__name__ + getattr(self, '_apply_{}'.format(class_name))(change) diff --git a/tests/fixtures/mythicbeasts-list.txt b/tests/fixtures/mythicbeasts-list.txt new file mode 100644 index 0000000..ed4ea4c --- /dev/null +++ b/tests/fixtures/mythicbeasts-list.txt @@ -0,0 +1,25 @@ +@ 3600 NS 6.2.3.4. +@ 3600 NS 7.2.3.4. +@ 300 A 1.2.3.4 +@ 300 A 1.2.3.5 +@ 3600 SSHFP 1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73 +@ 3600 SSHFP 1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49 +@ 3600 CAA 0 issue ca.unit.tests +_srv._tcp 600 SRV 10 20 30 foo-1.unit.tests. +_srv._tcp 600 SRV 12 20 30 foo-2.unit.tests. +aaaa 600 AAAA 2601:644:500:e210:62f8:1dff:feb8:947a +cname 300 CNAME unit.tests. +excluded 300 CNAME unit.tests. +ignored 300 A 9.9.9.9 +included 3600 CNAME unit.tests. +mx 300 MX 10 smtp-4.unit.tests. +mx 300 MX 20 smtp-2.unit.tests. +mx 300 MX 30 smtp-3.unit.tests. +mx 300 MX 40 smtp-1.unit.tests. +sub 3600 NS 6.2.3.4. +sub 3600 NS 7.2.3.4. +txt 600 TXT "Bah bah black sheep" +txt 600 TXT "have you any wool." +txt 600 TXT "v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs" +www 300 A 2.2.3.6 +www.sub 300 A 2.2.3.6 diff --git a/tests/test_octodns_provider_mythicbeasts.py b/tests/test_octodns_provider_mythicbeasts.py new file mode 100644 index 0000000..5acbc55 --- /dev/null +++ b/tests/test_octodns_provider_mythicbeasts.py @@ -0,0 +1,451 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from os.path import dirname, join + +from requests_mock import ANY, mock as requests_mock +from unittest import TestCase + +from octodns.provider.mythicbeasts import MythicBeastsProvider, \ + add_trailing_dot, remove_trailing_dot +from octodns.provider.yaml import YamlProvider +from octodns.zone import Zone +from octodns.record import Create, Update, Delete, Record + + +class TestMythicBeastsProvider(TestCase): + expected = Zone('unit.tests.', []) + source = YamlProvider('test_expected', join(dirname(__file__), 'config')) + source.populate(expected) + + # Dump anything we don't support from expected + for record in list(expected.records): + if record._type not in MythicBeastsProvider.SUPPORTS: + expected._remove_record(record) + + def test_trailing_dot(self): + with self.assertRaises(AssertionError) as err: + add_trailing_dot('unit.tests.') + self.assertEquals('Value already has trailing dot', + err.exception.message) + + with self.assertRaises(AssertionError) as err: + remove_trailing_dot('unit.tests') + self.assertEquals('Value already missing trailing dot', + err.exception.message) + + self.assertEquals(add_trailing_dot('unit.tests'), 'unit.tests.') + self.assertEquals(remove_trailing_dot('unit.tests.'), 'unit.tests') + + def test_data_for_single(self): + test_data = { + 'raw_values': [{'value': 'a:a::c', 'ttl': 0}], + 'zone': 'unit.tests.', + } + test_single = MythicBeastsProvider._data_for_single('', test_data) + self.assertTrue(isinstance(test_single, dict)) + self.assertEquals('a:a::c', test_single['value']) + + def test_data_for_multiple(self): + test_data = { + 'raw_values': [ + {'value': 'b:b::d', 'ttl': 60}, + {'value': 'a:a::c', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_multiple = MythicBeastsProvider._data_for_multiple('', test_data) + self.assertTrue(isinstance(test_multiple, dict)) + self.assertEquals(2, len(test_multiple['values'])) + + def test_data_for_txt(self): + test_data = { + 'raw_values': [ + {'value': 'v=DKIM1; k=rsa; p=prawf', 'ttl': 60}, + {'value': 'prawf prawf dyma prawf', 'ttl': 300}], + 'zone': 'unit.tests.', + } + test_txt = MythicBeastsProvider._data_for_TXT('', test_data) + self.assertTrue(isinstance(test_txt, dict)) + self.assertEquals(2, len(test_txt['values'])) + self.assertEquals('v=DKIM1\\; k=rsa\\; p=prawf', test_txt['values'][0]) + + def test_data_for_MX(self): + test_data = { + 'raw_values': [ + {'value': '10 un.unit', 'ttl': 60}, + {'value': '20 dau.unit', 'ttl': 60}, + {'value': '30 tri.unit', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_MX = MythicBeastsProvider._data_for_MX('', test_data) + self.assertTrue(isinstance(test_MX, dict)) + self.assertEquals(3, len(test_MX['values'])) + + with self.assertRaises(AssertionError) as err: + test_MX = MythicBeastsProvider._data_for_MX( + '', + {'raw_values': [{'value': '', 'ttl': 0}]} + ) + self.assertEquals('Unable to parse MX data', + err.exception.message) + + def test_data_for_CNAME(self): + test_data = { + 'raw_values': [{'value': 'cname', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_cname = MythicBeastsProvider._data_for_CNAME('', test_data) + self.assertTrue(isinstance(test_cname, dict)) + self.assertEquals('cname.unit.tests.', test_cname['value']) + + def test_data_for_ANAME(self): + test_data = { + 'raw_values': [{'value': 'aname', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_aname = MythicBeastsProvider._data_for_ANAME('', test_data) + self.assertTrue(isinstance(test_aname, dict)) + self.assertEquals('aname', test_aname['value']) + + def test_data_for_SRV(self): + test_data = { + 'raw_values': [ + {'value': '10 20 30 un.srv.unit', 'ttl': 60}, + {'value': '20 30 40 dau.srv.unit', 'ttl': 60}, + {'value': '30 30 50 tri.srv.unit', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_SRV = MythicBeastsProvider._data_for_SRV('', test_data) + self.assertTrue(isinstance(test_SRV, dict)) + self.assertEquals(3, len(test_SRV['values'])) + + with self.assertRaises(AssertionError) as err: + test_SRV = MythicBeastsProvider._data_for_SRV( + '', + {'raw_values': [{'value': '', 'ttl': 0}]} + ) + self.assertEquals('Unable to parse SRV data', + err.exception.message) + + def test_data_for_SSHFP(self): + test_data = { + 'raw_values': [ + {'value': '1 1 0123456789abcdef', 'ttl': 60}, + {'value': '1 2 0123456789abcdef', 'ttl': 60}, + {'value': '2 3 0123456789abcdef', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_SSHFP = MythicBeastsProvider._data_for_SSHFP('', test_data) + self.assertTrue(isinstance(test_SSHFP, dict)) + self.assertEquals(3, len(test_SSHFP['values'])) + + with self.assertRaises(AssertionError) as err: + test_SSHFP = MythicBeastsProvider._data_for_SSHFP( + '', + {'raw_values': [{'value': '', 'ttl': 0}]} + ) + self.assertEquals('Unable to parse SSHFP data', + err.exception.message) + + def test_data_for_CAA(self): + test_data = { + 'raw_values': [{'value': '1 issue letsencrypt.org', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_CAA = MythicBeastsProvider._data_for_CAA('', test_data) + self.assertTrue(isinstance(test_CAA, dict)) + self.assertEquals(3, len(test_CAA['value'])) + + with self.assertRaises(AssertionError) as err: + test_CAA = MythicBeastsProvider._data_for_CAA( + '', + {'raw_values': [{'value': '', 'ttl': 0}]} + ) + self.assertEquals('Unable to parse CAA data', + err.exception.message) + + def test_command_generation(self): + zone = Zone('unit.tests.', []) + zone.add_record(Record.new(zone, 'prawf-alias', { + 'ttl': 60, + 'type': 'ALIAS', + 'value': 'alias.unit.tests.', + })) + zone.add_record(Record.new(zone, 'prawf-ns', { + 'ttl': 300, + 'type': 'NS', + 'values': [ + 'alias.unit.tests.', + 'alias2.unit.tests.', + ], + })) + zone.add_record(Record.new(zone, 'prawf-a', { + 'ttl': 60, + 'type': 'A', + 'values': [ + '1.2.3.4', + '5.6.7.8', + ], + })) + zone.add_record(Record.new(zone, 'prawf-aaaa', { + 'ttl': 60, + 'type': 'AAAA', + 'values': [ + 'a:a::a', + 'b:b::b', + 'c:c::c:c', + ], + })) + zone.add_record(Record.new(zone, 'prawf-txt', { + 'ttl': 60, + 'type': 'TXT', + 'value': 'prawf prawf dyma prawf', + })) + zone.add_record(Record.new(zone, 'prawf-txt2', { + 'ttl': 60, + 'type': 'TXT', + 'value': 'v=DKIM1\\; k=rsa\\; p=prawf', + })) + with requests_mock() as mock: + mock.post(ANY, status_code=200, text='') + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + + plan = provider.plan(zone) + changes = plan.changes + generated_commands = [] + + for change in changes: + generated_commands.extend( + provider._compile_commands('ADD', change.new) + ) + + expected_commands = [ + 'ADD prawf-alias.unit.tests 60 ANAME alias.unit.tests.', + 'ADD prawf-ns.unit.tests 300 NS alias.unit.tests.', + 'ADD prawf-ns.unit.tests 300 NS alias2.unit.tests.', + 'ADD prawf-a.unit.tests 60 A 1.2.3.4', + 'ADD prawf-a.unit.tests 60 A 5.6.7.8', + 'ADD prawf-aaaa.unit.tests 60 AAAA a:a::a', + 'ADD prawf-aaaa.unit.tests 60 AAAA b:b::b', + 'ADD prawf-aaaa.unit.tests 60 AAAA c:c::c:c', + 'ADD prawf-txt.unit.tests 60 TXT prawf prawf dyma prawf', + 'ADD prawf-txt2.unit.tests 60 TXT v=DKIM1; k=rsa; p=prawf', + ] + + generated_commands.sort() + expected_commands.sort() + + self.assertEquals( + generated_commands, + expected_commands + ) + + # Now test deletion + existing = 'prawf-txt 300 TXT prawf prawf dyma prawf\n' \ + 'prawf-txt2 300 TXT v=DKIM1; k=rsa; p=prawf\n' \ + 'prawf-a 60 A 1.2.3.4' + + with requests_mock() as mock: + mock.post(ANY, status_code=200, text=existing) + wanted = Zone('unit.tests.', []) + + plan = provider.plan(wanted) + changes = plan.changes + generated_commands = [] + + for change in changes: + generated_commands.extend( + provider._compile_commands('DELETE', change.existing) + ) + + expected_commands = [ + 'DELETE prawf-a.unit.tests 60 A 1.2.3.4', + 'DELETE prawf-txt.unit.tests 300 TXT prawf prawf dyma prawf', + 'DELETE prawf-txt2.unit.tests 300 TXT v=DKIM1; k=rsa; p=prawf', + ] + + generated_commands.sort() + expected_commands.sort() + + self.assertEquals( + generated_commands, + expected_commands + ) + + def test_fake_command_generation(self): + class FakeChangeRecord(object): + def __init__(self): + self.__fqdn = 'prawf.unit.tests.' + self._type = 'NOOP' + self.value = 'prawf' + self.ttl = 60 + + @property + def record(self): + return self + + @property + def fqdn(self): + return self.__fqdn + + with requests_mock() as mock: + mock.post(ANY, status_code=200, text='') + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + record = FakeChangeRecord() + command = provider._compile_commands('ADD', record) + self.assertEquals([], command) + + def test_populate(self): + provider = None + + # Null passwords dict + with self.assertRaises(AssertionError) as err: + provider = MythicBeastsProvider('test', None) + self.assertEquals('Passwords must be a dictionary', + err.exception.message) + + # Missing password + with requests_mock() as mock: + mock.post(ANY, status_code=401, text='ERR Not authenticated') + + with self.assertRaises(AssertionError) as err: + provider = MythicBeastsProvider('test', dict()) + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals( + 'Missing password for domain: unit.tests', + err.exception.message) + + # Failed authentication + with requests_mock() as mock: + mock.post(ANY, status_code=401, text='ERR Not authenticated') + + with self.assertRaises(Exception) as err: + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals( + 'Mythic Beasts unauthorized for zone: unit.tests', + err.exception.message) + + # Check unmatched lines are ignored + test_data = 'This should not match' + with requests_mock() as mock: + mock.post(ANY, status_code=200, text=test_data) + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals(0, len(zone.records)) + + # Check unsupported records are skipped + test_data = '@ 60 NOOP prawf\n@ 60 SPF prawf prawf prawf' + with requests_mock() as mock: + mock.post(ANY, status_code=200, text=test_data) + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals(0, len(zone.records)) + + # Check no changes between what we support and what's parsed + # from the unit.tests. config YAML. Also make sure we see the same + # for both after we've thrown away records we don't support + with requests_mock() as mock: + with open('tests/fixtures/mythicbeasts-list.txt') as file_handle: + mock.post(ANY, status_code=200, text=file_handle.read()) + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + zone = Zone('unit.tests.', []) + provider.populate(zone) + + self.assertEquals(15, len(zone.records)) + self.assertEquals(15, len(self.expected.records)) + changes = self.expected.changes(zone, provider) + self.assertEquals(0, len(changes)) + + def test_apply(self): + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + zone = Zone('unit.tests.', []) + + # Create blank zone + with requests_mock() as mock: + mock.post(ANY, status_code=200, text='') + provider.populate(zone) + + self.assertEquals(0, len(zone.records)) + + # Record change failed + with requests_mock() as mock: + mock.post(ANY, status_code=200, text='') + provider.populate(zone) + zone.add_record(Record.new(zone, 'prawf', { + 'ttl': 300, + 'type': 'TXT', + 'value': 'prawf', + })) + plan = provider.plan(zone) + + with requests_mock() as mock: + mock.post(ANY, status_code=400, text='NADD 300 TXT prawf') + + with self.assertRaises(Exception) as err: + provider.apply(plan) + self.assertEquals( + 'Mythic Beasts could not action command: unit.tests ' + 'ADD prawf.unit.tests 300 TXT prawf', + err.exception.message) + + # Check deleting and adding/changing test record + existing = 'prawf 300 TXT prawf prawf prawf\ndileu 300 TXT dileu' + + with requests_mock() as mock: + mock.post(ANY, status_code=200, text=existing) + + # Mash up a new zone with records so a plan + # is generated with changes and applied. For some reason + # passing self.expected, or just changing each record's zone + # doesn't work. Nor does this without a single add_record after + wanted = Zone('unit.tests.', []) + for record in list(self.expected.records): + data = {'type': record._type} + data.update(record.data) + wanted.add_record(Record.new(wanted, record.name, data)) + + wanted.add_record(Record.new(wanted, 'prawf', { + 'ttl': 60, + 'type': 'TXT', + 'value': 'prawf yw e', + })) + + plan = provider.plan(wanted) + + # Octo ignores NS records (15-1) + self.assertEquals(1, len(filter(lambda u: isinstance(u, Update), + plan.changes))) + self.assertEquals(1, len(filter(lambda d: isinstance(d, Delete), + plan.changes))) + self.assertEquals(14, len(filter(lambda c: isinstance(c, Create), + plan.changes))) + self.assertEquals(16, provider.apply(plan)) + self.assertTrue(plan.exists)