Browse Source

Merge pull request #47 from github/alias-support

Alias support
pull/68/head
Ross McFarland 9 years ago
committed by GitHub
parent
commit
a7c538dcd6
10 changed files with 214 additions and 24 deletions
  1. +6
    -0
      README.md
  2. +7
    -0
      octodns/provider/dnsimple.py
  3. +21
    -13
      octodns/provider/dyn.py
  4. +2
    -0
      octodns/provider/ns1.py
  5. +2
    -0
      octodns/provider/powerdns.py
  6. +18
    -6
      octodns/record.py
  7. +1
    -1
      tests/fixtures/dnsimple-page-1.json
  8. +17
    -1
      tests/fixtures/dnsimple-page-2.json
  9. +106
    -0
      tests/test_octodns_provider_dyn.py
  10. +34
    -3
      tests/test_octodns_record.py

+ 6
- 0
README.md View File

@ -158,6 +158,12 @@ The above command pulled the existing data out of Route53 and placed the results
| [TinyDNSSource](/octodns/source/tinydns.py) | A, CNAME, MX, NS, PTR | No | read-only | | [TinyDNSSource](/octodns/source/tinydns.py) | A, CNAME, MX, NS, PTR | No | read-only |
| [YamlProvider](/octodns/provider/yaml.py) | All | Yes | config | | [YamlProvider](/octodns/provider/yaml.py) | All | Yes | config |
#### Notes
* ALIAS support varies a lot fromm provider to provider care should be taken to verify that your needs are met in detail.
* Dyn's UI doesn't allow editing or view of TTL, but the API accepts and stores the value provided, this value does not appear to be used when served
* Dnsimple's uses the configured TTL when serving things through the ALIAS, there's also a secondary TXT record created alongside the ALIAS that octoDNS ignores
## Custom Sources and Providers ## Custom Sources and Providers
You can check out the [source](/octodns/source/) and [provider](/octodns/provider/) directory to see what's currently supported. Sources act as a source of record information. TinyDnsProvider is currently the only OSS source, though we have several others internally that are specific to our environment. These include something to pull host data from [gPanel](https://githubengineering.com/githubs-metal-cloud/) and a similar provider that sources information about our network gear to create both `A` & `PTR` records for their interfaces. Things that might make good OSS sources might include an `ElbSource` that pulls information about [AWS Elastic Load Balancers](https://aws.amazon.com/elasticloadbalancing/) and dynamically creates `CNAME`s for them, or `Ec2Source` that pulls instance information so that records can be created for hosts similar to how our `GPanelProvider` works. An `AxfrSource` could be really interesting as well. Another case where a source may make sense is if you'd like to export data from a legacy service that you have no plans to push changes back into. You can check out the [source](/octodns/source/) and [provider](/octodns/provider/) directory to see what's currently supported. Sources act as a source of record information. TinyDnsProvider is currently the only OSS source, though we have several others internally that are specific to our environment. These include something to pull host data from [gPanel](https://githubengineering.com/githubs-metal-cloud/) and a similar provider that sources information about our network gear to create both `A` & `PTR` records for their interfaces. Things that might make good OSS sources might include an `ElbSource` that pulls information about [AWS Elastic Load Balancers](https://aws.amazon.com/elasticloadbalancing/) and dynamically creates `CNAME`s for them, or `Ec2Source` that pulls instance information so that records can be created for hosts similar to how our `GPanelProvider` works. An `AxfrSource` could be really interesting as well. Another case where a source may make sense is if you'd like to export data from a legacy service that you have no plans to push changes back into.


+ 7
- 0
octodns/provider/dnsimple.py View File

