1
0
mirror of https://github.com/github/octodns.git synced 2024-05-11 05:55:00 +00:00

Added tests, clean up and small modifications

This commit is contained in:
Rhosyn Celyn
2019-05-13 16:51:38 +01:00
parent 2b6d86fb4f
commit fd63150cac
3 changed files with 517 additions and 138 deletions

View File

@@ -7,7 +7,7 @@ from __future__ import absolute_import, division, print_function, \
import re
from requests import HTTPError, Session
from requests import Session
from logging import getLogger
from ..record import Record
@@ -18,8 +18,8 @@ def add_trailing_dot(value):
'''
Add trailing dots to values
'''
assert value
assert value[-1] != '.'
assert value, 'Missing value'
assert value[-1] != '.', 'Value already has trailing dot'
return value + '.'
@@ -27,8 +27,8 @@ def remove_trailing_dot(value):
'''
Remove trailing dots from values
'''
assert value
assert value[-1] == '.'
assert value, 'Missing value'
assert value[-1] == '.', 'Value already missing trailing dot'
return value[:-1]
@@ -44,13 +44,15 @@ class MythicBeastsProvider(BaseProvider):
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS',
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NS',
'SRV', 'SSHFP', 'CAA', 'TXT'))
BASE = 'https://dnsapi.mythic-beasts.com/'
TIMEOUT = 15
def __init__(self, identifier, passwords, *args, **kwargs):
self.log = getLogger('MythicBeastsProvider[{}]'.format(identifier))
assert isinstance(passwords, dict), 'Missing passwords'
self.log.debug(
'__init__: id=%s, registered zones; %s',
identifier,
@@ -62,23 +64,28 @@ class MythicBeastsProvider(BaseProvider):
self._sess = sess
def _request(self, method, path, data=None):
self.log.debug('_request: method=%s, path=%s', method, path)
self.log.debug('_request: method=%s, path=%s data=%s',
method, path, data)
url = self.BASE
resp = self._sess.request(method, url, data=data, timeout=self.TIMEOUT)
resp = self._sess.request(method, path, data=data)
self.log.debug(
'_request: status=%d data=%s',
resp.status_code,
resp.text)
if resp.status_code != 200:
self.log.info('request failed: %s, response %s', data, resp.text)
resp.raise_for_status()
resp.text[:20])
if resp.status_code == 401:
raise Exception('Mythic Beasts unauthorized for domain: {}'
.format(data['domain']))
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],
@@ -113,16 +120,17 @@ class MythicBeastsProvider(BaseProvider):
[raw_values['value'] for raw_values in data['raw_values']]:
match = re.match('^([0-9]+)\\s+(\\S+)$', raw_value, re.IGNORECASE)
if match is not None:
exchange = match.group(2)
assert match is not None, 'Unable to parse MX data'
if not exchange.endswith('.'):
exchange = '{}.{}'.format(exchange, data['zone'])
exchange = match.group(2)
values.append({
'preference': match.group(1),
'exchange': exchange,
})
if not exchange.endswith('.'):
exchange = '{}.{}'.format(exchange, data['zone'])
values.append({
'preference': match.group(1),
'exchange': exchange,
})
return {
'type': _type,
@@ -155,54 +163,61 @@ class MythicBeastsProvider(BaseProvider):
@staticmethod
def _data_for_SRV(_type, data):
ttl = data['raw_values'][0]['ttl']
raw_value = data['raw_values'][0]['value']
ttl = max([raw_values['ttl'] for raw_values in data['raw_values']])
values = []
match = re.match(
'^([0-9]+)\\s+([0-9]+)\\s+([0-9]+)\\s+(\\S+)$',
raw_value,
re.IGNORECASE)
for raw_value in \
[raw_values['value'] for raw_values in data['raw_values']]:
match = re.match(
'^([0-9]+)\\s+([0-9]+)\\s+([0-9]+)\\s+(\\S+)$',
raw_value,
re.IGNORECASE)
assert match is not None, 'Unable to parse SRV data'
if match is not None:
target = match.group(4)
if not target.endswith('.'):
target = '{}.{}'.format(target, data['zone'])
value = {
values.append({
'priority': match.group(1),
'weight': match.group(2),
'port': match.group(3),
'target': target,
}
})
return MythicBeastsProvider._data_for_single(
'SRV',
{'raw_values': [
{'value': value, 'ttl': ttl}
]})
return {
'type': _type,
'values': values,
'ttl': ttl,
}
@staticmethod
def _data_for_SSHFP(_type, data):
ttl = data['raw_values'][0]['ttl']
raw_value = data['raw_values'][0]['value']
ttl = max([raw_values['ttl'] for raw_values in data['raw_values']])
values = []
match = re.match(
'^([0-9]+)\\s+([0-9]+)\\s+(\\S+)$',
raw_value,
re.IGNORECASE)
for raw_value in \
[raw_values['value'] for raw_values in data['raw_values']]:
match = re.match(
'^([0-9]+)\\s+([0-9]+)\\s+(\\S+)$',
raw_value,
re.IGNORECASE)
if match is not None:
value = {
assert match is not None, 'Unable to parse SSHFP data'
values.append({
'algorithm': match.group(1),
'fingerprint_type': match.group(2),
'fingerprint': match.group(3),
}
})
return MythicBeastsProvider._data_for_single(
'SSHFP',
{'raw_values': [
{'value': value, 'ttl': ttl}
]})
return {
'type': _type,
'values': values,
'ttl': ttl,
}
@staticmethod
def _data_for_CAA(_type, data):
@@ -214,17 +229,17 @@ class MythicBeastsProvider(BaseProvider):
raw_value,
re.IGNORECASE)
if match is not None:
value = {
'flags': match.group(1),
'tag': match.group(2),
'value': match.group(3),
}
return MythicBeastsProvider._data_for_single(
'CAA',
{'raw_values': [
{'value': value, 'ttl': ttl}
]})
assert match is not None, 'Unable to parse CAA data'
value = {
'flags': match.group(1),
'tag': match.group(2),
'value': match.group(3),
}
return MythicBeastsProvider._data_for_single(
'CAA',
{'raw_values': [{'value': value, 'ttl': ttl}]})
_data_for_NS = _data_for_multiple
_data_for_TXT = _data_for_multiple
@@ -235,84 +250,69 @@ class MythicBeastsProvider(BaseProvider):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient)
resp = None
try:
resp = self.records(zone.name)
except HTTPError as err:
if err.response.status_code == 401:
# Nicer error message for auth problems
raise Exception(
'Mythic Beasts authentication problem with {}'.format(
zone.name))
else:
# just re-throw
raise
resp = self.records(zone.name)
before = len(zone.records)
exists = False
data = dict()
if resp:
exists = True
for line in resp.content.splitlines():
match = re.match(
'^(\\S+)\\s+(\\S+)\\s+(\\S+)\\s+(.*)$',
line,
re.IGNORECASE)
exists = True
for line in resp.content.splitlines():
match = re.match(
'^(\\S+)\\s+(\\d+)\\s+(\\S+)\\s+(.*)$',
line,
re.IGNORECASE)
if match is not None:
if match.group(1) == '@':
_name = ''
else:
_name = match.group(1)
if match is None:
self.log.debug('failed to match line: %s', line)
continue
_type = match.group(3)
_ttl = int(match.group(2))
_value = match.group(4).strip()
if match.group(1) == '@':
_name = ''
else:
_name = match.group(1)
if _type == 'SOA':
continue
_type = match.group(3)
_ttl = int(match.group(2))
_value = match.group(4).strip()
try:
if getattr(self, '_data_for_{}'.format(_type)) is not None:
if _type == 'TXT':
_value = _value.replace(';', '\\;')
if _type not in data:
data[_type] = dict()
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,
}
if _type not in data:
data[_type] = dict()
else:
data[_type][_name].get('raw_values').append(
{'value': _value, 'ttl': _ttl}
)
except AttributeError:
self.log.debug('skipping {} as not supported', _type)
continue
if _name not in data[_type]:
data[_type][_name] = {
'raw_values': [{'value': _value, 'ttl': _ttl}],
'name': _name,
'zone': zone.name,
}
for _type in data:
for _name in data[_type]:
data_for = getattr(self, '_data_for_{}'.format(_type))
self.log.debug(
'record: %s,\t%s',
_type,
data[_type][_name])
record = Record.new(
zone,
_name,
data_for(_type, data[_type][_name]),
source=self
else:
data[_type][_name].get('raw_values').append(
{'value': _value, 'ttl': _ttl}
)
zone.add_record(record, lenient=lenient)
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, change):
@@ -322,8 +322,7 @@ class MythicBeastsProvider(BaseProvider):
if action == 'ADD':
record = change.new
elif action == 'DELETE':
else:
record = change.existing
hostname = remove_trailing_dot(record.fqdn)
@@ -354,13 +353,14 @@ class MythicBeastsProvider(BaseProvider):
))
elif _type == 'SRV':
data = values[0].data
commands.append('{} {} {} {} {}'.format(
base,
data['priority'],
data['weight'],
data['port'],
data['target']))
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:
@@ -371,13 +371,11 @@ class MythicBeastsProvider(BaseProvider):
data['exchange']))
else:
try:
if getattr(self, '_data_for_{}'.format(_type)) is not None:
commands.append('{} {}'.format(
base, values[0]))
except AttributeError:
self.log.debug('skipping {} as not supported', _type)
pass
if hasattr(self, '_data_for_{}'.format(_type)):
commands.append('{} {}'.format(
base, values[0]))
else:
self.log.debug('skipping %s as not supported', _type)
return commands