@ -120,6 +120,8 @@ class DnsimpleProvider(BaseProvider):
'value': '{}.'.format(record['content']) 'value': '{}.'.format(record['content'])
} }
_data_for_ALIAS = _data_for_CNAME
def _data_for_MX(self, _type, records): def _data_for_MX(self, _type, records):
values = [] values = []
for record in records: for record in records:
@ -238,6 +240,10 @@ class DnsimpleProvider(BaseProvider):
_type = record['type'] _type = record['type']
if _type == 'SOA': if _type == 'SOA':
continue continue
elif _type == 'TXT' and record['content'].startswith('ALIAS for'):
# ALIAS has a "ride along" TXT record with 'ALIAS for XXXX',
# we're ignoring it
continue
values[record['name']][record['type']].append(record) values[record['name']][record['type']].append(record)
before = len(zone.records) before = len(zone.records)
@ -273,6 +279,7 @@ class DnsimpleProvider(BaseProvider):
'type': record._type 'type': record._type
} }
_params_for_ALIAS = _params_for_single
_params_for_CNAME = _params_for_single _params_for_CNAME = _params_for_single
_params_for_PTR = _params_for_single _params_for_PTR = _params_for_single


+ 21
- 13
octodns/provider/dyn.py View File

@ -109,6 +109,7 @@ class DynProvider(BaseProvider):
RECORDS_TO_TYPE = { RECORDS_TO_TYPE = {
'a_records': 'A', 'a_records': 'A',
'aaaa_records': 'AAAA', 'aaaa_records': 'AAAA',
'alias_records': 'ALIAS',
'cname_records': 'CNAME', 'cname_records': 'CNAME',
'mx_records': 'MX', 'mx_records': 'MX',
'naptr_records': 'NAPTR', 'naptr_records': 'NAPTR',
@ -119,19 +120,7 @@ class DynProvider(BaseProvider):
'srv_records': 'SRV', 'srv_records': 'SRV',
'txt_records': 'TXT', 'txt_records': 'TXT',
} }
TYPE_TO_RECORDS = {
'A': 'a_records',
'AAAA': 'aaaa_records',
'CNAME': 'cname_records',
'MX': 'mx_records',
'NAPTR': 'naptr_records',
'NS': 'ns_records',
'PTR': 'ptr_records',
'SSHFP': 'sshfp_records',
'SPF': 'spf_records',
'SRV': 'srv_records',
'TXT': 'txt_records',
}
TYPE_TO_RECORDS = {v: k for k, v in RECORDS_TO_TYPE.items()}
# https://help.dyn.com/predefined-geotm-regions-groups/ # https://help.dyn.com/predefined-geotm-regions-groups/
REGION_CODES = { REGION_CODES = {
@ -194,6 +183,15 @@ class DynProvider(BaseProvider):
_data_for_AAAA = _data_for_A _data_for_AAAA = _data_for_A
def _data_for_ALIAS(self, _type, records):
# See note on ttl in _kwargs_for_ALIAS
record = records[0]
return {
'type': _type,
'ttl': record.ttl,
'value': record.alias
}
def _data_for_CNAME(self, _type, records): def _data_for_CNAME(self, _type, records):
record = records[0] record = records[0]
return { return {
@ -385,6 +383,16 @@ class DynProvider(BaseProvider):
'ttl': record.ttl, 'ttl': record.ttl,
}] }]
def _kwargs_for_ALIAS(self, record):
# NOTE: Dyn's UI doesn't allow editing of ALIAS ttl, but the API seems
# to accept and store the values we send it just fine. No clue if they
# do anything with them. I'd assume they just obey the TTL of the
# record that we're pointed at which makes sense.
return [{
'alias': record.value,
'ttl': record.ttl,
}]
def _kwargs_for_MX(self, record): def _kwargs_for_MX(self, record):
return [{ return [{
'preference': v.priority, 'preference': v.priority,


+ 2
- 0
octodns/provider/ns1.py View File

@ -51,6 +51,7 @@ class Ns1Provider(BaseProvider):
'value': record['short_answers'][0], 'value': record['short_answers'][0],
} }
_data_for_ALIAS = _data_for_CNAME
_data_for_PTR = _data_for_CNAME _data_for_PTR = _data_for_CNAME
def _data_for_MX(self, _type, record): def _data_for_MX(self, _type, record):
@ -143,6 +144,7 @@ class Ns1Provider(BaseProvider):
def _params_for_CNAME(self, record): def _params_for_CNAME(self, record):
return {'answers': [record.value], 'ttl': record.ttl} return {'answers': [record.value], 'ttl': record.ttl}
_params_for_ALIAS = _params_for_CNAME
_params_for_PTR = _params_for_CNAME _params_for_PTR = _params_for_CNAME
def _params_for_MX(self, record): def _params_for_MX(self, record):


+ 2
- 0
octodns/provider/powerdns.py View File

@ -64,6 +64,7 @@ class PowerDnsBaseProvider(BaseProvider):
'ttl': rrset['ttl'] 'ttl': rrset['ttl']
} }
_data_for_ALIAS = _data_for_single
_data_for_CNAME = _data_for_single _data_for_CNAME = _data_for_single
_data_for_PTR = _data_for_single _data_for_PTR = _data_for_single
@ -191,6 +192,7 @@ class PowerDnsBaseProvider(BaseProvider):
def _records_for_single(self, record): def _records_for_single(self, record):
return [{'content': record.value, 'disabled': False}] return [{'content': record.value, 'disabled': False}]
_records_for_ALIAS = _records_for_single
_records_for_CNAME = _records_for_single _records_for_CNAME = _records_for_single
_records_for_PTR = _records_for_single _records_for_PTR = _records_for_single


+ 18
- 6
octodns/record.py View File

@ -71,7 +71,7 @@ class Record(object):
_type = { _type = {
'A': ARecord, 'A': ARecord,
'AAAA': AaaaRecord, 'AAAA': AaaaRecord,
# alias
'ALIAS': AliasRecord,
# cert # cert
'CNAME': CnameRecord, 'CNAME': CnameRecord,
# dhcid # dhcid
@ -188,13 +188,14 @@ class _ValuesMixin(object):
def __init__(self, zone, name, data, source=None): def __init__(self, zone, name, data, source=None):
super(_ValuesMixin, self).__init__(zone, name, data, source=source) super(_ValuesMixin, self).__init__(zone, name, data, source=source)
try: try:
self.values = sorted(self._process_values(data['values']))
values = data['values']
except KeyError: except KeyError:
try: try:
self.values = self._process_values([data['value']])
values = [data['value']]
except KeyError: except KeyError:
raise Exception('Invalid record {}, missing value(s)' raise Exception('Invalid record {}, missing value(s)'
.format(self.fqdn)) .format(self.fqdn))
self.values = sorted(self._process_values(values))
def changes(self, other, target): def changes(self, other, target):
if self.values != other.values: if self.values != other.values:
@ -293,10 +294,11 @@ class _ValueMixin(object):
def __init__(self, zone, name, data, source=None): def __init__(self, zone, name, data, source=None):
super(_ValueMixin, self).__init__(zone, name, data, source=source) super(_ValueMixin, self).__init__(zone, name, data, source=source)
try: try:
self.value = self._process_value(data['value'])
value = data['value']
except KeyError: except KeyError:
raise Exception('Invalid record {}, missing value' raise Exception('Invalid record {}, missing value'
.format(self.fqdn)) .format(self.fqdn))
self.value = self._process_value(value)
def changes(self, other, target): def changes(self, other, target):
if self.value != other.value: if self.value != other.value:
@ -314,12 +316,22 @@ class _ValueMixin(object):
self.fqdn, self.value) self.fqdn, self.value)
class AliasRecord(_ValueMixin, Record):
_type = 'ALIAS'
def _process_value(self, value):
if not value.endswith('.'):
raise Exception('Invalid record {}, value ({}) missing trailing .'
.format(self.fqdn, value))
return value
class CnameRecord(_ValueMixin, Record): class CnameRecord(_ValueMixin, Record):
_type = 'CNAME' _type = 'CNAME'
def _process_value(self, value): def _process_value(self, value):
if not value.endswith('.'): if not value.endswith('.'):
raise Exception('Invalid record {}, value {} missing trailing .'
raise Exception('Invalid record {}, value ({}) missing trailing .'
.format(self.fqdn, value)) .format(self.fqdn, value))
return value.lower() return value.lower()
@ -437,7 +449,7 @@ class PtrRecord(_ValueMixin, Record):
def _process_value(self, value): def _process_value(self, value):
if not value.endswith('.'): if not value.endswith('.'):
raise Exception('Invalid record {}, value {} missing trailing .'
raise Exception('Invalid record {}, value ({}) missing trailing .'
.format(self.fqdn, value)) .format(self.fqdn, value))
return value.lower() return value.lower()