25
tests/fixtures/mythicbeasts-list.txt vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,356 @@
#
#
#
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_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_alias_command_generation(self):
zone = Zone('unit.tests.', [])
zone.add_record(Record.new(zone, 'prawf', {
'ttl': 60,
'type': 'ALIAS',
'value': 'alias.unit.tests.',
}))
with requests_mock() as mock:
mock.post(ANY, status_code=200, text='')
provider = MythicBeastsProvider('test', {
'unit.tests.': 'mypassword'
})
plan = provider.plan(zone)
change = plan.changes[0]
command = provider._compile_commands('ADD', change)
self.assertEquals(
['ADD prawf.unit.tests 60 ANAME alias.unit.tests.'],
command
)
def test_txt_command_generation(self):
zone = Zone('unit.tests.', [])
zone.add_record(Record.new(zone, 'prawf', {
'ttl': 60,
'type': 'TXT',
'value': 'prawf prawf dyma prawf',
}))
with requests_mock() as mock:
mock.post(ANY, status_code=200, text='')
provider = MythicBeastsProvider('test', {
'unit.tests.': 'mypassword'
})
plan = provider.plan(zone)
change = plan.changes[0]
command = provider._compile_commands('ADD', change)
self.assertEquals(
['ADD prawf.unit.tests 60 TXT prawf prawf dyma prawf'],
command
)
def test_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 new(self):
return self
@property
def existing(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('Missing passwords', 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 domain: 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))
# 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)