+ 1
- 1
tests/fixtures/dnsimple-page-1.json View File

@ -308,7 +308,7 @@
"pagination": { "pagination": {
"current_page": 1, "current_page": 1,
"per_page": 20, "per_page": 20,
"total_entries": 28,
"total_entries": 29,
"total_pages": 2 "total_pages": 2
} }
} }

+ 17
- 1
tests/fixtures/dnsimple-page-2.json View File

@ -143,12 +143,28 @@
"system_record": false, "system_record": false,
"created_at": "2017-03-09T15:55:09Z", "created_at": "2017-03-09T15:55:09Z",
"updated_at": "2017-03-09T15:55:09Z" "updated_at": "2017-03-09T15:55:09Z"
},
{
"id": 11188802,
"zone_id": "unit.tests",
"parent_id": null,
"name": "txt",
"content": "ALIAS for www.unit.tests.",
"ttl": 600,
"priority": null,
"type": "TXT",
"regions": [
"global"
],
"system_record": false,
"created_at": "2017-03-09T15:55:09Z",
"updated_at": "2017-03-09T15:55:09Z"
} }
], ],
"pagination": { "pagination": {
"current_page": 2, "current_page": 2,
"per_page": 20, "per_page": 20,
"total_entries": 28,
"total_entries": 29,
"total_pages": 2 "total_pages": 2
} }
} }

+ 106
- 0
tests/test_octodns_provider_dyn.py View File

@ -1154,3 +1154,109 @@ class TestDynProviderGeo(TestCase):
# old ruleset ruleset should be deleted, it's pool will have been # old ruleset ruleset should be deleted, it's pool will have been
# reused # reused
ruleset_mock.delete.assert_called_once() ruleset_mock.delete.assert_called_once()
class TestDynProviderAlias(TestCase):
expected = Zone('unit.tests.', [])
for name, data in (
('', {
'type': 'ALIAS',
'ttl': 300,
'value': 'www.unit.tests.'
}),
('www', {
'type': 'A',
'ttl': 300,
'values': ['1.2.3.4']
})):
expected.add_record(Record.new(expected, name, data))
def setUp(self):
# Flush our zone to ensure we start fresh
_CachingDynZone.flush_zone(self.expected.name[:-1])
@patch('dyn.core.SessionEngine.execute')
def test_populate(self, execute_mock):
provider = DynProvider('test', 'cust', 'user', 'pass')
# Test Zone create
execute_mock.side_effect = [
# get Zone
{'data': {}},
# get_all_records
{'data': {
'a_records': [{
'fqdn': 'www.unit.tests',
'rdata': {'address': '1.2.3.4'},
'record_id': 1,
'record_type': 'A',
'ttl': 300,
'zone': 'unit.tests',
}],
'alias_records': [{
'fqdn': 'unit.tests',
'rdata': {'alias': 'www.unit.tests.'},
'record_id': 2,
'record_type': 'ALIAS',
'ttl': 300,
'zone': 'unit.tests',
}],
}}
]
got = Zone('unit.tests.', [])
provider.populate(got)
execute_mock.assert_has_calls([
call('/Zone/unit.tests/', 'GET', {}),
call('/AllRecord/unit.tests/unit.tests./', 'GET', {'detail': 'Y'})
])
changes = self.expected.changes(got, SimpleProvider())
self.assertEquals([], changes)
@patch('dyn.core.SessionEngine.execute')
def test_sync(self, execute_mock):
provider = DynProvider('test', 'cust', 'user', 'pass')
# Test Zone create
execute_mock.side_effect = [
# No such zone, during populate
DynectGetError('foo'),
# No such zone, during sync
DynectGetError('foo'),
# get empty Zone
{'data': {}},
# get zone we can modify & delete with
{'data': {
# A top-level to delete
'a_records': [{
'fqdn': 'www.unit.tests',
'rdata': {'address': '1.2.3.4'},
'record_id': 1,
'record_type': 'A',
'ttl': 300,
'zone': 'unit.tests',
}],
# A node to delete
'alias_records': [{
'fqdn': 'unit.tests',
'rdata': {'alias': 'www.unit.tests.'},
'record_id': 2,
'record_type': 'ALIAS',
'ttl': 300,
'zone': 'unit.tests',
}],
}}
]
# No existing records, create all
with patch('dyn.tm.zones.Zone.add_record') as add_mock:
with patch('dyn.tm.zones.Zone._update') as update_mock:
plan = provider.plan(self.expected)
update_mock.assert_not_called()
provider.apply(plan)
update_mock.assert_called()
add_mock.assert_called()
# Once for each dyn record
self.assertEquals(2, len(add_mock.call_args_list))
execute_mock.assert_has_calls([call('/Zone/unit.tests/', 'GET', {}),
call('/Zone/unit.tests/', 'GET', {})])
self.assertEquals(2, len(plan.changes))

+ 34
- 3
tests/test_octodns_record.py View File

@ -7,9 +7,9 @@ from __future__ import absolute_import, division, print_function, \
from unittest import TestCase from unittest import TestCase
from octodns.record import ARecord, AaaaRecord, CnameRecord, Create, Delete, \
GeoValue, MxRecord, NaptrRecord, NaptrValue, NsRecord, PtrRecord, Record, \
SshfpRecord, SpfRecord, SrvRecord, TxtRecord, Update
from octodns.record import ARecord, AaaaRecord, AliasRecord, CnameRecord, \
Create, Delete, GeoValue, MxRecord, NaptrRecord, NaptrValue, NsRecord, \
PtrRecord, Record, SshfpRecord, SpfRecord, SrvRecord, TxtRecord, Update
from octodns.zone import Zone from octodns.zone import Zone
from helpers import GeoProvider, SimpleProvider from helpers import GeoProvider, SimpleProvider
@ -242,6 +242,37 @@ class TestRecord(TestCase):
# __repr__ doesn't blow up # __repr__ doesn't blow up
a.__repr__() a.__repr__()
def test_alias(self):
a_data = {'ttl': 0, 'value': 'www.unit.tests.'}
a = AliasRecord(self.zone, '', a_data)
self.assertEquals('', a.name)
self.assertEquals('unit.tests.', a.fqdn)
self.assertEquals(0, a.ttl)
self.assertEquals(a_data['value'], a.value)
self.assertEquals(a_data, a.data)
# missing value
with self.assertRaises(Exception) as ctx:
AliasRecord(self.zone, None, {'ttl': 0})
self.assertTrue('missing value' in ctx.exception.message)
# bad name
with self.assertRaises(Exception) as ctx:
AliasRecord(self.zone, None, {'ttl': 0, 'value': 'www.unit.tests'})
self.assertTrue('missing trailing .' in ctx.exception.message)
target = SimpleProvider()
# No changes with self
self.assertFalse(a.changes(a, target))
# Diff in value causes change
other = AliasRecord(self.zone, 'a', a_data)
other.value = 'foo.unit.tests.'
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): def test_cname(self):
self.assertSingleValue(CnameRecord, 'target.foo.com.', self.assertSingleValue(CnameRecord, 'target.foo.com.',
'other.foo.com.') 'other.foo.com.')


Loading…
Cancel
Save