Browse Source

Merge remote-tracking branch 'origin/master' into geo-cname

pull/101/head
Ross McFarland 8 years ago
parent
commit
e60acefd1c
No known key found for this signature in database GPG Key ID: 61C10C4FC8FE4A89
21 changed files with 997 additions and 68 deletions
  1. +2
    -0
      .gitignore
  2. +13
    -2
      CHANGELOG.md
  3. +1
    -0
      README.md
  4. +2
    -4
      octodns/__init__.py
  5. +443
    -0
      octodns/provider/azuredns.py
  6. +12
    -12
      octodns/provider/base.py
  7. +19
    -4
      octodns/provider/ns1.py
  8. +5
    -3
      octodns/provider/powerdns.py
  9. +11
    -2
      octodns/provider/route53.py
  10. +7
    -3
      octodns/yaml.py
  11. +32
    -17
      octodns/zone.py
  12. +9
    -6
      requirements.txt
  13. +379
    -0
      tests/test_octodns_provider_azuredns.py
  14. +6
    -2
      tests/test_octodns_provider_base.py
  15. +1
    -1
      tests/test_octodns_provider_cloudflare.py
  16. +1
    -1
      tests/test_octodns_provider_dnsimple.py
  17. +36
    -1
      tests/test_octodns_provider_ns1.py
  18. +1
    -1
      tests/test_octodns_provider_powerdns.py
  19. +14
    -6
      tests/test_octodns_provider_route53.py
  20. +2
    -2
      tests/test_octodns_yaml.py
  21. +1
    -1
      tests/test_octodns_zone.py

+ 2
- 0
.gitignore View File

@ -9,3 +9,5 @@ nosetests.xml
octodns.egg-info/ octodns.egg-info/
output/ output/
tmp/ tmp/
build/
config/

+ 13
- 2
CHANGELOG.md View File

@ -1,5 +1,16 @@
## v0.8.5 - 2017-07-21 - Azure, NS1 escaping, & large zones
## v0.8.4 - 2017-03-14 - It's been too long
Relatively small delta this go around. No major themes or anything, just steady
progress.
* AzureProvider added thanks to work by
[Heesu Hwang](https://github.com/h-hwang).
* Fixed some escaping issues with NS1 TXT and SPF records that were tracked down
with the help of [Blake Stoddard](https://github.com/blakestoddard).
* Some tweaks were made to Zone.records to vastly improve handling of zones with
very large numbers of records, no more O(N^2).
## v0.8.4 - 2017-06-28 - It's been too long
Lots of updates based on our internal use, needs, and feedback & suggestions Lots of updates based on our internal use, needs, and feedback & suggestions
from our OSS users. There's too much to list out since the previous release was from our OSS users. There's too much to list out since the previous release was
@ -28,7 +39,7 @@ better in the future :fingers_crossed:
implementation. Sorting can be disabled in the YamlProvider with implementation. Sorting can be disabled in the YamlProvider with
`enforce_order: False`. `enforce_order: False`.
* Semi-colon/escaping fixes and improvements. * Semi-colon/escaping fixes and improvements.
* Meta record support, `TXT octodns-meta.<zone>`. For now just
* Meta record support, `TXT octodns-meta.<zone>`. For now just
`provider=<provider-id>`. Optionally turned on with `include_meta` manager `provider=<provider-id>`. Optionally turned on with `include_meta` manager
config val. config val.
* Validations check for CNAMEs co-existing with other records and error out if * Validations check for CNAMEs co-existing with other records and error out if


+ 1
- 0
README.md View File

@ -149,6 +149,7 @@ The above command pulled the existing data out of Route53 and placed the results
| Provider | Record Support | GeoDNS Support | Notes | | 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 | | | [CloudflareProvider](/octodns/provider/cloudflare.py) | A, AAAA, CNAME, MX, NS, SPF, TXT | No | |
| [DnsimpleProvider](/octodns/provider/dnsimple.py) | All | No | | | [DnsimpleProvider](/octodns/provider/dnsimple.py) | All | No | |
| [DynProvider](/octodns/provider/dyn.py) | All | Yes | | | [DynProvider](/octodns/provider/dyn.py) | All | Yes | |


+ 2
- 4
octodns/__init__.py View File

@ -1,8 +1,6 @@
'''
OctoDNS: DNS as code - Tools for managing DNS across multiple providers
'''
'OctoDNS: DNS as code - Tools for managing DNS across multiple providers'
from __future__ import absolute_import, division, print_function, \ from __future__ import absolute_import, division, print_function, \
unicode_literals unicode_literals
__VERSION__ = '0.8.4'
__VERSION__ = '0.8.5'

+ 443
- 0
octodns/provider/azuredns.py View File

@ -0,0 +1,443 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from azure.common.credentials import ServicePrincipalCredentials
from azure.mgmt.dns import DnsManagementClient
from msrestazure.azure_exceptions import CloudError
from azure.mgmt.dns.models import ARecord, AaaaRecord, CnameRecord, MxRecord, \
SrvRecord, NsRecord, PtrRecord, TxtRecord, Zone
import logging
from functools import reduce
from ..record import Record
from .base import BaseProvider
class _AzureRecord(object):
'''Wrapper for OctoDNS record for AzureProvider to make dns_client calls.
azuredns.py:
class: octodns.provider.azuredns._AzureRecord
An _AzureRecord is easily accessible to Azure DNS Management library
functions and is used to wrap all relevant data to create a record in
Azure.
'''
TYPE_MAP = {
'A': ARecord,
'AAAA': AaaaRecord,
'CNAME': CnameRecord,
'MX': MxRecord,
'SRV': SrvRecord,
'NS': NsRecord,
'PTR': PtrRecord,
'TXT': TxtRecord
}
def __init__(self, resource_group, record, delete=False):
'''Contructor for _AzureRecord.
Notes on Azure records: An Azure record set has the form
RecordSet(name=<...>, type=<...>, arecords=[...], aaaa_records, ..)
When constructing an azure record as done in self._apply_Create,
the argument parameters for an A record would be
parameters={'ttl': <int>, 'arecords': [ARecord(<str ip>),]}.
As another example for CNAME record:
parameters={'ttl': <int>, 'cname_record': CnameRecord(<str>)}.
Below, key_name and class_name are the dictionary key and Azure
Record class respectively.
:param resource_group: The name of resource group in Azure
:type resource_group: str
:param record: An OctoDNS record
:type record: ..record.Record
:param delete: If true, omit data parsing; not needed to delete
:type delete: bool
:type return: _AzureRecord
'''
self.resource_group = resource_group
self.zone_name = record.zone.name[:len(record.zone.name) - 1]
self.relative_record_set_name = record.name or '@'
self.record_type = record._type
if delete:
return
# Refer to function docstring for key_name and class_name.
format_u_s = '' if record._type == 'A' else '_'
key_name = '{}{}records'.format(self.record_type, format_u_s).lower()
if record._type == 'CNAME':
key_name = key_name[:len(key_name) - 1]
azure_class = self.TYPE_MAP[self.record_type]
self.params = getattr(self, '_params_for_{}'.format(record._type))
self.params = self.params(record.data, key_name, azure_class)
self.params['ttl'] = record.ttl
def _params(self, data, key_name, azure_class):
try:
values = data['values']
except KeyError:
values = [data['value']]
return {key_name: [azure_class(v) for v in values]}
_params_for_A = _params
_params_for_AAAA = _params
_params_for_NS = _params
_params_for_PTR = _params
def _params_for_CNAME(self, data, key_name, azure_class):
return {key_name: azure_class(data['value'])}
def _params_for_MX(self, data, key_name, azure_class):
params = []
if 'values' in data:
for vals in data['values']:
params.append(azure_class(vals['preference'],
vals['exchange']))
else: # Else there is a singular data point keyed by 'value'.
params.append(azure_class(data['value']['preference'],
data['value']['exchange']))
return {key_name: params}
def _params_for_SRV(self, data, key_name, azure_class):
params = []
if 'values' in data:
for vals in data['values']:
params.append(azure_class(vals['priority'],
vals['weight'],
vals['port'],
vals['target']))
else: # Else there is a singular data point keyed by 'value'.
params.append(azure_class(data['value']['priority'],
data['value']['weight'],
data['value']['port'],
data['value']['target']))
return {key_name: params}
def _params_for_TXT(self, data, key_name, azure_class):
try: # API for TxtRecord has list of str, even for singleton
values = data['values']
except KeyError:
values = [data['value']]
return {key_name: [azure_class([v]) for v in values]}
def _equals(self, b):
'''Checks whether two records are equal by comparing all fields.
:param b: Another _AzureRecord object
:type b: _AzureRecord
:type return: bool
'''
def parse_dict(params):
vals = []
for char in params:
if char != 'ttl':
list_records = params[char]
try:
for record in list_records:
vals.append(record.__dict__)
except:
vals.append(list_records.__dict__)
vals.sort()
return vals
return (self.resource_group == b.resource_group) & \
(self.zone_name == b.zone_name) & \
(self.record_type == b.record_type) & \
(self.params['ttl'] == b.params['ttl']) & \
(parse_dict(self.params) == parse_dict(b.params)) & \
(self.relative_record_set_name == b.relative_record_set_name)
def __str__(self):
'''String representation of an _AzureRecord.
:type return: str
'''
string = 'Zone: {}; '.format(self.zone_name)
string += 'Name: {}; '.format(self.relative_record_set_name)
string += 'Type: {}; '.format(self.record_type)
if not hasattr(self, 'params'):
return string
string += 'Ttl: {}; '.format(self.params['ttl'])
for char in self.params:
if char != 'ttl':
try:
for rec in self.params[char]:
string += 'Record: {}; '.format(rec.__dict__)
except:
string += 'Record: {}; '.format(self.params[char].__dict__)
return string
def _check_endswith_dot(string):
return string if string.endswith('.') else string + '.'
def _parse_azure_type(string):
'''Converts string representing an Azure RecordSet type to usual type.
:param string: the Azure type. eg: <Microsoft.Network/dnszones/A>
:type string: str
:type return: str
'''
return string.split('/')[len(string.split('/')) - 1]
class AzureProvider(BaseProvider):
'''
Azure DNS Provider
azuredns.py:
class: octodns.provider.azuredns.AzureProvider
# Current support of authentication of access to Azure services only
# includes using a Service Principal:
# https://docs.microsoft.com/en-us/azure/azure-resource-manager/
# resource-group-create-service-principal-portal
# The Azure Active Directory Application ID (aka client ID):
client_id:
# Authentication Key Value: (note this should be secret)
key:
# Directory ID (aka tenant ID):
directory_id:
# Subscription ID:
sub_id:
# Resource Group name:
resource_group:
# All are required to authenticate.
Example config file with variables:
"
---
providers:
config:
class: octodns.provider.yaml.YamlProvider
directory: ./config (example path to directory of zone files)
azuredns:
class: octodns.provider.azuredns.AzureProvider
client_id: env/AZURE_APPLICATION_ID
key: env/AZURE_AUTHENICATION_KEY
directory_id: env/AZURE_DIRECTORY_ID
sub_id: env/AZURE_SUBSCRIPTION_ID
resource_group: 'TestResource1'
zones:
example.com.:
sources:
- config
targets:
- azuredns
"
The first four variables above can be hidden in environment variables
and octoDNS will automatically search for them in the shell. It is
possible to also hard-code into the config file: eg, resource_group.
'''
SUPPORTS_GEO = False
SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'SRV', 'TXT'))
def __init__(self, id, client_id, key, directory_id, sub_id,
resource_group, *args, **kwargs):
self.log = logging.getLogger('AzureProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, client_id=%s, '
'key=***, directory_id:%s', id, client_id, directory_id)
super(AzureProvider, self).__init__(id, *args, **kwargs)
credentials = ServicePrincipalCredentials(
client_id, secret=key, tenant=directory_id
)
self._dns_client = DnsManagementClient(credentials, sub_id)
self._resource_group = resource_group
self._azure_zones = set()
def _populate_zones(self):
self.log.debug('azure_zones: loading')
list_zones = self._dns_client.zones.list_by_resource_group
for zone in list_zones(self._resource_group):
self._azure_zones.add(zone.name)
def _check_zone(self, name, create=False):
'''Checks whether a zone specified in a source exist in Azure server.
Note that Azure zones omit end '.' eg: contoso.com vs contoso.com.
Returns the name if it exists.
:param name: Name of a zone to checks
:type name: str
:param create: If True, creates the zone of that name.
:type create: bool
:type return: str or None
'''
self.log.debug('_check_zone: name=%s', name)
try:
if name in self._azure_zones:
return name
self._dns_client.zones.get(self._resource_group, name)
self._azure_zones.add(name)
return name
except CloudError as err:
msg = 'The Resource \'Microsoft.Network/dnszones/{}\''.format(name)
msg += ' under resource group \'{}\''.format(self._resource_group)
msg += ' was not found.'
if msg == err.message:
# Then the only error is that the zone doesn't currently exist
if create:
self.log.debug('_check_zone:no matching zone; creating %s',
name)
create_zone = self._dns_client.zones.create_or_update
create_zone(self._resource_group, name, Zone('global'))
return name
else:
return
raise
def populate(self, zone, target=False, lenient=False):
'''Required function of manager.py to collect records from zone.
Special notes for Azure.
Azure zone names omit final '.'
Azure root records names are represented by '@'. OctoDNS uses ''
Azure records created through online interface may have null values
(eg, no IP address for A record).
Azure online interface allows constructing records with null values
which are destroyed by _apply.
Specific quirks such as these are responsible for any non-obvious
parsing in this function and the functions '_params_for_*'.
:param zone: A dns zone
:type zone: octodns.zone.Zone
:param target: Checks if Azure is source or target of config.
Currently only supports as a target. Unused.
:type target: bool
:param lenient: Unused. Check octodns.manager for usage.
:type lenient: bool
:type return: void
'''
self.log.debug('populate: name=%s', zone.name)
before = len(zone.records)
zone_name = zone.name[:len(zone.name) - 1]
self._populate_zones()
self._check_zone(zone_name)
_records = set()
records = self._dns_client.record_sets.list_by_dns_zone
if self._check_zone(zone_name):
for azrecord in records(self._resource_group, zone_name):
if _parse_azure_type(azrecord.type) in self.SUPPORTS:
_records.add(azrecord)
for azrecord in _records:
record_name = azrecord.name if azrecord.name != '@' else ''
typ = _parse_azure_type(azrecord.type)
data = getattr(self, '_data_for_{}'.format(typ))
data = data(azrecord)
data['type'] = typ
data['ttl'] = azrecord.ttl
record = Record.new(zone, record_name, data, source=self)
zone.add_record(record)
self.log.info('populate: found %s records', len(zone.records) - before)
def _data_for_A(self, azrecord):
return {'values': [ar.ipv4_address for ar in azrecord.arecords]}
def _data_for_AAAA(self, azrecord):
return {'values': [ar.ipv6_address for ar in azrecord.aaaa_records]}
def _data_for_CNAME(self, azrecord):
'''Parsing data from Azure DNS Client record call
:param azrecord: a return of a call to list azure records
:type azrecord: azure.mgmt.dns.models.RecordSet
:type return: dict
CNAME and PTR both use the catch block to catch possible empty
records. Refer to population comment.
'''
try:
return {'value': _check_endswith_dot(azrecord.cname_record.cname)}
except:
return {'value': '.'}
def _data_for_MX(self, azrecord):
return {'values': [{'preference': ar.preference,
'exchange': ar.exchange}
for ar in azrecord.mx_records]}
def _data_for_NS(self, azrecord):
vals = [ar.nsdname for ar in azrecord.ns_records]
return {'values': [_check_endswith_dot(val) for val in vals]}
def _data_for_PTR(self, azrecord):
try:
ptrdname = azrecord.ptr_records[0].ptrdname
return {'value': _check_endswith_dot(ptrdname)}
except:
return {'value': '.'}
def _data_for_SRV(self, azrecord):
return {'values': [{'priority': ar.priority, 'weight': ar.weight,
'port': ar.port, 'target': ar.target}
for ar in azrecord.srv_records]}
def _data_for_TXT(self, azrecord):
return {'values': [reduce((lambda a, b: a + b), ar.value)
for ar in azrecord.txt_records]}
def _apply_Create(self, change):
'''A record from change must be created.
:param change: a change object
:type change: octodns.record.Change
:type return: void
'''
ar = _AzureRecord(self._resource_group, change.new)
create = self._dns_client.record_sets.create_or_update
create(resource_group_name=ar.resource_group,
zone_name=ar.zone_name,
relative_record_set_name=ar.relative_record_set_name,
record_type=ar.record_type,
parameters=ar.params)
self.log.debug('* Success Create/Update: {}'.format(ar))
_apply_Update = _apply_Create
def _apply_Delete(self, change):
ar = _AzureRecord(self._resource_group, change.existing, delete=True)
delete = self._dns_client.record_sets.delete
delete(self._resource_group, ar.zone_name, ar.relative_record_set_name,
ar.record_type)
self.log.debug('* Success Delete: {}'.format(ar))
def _apply(self, plan):
'''Required function of manager.py to actually apply a record change.
:param plan: Contains the zones and changes to be made
:type plan: octodns.provider.base.Plan
:type return: void
'''
desired = plan.desired
changes = plan.changes
self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
len(changes))
azure_zone_name = desired.name[:len(desired.name) - 1]
self._check_zone(azure_zone_name, create=True)
for change in changes:
class_name = change.__class__.__name__
getattr(self, '_apply_{}'.format(class_name))(change)

+ 12
- 12
octodns/provider/base.py View File

@ -56,19 +56,19 @@ class Plan(object):
delete_pcent = self.change_counts['Delete'] / existing_record_count delete_pcent = self.change_counts['Delete'] / existing_record_count
if update_pcent > self.MAX_SAFE_UPDATE_PCENT: if update_pcent > self.MAX_SAFE_UPDATE_PCENT:
raise UnsafePlan('Too many updates, %s is over %s percent'
'(%s/%s)',
update_pcent,
self.MAX_SAFE_UPDATE_PCENT * 100,
self.change_counts['Update'],
existing_record_count)
raise UnsafePlan('Too many updates, {} is over {} percent'
'({}/{})'.format(
update_pcent,
self.MAX_SAFE_UPDATE_PCENT * 100,
self.change_counts['Update'],
existing_record_count))
if delete_pcent > self.MAX_SAFE_DELETE_PCENT: if delete_pcent > self.MAX_SAFE_DELETE_PCENT:
raise UnsafePlan('Too many deletes, %s is over %s percent'
'(%s/%s)',
delete_pcent,
self.MAX_SAFE_DELETE_PCENT * 100,
self.change_counts['Delete'],
existing_record_count)
raise UnsafePlan('Too many deletes, {} is over {} percent'
'({}/{})'.format(
delete_pcent,
self.MAX_SAFE_DELETE_PCENT * 100,
self.change_counts['Delete'],
existing_record_count))
def __repr__(self): def __repr__(self):
return 'Creates={}, Updates={}, Deletes={}, Existing Records={}' \ return 'Creates={}, Updates={}, Deletes={}, Existing Records={}' \


+ 19
- 4
octodns/provider/ns1.py View File

@ -42,8 +42,16 @@ class Ns1Provider(BaseProvider):
} }
_data_for_AAAA = _data_for_A _data_for_AAAA = _data_for_A
_data_for_SPF = _data_for_A
_data_for_TXT = _data_for_A
def _data_for_SPF(self, _type, record):
values = [v.replace(';', '\;') for v in record['short_answers']]
return {
'ttl': record['ttl'],
'type': _type,
'values': values
}
_data_for_TXT = _data_for_SPF
def _data_for_CNAME(self, _type, record): def _data_for_CNAME(self, _type, record):
return { return {
@ -141,8 +149,15 @@ class Ns1Provider(BaseProvider):
_params_for_AAAA = _params_for_A _params_for_AAAA = _params_for_A
_params_for_NS = _params_for_A _params_for_NS = _params_for_A
_params_for_SPF = _params_for_A
_params_for_TXT = _params_for_A
def _params_for_SPF(self, record):
# NS1 seems to be the only provider that doesn't want things escaped in
# values so we have to strip them here and add them when going the
# other way
values = [v.replace('\;', ';') for v in record.values]
return {'answers': values, 'ttl': record.ttl}
_params_for_TXT = _params_for_SPF
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}


+ 5
- 3
octodns/provider/powerdns.py View File

@ -18,11 +18,13 @@ class PowerDnsBaseProvider(BaseProvider):
'SPF', 'SSHFP', 'SRV', 'TXT')) 'SPF', 'SSHFP', 'SRV', 'TXT'))
TIMEOUT = 5 TIMEOUT = 5
def __init__(self, id, host, api_key, port=8081, *args, **kwargs):
def __init__(self, id, host, api_key, port=8081, scheme="http", *args,
**kwargs):
super(PowerDnsBaseProvider, self).__init__(id, *args, **kwargs) super(PowerDnsBaseProvider, self).__init__(id, *args, **kwargs)
self.host = host self.host = host
self.port = port self.port = port
self.scheme = scheme
sess = Session() sess = Session()
sess.headers.update({'X-API-Key': api_key}) sess.headers.update({'X-API-Key': api_key})
@ -31,8 +33,8 @@ class PowerDnsBaseProvider(BaseProvider):
def _request(self, method, path, data=None): 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', method, path)
url = 'http://{}:{}/api/v1/servers/localhost/{}' \
.format(self.host, self.port, path)
url = '{}://{}:{}/api/v1/servers/localhost/{}' \
.format(self.scheme, self.host, self.port, path)
resp = self._sess.request(method, url, json=data, timeout=self.TIMEOUT) resp = self._sess.request(method, url, json=data, timeout=self.TIMEOUT)
self.log.debug('_request: status=%d', resp.status_code) self.log.debug('_request: status=%d', resp.status_code)
resp.raise_for_status() resp.raise_for_status()


+ 11
- 2
octodns/provider/route53.py View File

@ -6,6 +6,7 @@ from __future__ import absolute_import, division, print_function, \
unicode_literals unicode_literals
from boto3 import client from boto3 import client
from botocore.config import Config
from collections import defaultdict from collections import defaultdict
from incf.countryutils.transformations import cca_to_ctca2 from incf.countryutils.transformations import cca_to_ctca2
from uuid import uuid4 from uuid import uuid4
@ -229,14 +230,22 @@ class Route53Provider(BaseProvider):
HEALTH_CHECK_VERSION = '0000' HEALTH_CHECK_VERSION = '0000'
def __init__(self, id, access_key_id, secret_access_key, max_changes=1000, def __init__(self, id, access_key_id, secret_access_key, max_changes=1000,
*args, **kwargs):
client_max_attempts=None, *args, **kwargs):
self.max_changes = max_changes self.max_changes = max_changes
self.log = logging.getLogger('Route53Provider[{}]'.format(id)) self.log = logging.getLogger('Route53Provider[{}]'.format(id))
self.log.debug('__init__: id=%s, access_key_id=%s, ' self.log.debug('__init__: id=%s, access_key_id=%s, '
'secret_access_key=***', id, access_key_id) 'secret_access_key=***', id, access_key_id)
super(Route53Provider, self).__init__(id, *args, **kwargs) super(Route53Provider, self).__init__(id, *args, **kwargs)
config = None
if client_max_attempts is not None:
self.log.info('__init__: setting max_attempts to %d',
client_max_attempts)
config = Config(retries={'max_attempts': client_max_attempts})
self._conn = client('route53', aws_access_key_id=access_key_id, self._conn = client('route53', aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key)
aws_secret_access_key=secret_access_key,
config=config)
self._r53_zones = None self._r53_zones = None
self._r53_rrsets = {} self._r53_rrsets = {}


+ 7
- 3
octodns/yaml.py View File

@ -21,9 +21,13 @@ class SortEnforcingLoader(SafeLoader):
self.flatten_mapping(node) self.flatten_mapping(node)
ret = self.construct_pairs(node) ret = self.construct_pairs(node)
keys = [d[0] for d in ret] keys = [d[0] for d in ret]
if keys != sorted(keys, key=_natsort_key):
raise ConstructorError(None, None, "keys out of order: {}"
.format(', '.join(keys)), node.start_mark)
keys_sorted = sorted(keys, key=_natsort_key)
for key in keys:
expected = keys_sorted.pop(0)
if key != expected:
raise ConstructorError(None, None, 'keys out of order: '
'expected {} got {} at {}'
.format(expected, key, node.start_mark))
return dict(ret) return dict(ret)


+ 32
- 17
octodns/zone.py View File

@ -5,6 +5,7 @@
from __future__ import absolute_import, division, print_function, \ from __future__ import absolute_import, division, print_function, \
unicode_literals unicode_literals
from collections import defaultdict
from logging import getLogger from logging import getLogger
import re import re
@ -39,13 +40,19 @@ class Zone(object):
# Force everyting to lowercase just to be safe # Force everyting to lowercase just to be safe
self.name = str(name).lower() if name else name self.name = str(name).lower() if name else name
self.sub_zones = sub_zones self.sub_zones = sub_zones
self.records = set()
# We're grouping by node, it allows us to efficently search for
# duplicates and detect when CNAMEs co-exist with other records
self._records = defaultdict(set)
# optional leading . to match empty hostname # optional leading . to match empty hostname
# optional trailing . b/c some sources don't have it on their fqdn # optional trailing . b/c some sources don't have it on their fqdn
self._name_re = re.compile('\.?{}?$'.format(name)) self._name_re = re.compile('\.?{}?$'.format(name))
self.log.debug('__init__: zone=%s, sub_zones=%s', self, sub_zones) self.log.debug('__init__: zone=%s, sub_zones=%s', self, sub_zones)
@property
def records(self):
return set([r for _, node in self._records.items() for r in node])
def hostname_from_fqdn(self, fqdn): def hostname_from_fqdn(self, fqdn):
return self._name_re.sub('', fqdn) return self._name_re.sub('', fqdn)
@ -53,9 +60,6 @@ class Zone(object):
name = record.name name = record.name
last = name.split('.')[-1] last = name.split('.')[-1]
if replace and record in self.records:
self.records.remove(record)
if last in self.sub_zones: if last in self.sub_zones:
if name != last: if name != last:
# it's a record for something under a sub-zone # it's a record for something under a sub-zone
@ -67,19 +71,30 @@ class Zone(object):
raise SubzoneRecordException('Record {} a managed sub-zone ' raise SubzoneRecordException('Record {} a managed sub-zone '
'and not of type NS' 'and not of type NS'
.format(record.fqdn)) .format(record.fqdn))
# TODO: this is pretty inefficent
for existing in self.records:
if record == existing:
raise DuplicateRecordException('Duplicate record {}, type {}'
.format(record.fqdn,
record._type))
elif name == existing.name and (record._type == 'CNAME' or
existing._type == 'CNAME'):
raise InvalidNodeException('Invalid state, CNAME at {} '
'cannot coexist with other records'
.format(record.fqdn))
self.records.add(record)
if replace:
# will remove it if it exists
self._records[name].discard(record)
node = self._records[name]
if record in node:
# We already have a record at this node of this type
raise DuplicateRecordException('Duplicate record {}, type {}'
.format(record.fqdn,
record._type))
elif ((record._type == 'CNAME' and len(node) > 0) or
('CNAME' in map(lambda r: r._type, node))):
# We're adding a CNAME to existing records or adding to an existing
# CNAME
raise InvalidNodeException('Invalid state, CNAME at {} cannot '
'coexist with other records'
.format(record.fqdn))
node.add(record)
def _remove_record(self, record):
'Only for use in tests'
self._records[record.name].discard(record)
def changes(self, desired, target): def changes(self, desired, target):
self.log.debug('changes: zone=%s, target=%s', self, target) self.log.debug('changes: zone=%s, target=%s', self, target)


+ 9
- 6
requirements.txt View File

@ -1,18 +1,21 @@
# These are known good versions. You're free to use others and things will # These are known good versions. You're free to use others and things will
# likely work, but no promises are made, especilly if you go older. # likely work, but no promises are made, especilly if you go older.
PyYaml==3.12 PyYaml==3.12
boto3==1.4.4
botocore==1.5.4
azure-mgmt-dns==1.0.1
azure-common==1.1.6
boto3==1.4.6
botocore==1.6.0
dnspython==1.15.0 dnspython==1.15.0
docutils==0.13.1
docutils==0.14
dyn==1.7.10 dyn==1.7.10
futures==3.0.5
futures==3.1.1
incf.countryutils==1.0 incf.countryutils==1.0
ipaddress==1.0.18 ipaddress==1.0.18
jmespath==0.9.0
jmespath==0.9.3
msrestazure==0.4.10
natsort==5.0.3 natsort==5.0.3
nsone==0.9.14 nsone==0.9.14
python-dateutil==2.6.0
python-dateutil==2.6.1
requests==2.13.0 requests==2.13.0
s3transfer==0.1.10 s3transfer==0.1.10
six==1.10.0 six==1.10.0

+ 379
- 0
tests/test_octodns_provider_azuredns.py View File

@ -0,0 +1,379 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from octodns.record import Create, Delete, Record
from octodns.provider.azuredns import _AzureRecord, AzureProvider, \
_check_endswith_dot, _parse_azure_type
from octodns.zone import Zone
from octodns.provider.base import Plan
from azure.mgmt.dns.models import ARecord, AaaaRecord, CnameRecord, MxRecord, \
SrvRecord, NsRecord, PtrRecord, TxtRecord, RecordSet, SoaRecord, \
Zone as AzureZone
from msrestazure.azure_exceptions import CloudError
from unittest import TestCase
from mock import Mock, patch
zone = Zone(name='unit.tests.', sub_zones=[])
octo_records = []
octo_records.append(Record.new(zone, '', {
'ttl': 0,
'type': 'A',
'values': ['1.2.3.4', '10.10.10.10']}))
octo_records.append(Record.new(zone, 'a', {
'ttl': 1,
'type': 'A',
'values': ['1.2.3.4', '1.1.1.1']}))
octo_records.append(Record.new(zone, 'aa', {
'ttl': 9001,
'type': 'A',
'values': ['1.2.4.3']}))
octo_records.append(Record.new(zone, 'aaa', {
'ttl': 2,
'type': 'A',
'values': ['1.1.1.3']}))
octo_records.append(Record.new(zone, 'cname', {
'ttl': 3,
'type': 'CNAME',
'value': 'a.unit.tests.'}))
octo_records.append(Record.new(zone, 'mx1', {
'ttl': 3,
'type': 'MX',
'values': [{
'priority': 10,
'value': 'mx1.unit.tests.',
}, {
'priority': 20,
'value': 'mx2.unit.tests.',
}]}))
octo_records.append(Record.new(zone, 'mx2', {
'ttl': 3,
'type': 'MX',
'values': [{
'priority': 10,
'value': 'mx1.unit.tests.',
}]}))
octo_records.append(Record.new(zone, '', {
'ttl': 4,
'type': 'NS',
'values': ['ns1.unit.tests.', 'ns2.unit.tests.']}))
octo_records.append(Record.new(zone, 'foo', {
'ttl': 5,
'type': 'NS',
'value': 'ns1.unit.tests.'}))
octo_records.append(Record.new(zone, '_srv._tcp', {
'ttl': 6,
'type': 'SRV',
'values': [{
'priority': 10,
'weight': 20,
'port': 30,
'target': 'foo-1.unit.tests.',
}, {
'priority': 12,
'weight': 30,
'port': 30,
'target': 'foo-2.unit.tests.',
}]}))
octo_records.append(Record.new(zone, '_srv2._tcp', {
'ttl': 7,
'type': 'SRV',
'values': [{
'priority': 12,
'weight': 17,
'port': 1,
'target': 'srvfoo.unit.tests.',
}]}))
octo_records.append(Record.new(zone, 'txt1', {
'ttl': 8,
'type': 'TXT',
'value': 'txt singleton test'}))
octo_records.append(Record.new(zone, 'txt2', {
'ttl': 9,
'type': 'TXT',
'values': ['txt multiple test', 'txt multiple test 2']}))
azure_records = []
_base0 = _AzureRecord('TestAzure', octo_records[0])
_base0.zone_name = 'unit.tests'
_base0.relative_record_set_name = '@'
_base0.record_type = 'A'
_base0.params['ttl'] = 0
_base0.params['arecords'] = [ARecord('1.2.3.4'), ARecord('10.10.10.10')]
azure_records.append(_base0)
_base1 = _AzureRecord('TestAzure', octo_records[1])
_base1.zone_name = 'unit.tests'
_base1.relative_record_set_name = 'a'
_base1.record_type = 'A'
_base1.params['ttl'] = 1
_base1.params['arecords'] = [ARecord('1.2.3.4'), ARecord('1.1.1.1')]
azure_records.append(_base1)
_base2 = _AzureRecord('TestAzure', octo_records[2])
_base2.zone_name = 'unit.tests'
_base2.relative_record_set_name = 'aa'
_base2.record_type = 'A'
_base2.params['ttl'] = 9001
_base2.params['arecords'] = ARecord('1.2.4.3')
azure_records.append(_base2)
_base3 = _AzureRecord('TestAzure', octo_records[3])
_base3.zone_name = 'unit.tests'
_base3.relative_record_set_name = 'aaa'
_base3.record_type = 'A'
_base3.params['ttl'] = 2
_base3.params['arecords'] = ARecord('1.1.1.3')
azure_records.append(_base3)
_base4 = _AzureRecord('TestAzure', octo_records[4])
_base4.zone_name = 'unit.tests'
_base4.relative_record_set_name = 'cname'
_base4.record_type = 'CNAME'
_base4.params['ttl'] = 3
_base4.params['cname_record'] = CnameRecord('a.unit.tests.')
azure_records.append(_base4)
_base5 = _AzureRecord('TestAzure', octo_records[5])
_base5.zone_name = 'unit.tests'
_base5.relative_record_set_name = 'mx1'
_base5.record_type = 'MX'
_base5.params['ttl'] = 3
_base5.params['mx_records'] = [MxRecord(10, 'mx1.unit.tests.'),
MxRecord(20, 'mx2.unit.tests.')]
azure_records.append(_base5)
_base6 = _AzureRecord('TestAzure', octo_records[6])
_base6.zone_name = 'unit.tests'
_base6.relative_record_set_name = 'mx2'
_base6.record_type = 'MX'
_base6.params['ttl'] = 3
_base6.params['mx_records'] = [MxRecord(10, 'mx1.unit.tests.')]
azure_records.append(_base6)
_base7 = _AzureRecord('TestAzure', octo_records[7])
_base7.zone_name = 'unit.tests'
_base7.relative_record_set_name = '@'
_base7.record_type = 'NS'
_base7.params['ttl'] = 4
_base7.params['ns_records'] = [NsRecord('ns1.unit.tests.'),
NsRecord('ns2.unit.tests.')]
azure_records.append(_base7)
_base8 = _AzureRecord('TestAzure', octo_records[8])
_base8.zone_name = 'unit.tests'
_base8.relative_record_set_name = 'foo'
_base8.record_type = 'NS'
_base8.params['ttl'] = 5
_base8.params['ns_records'] = [NsRecord('ns1.unit.tests.')]
azure_records.append(_base8)
_base9 = _AzureRecord('TestAzure', octo_records[9])
_base9.zone_name = 'unit.tests'
_base9.relative_record_set_name = '_srv._tcp'
_base9.record_type = 'SRV'
_base9.params['ttl'] = 6
_base9.params['srv_records'] = [SrvRecord(10, 20, 30, 'foo-1.unit.tests.'),
SrvRecord(12, 30, 30, 'foo-2.unit.tests.')]
azure_records.append(_base9)
_base10 = _AzureRecord('TestAzure', octo_records[10])
_base10.zone_name = 'unit.tests'
_base10.relative_record_set_name = '_srv2._tcp'
_base10.record_type = 'SRV'
_base10.params['ttl'] = 7
_base10.params['srv_records'] = [SrvRecord(12, 17, 1, 'srvfoo.unit.tests.')]
azure_records.append(_base10)
_base11 = _AzureRecord('TestAzure', octo_records[11])
_base11.zone_name = 'unit.tests'
_base11.relative_record_set_name = 'txt1'
_base11.record_type = 'TXT'
_base11.params['ttl'] = 8
_base11.params['txt_records'] = [TxtRecord(['txt singleton test'])]
azure_records.append(_base11)
_base12 = _AzureRecord('TestAzure', octo_records[12])
_base12.zone_name = 'unit.tests'
_base12.relative_record_set_name = 'txt2'
_base12.record_type = 'TXT'
_base12.params['ttl'] = 9
_base12.params['txt_records'] = [TxtRecord(['txt multiple test']),
TxtRecord(['txt multiple test 2'])]
azure_records.append(_base12)
class Test_AzureRecord(TestCase):
def test_azure_record(self):
assert(len(azure_records) == len(octo_records))
for i in range(len(azure_records)):
octo = _AzureRecord('TestAzure', octo_records[i])
assert(azure_records[i]._equals(octo))
class Test_ParseAzureType(TestCase):
def test_parse_azure_type(self):
for expected, test in [['A', 'Microsoft.Network/dnszones/A'],
['AAAA', 'Microsoft.Network/dnszones/AAAA'],
['NS', 'Microsoft.Network/dnszones/NS'],
['MX', 'Microsoft.Network/dnszones/MX']]:
self.assertEquals(expected, _parse_azure_type(test))
class Test_CheckEndswithDot(TestCase):
def test_check_endswith_dot(self):
for expected, test in [['a.', 'a'],
['a.', 'a.'],
['foo.bar.', 'foo.bar.'],
['foo.bar.', 'foo.bar']]:
self.assertEquals(expected, _check_endswith_dot(test))
class TestAzureDnsProvider(TestCase):
def _provider(self):
return self._get_provider('mock_spc', 'mock_dns_client')
@patch('octodns.provider.azuredns.DnsManagementClient')
@patch('octodns.provider.azuredns.ServicePrincipalCredentials')
def _get_provider(self, mock_spc, mock_dns_client):
'''Returns a mock AzureProvider object to use in testing.
:param mock_spc: placeholder
:type mock_spc: str
:param mock_dns_client: placeholder
:type mock_dns_client: str
:type return: AzureProvider
'''
return AzureProvider('mock_id', 'mock_client', 'mock_key',
'mock_directory', 'mock_sub', 'mock_rg')
def test_populate_records(self):
provider = self._get_provider()
rs = []
rs.append(RecordSet(name='a1', ttl=0, type='A',
arecords=[ARecord('1.1.1.1')]))
rs.append(RecordSet(name='a2', ttl=1, type='A',
arecords=[ARecord('1.1.1.1'),
ARecord('2.2.2.2')]))
rs.append(RecordSet(name='aaaa1', ttl=2, type='AAAA',
aaaa_records=[AaaaRecord('1:1ec:1::1')]))
rs.append(RecordSet(name='aaaa2', ttl=3, type='AAAA',
aaaa_records=[AaaaRecord('1:1ec:1::1'),
AaaaRecord('1:1ec:1::2')]))
rs.append(RecordSet(name='cname1', ttl=4, type='CNAME',
cname_record=CnameRecord('cname.unit.test.')))
rs.append(RecordSet(name='cname2', ttl=5, type='CNAME',
cname_record=None))
rs.append(RecordSet(name='mx1', ttl=6, type='MX',
mx_records=[MxRecord(10, 'mx1.unit.test.')]))
rs.append(RecordSet(name='mx2', ttl=7, type='MX',
mx_records=[MxRecord(10, 'mx1.unit.test.'),
MxRecord(11, 'mx2.unit.test.')]))
rs.append(RecordSet(name='ns1', ttl=8, type='NS',
ns_records=[NsRecord('ns1.unit.test.')]))
rs.append(RecordSet(name='ns2', ttl=9, type='NS',
ns_records=[NsRecord('ns1.unit.test.'),
NsRecord('ns2.unit.test.')]))
rs.append(RecordSet(name='ptr1', ttl=10, type='PTR',
ptr_records=[PtrRecord('ptr1.unit.test.')]))
rs.append(RecordSet(name='ptr2', ttl=11, type='PTR',
ptr_records=[PtrRecord(None)]))
rs.append(RecordSet(name='_srv1._tcp', ttl=12, type='SRV',
srv_records=[SrvRecord(1, 2, 3, '1unit.tests.')]))
rs.append(RecordSet(name='_srv2._tcp', ttl=13, type='SRV',
srv_records=[SrvRecord(1, 2, 3, '1unit.tests.'),
SrvRecord(4, 5, 6, '2unit.tests.')]))
rs.append(RecordSet(name='txt1', ttl=14, type='TXT',
txt_records=[TxtRecord('sample text1')]))
rs.append(RecordSet(name='txt2', ttl=15, type='TXT',
txt_records=[TxtRecord('sample text1'),
TxtRecord('sample text2')]))
rs.append(RecordSet(name='', ttl=16, type='SOA',
soa_record=[SoaRecord()]))
record_list = provider._dns_client.record_sets.list_by_dns_zone
record_list.return_value = rs
provider.populate(zone)
self.assertEquals(len(zone.records), 16)
def test_populate_zone(self):
provider = self._get_provider()
zone_list = provider._dns_client.zones.list_by_resource_group
zone_list.return_value = [AzureZone(location='global'),
AzureZone(location='global')]
provider._populate_zones()
self.assertEquals(len(provider._azure_zones), 1)
def test_bad_zone_response(self):
provider = self._get_provider()
_get = provider._dns_client.zones.get
_get.side_effect = CloudError(Mock(status=404), 'Azure Error')
trip = False
try:
provider._check_zone('unit.test', create=False)
except CloudError:
trip = True
self.assertEquals(trip, True)
def test_apply(self):
provider = self._get_provider()
changes = []
deletes = []
for i in octo_records:
changes.append(Create(i))
deletes.append(Delete(i))
self.assertEquals(13, provider.apply(Plan(None, zone, changes)))
self.assertEquals(13, provider.apply(Plan(zone, zone, deletes)))
def test_create_zone(self):
provider = self._get_provider()
changes = []
for i in octo_records:
changes.append(Create(i))
desired = Zone('unit2.test.', [])
err_msg = 'The Resource \'Microsoft.Network/dnszones/unit2.test\' '
err_msg += 'under resource group \'mock_rg\' was not found.'
_get = provider._dns_client.zones.get
_get.side_effect = CloudError(Mock(status=404), err_msg)
self.assertEquals(13, provider.apply(Plan(None, desired, changes)))
def test_check_zone_no_create(self):
provider = self._get_provider()
rs = []
rs.append(RecordSet(name='a1', ttl=0, type='A',
arecords=[ARecord('1.1.1.1')]))
rs.append(RecordSet(name='a2', ttl=1, type='A',
arecords=[ARecord('1.1.1.1'),
ARecord('2.2.2.2')]))
record_list = provider._dns_client.record_sets.list_by_dns_zone
record_list.return_value = rs
err_msg = 'The Resource \'Microsoft.Network/dnszones/unit3.test\' '
err_msg += 'under resource group \'mock_rg\' was not found.'
_get = provider._dns_client.zones.get
_get.side_effect = CloudError(Mock(status=404), err_msg)
provider.populate(Zone('unit3.test.', []))
self.assertEquals(len(zone.records), 0)

+ 6
- 2
tests/test_octodns_provider_base.py View File

@ -214,9 +214,11 @@ class TestBaseProvider(TestCase):
for i in range(int(Plan.MIN_EXISTING_RECORDS * for i in range(int(Plan.MIN_EXISTING_RECORDS *
Plan.MAX_SAFE_UPDATE_PCENT) + 1)] Plan.MAX_SAFE_UPDATE_PCENT) + 1)]
with self.assertRaises(UnsafePlan):
with self.assertRaises(UnsafePlan) as ctx:
Plan(zone, zone, changes).raise_if_unsafe() Plan(zone, zone, changes).raise_if_unsafe()
self.assertTrue('Too many updates' in ctx.exception.message)
def test_safe_updates_min_existing_pcent(self): def test_safe_updates_min_existing_pcent(self):
# MAX_SAFE_UPDATE_PCENT is safe when more # MAX_SAFE_UPDATE_PCENT is safe when more
# than MIN_EXISTING_RECORDS exist # than MIN_EXISTING_RECORDS exist
@ -260,9 +262,11 @@ class TestBaseProvider(TestCase):
for i in range(int(Plan.MIN_EXISTING_RECORDS * for i in range(int(Plan.MIN_EXISTING_RECORDS *
Plan.MAX_SAFE_DELETE_PCENT) + 1)] Plan.MAX_SAFE_DELETE_PCENT) + 1)]
with self.assertRaises(UnsafePlan):
with self.assertRaises(UnsafePlan) as ctx:
Plan(zone, zone, changes).raise_if_unsafe() Plan(zone, zone, changes).raise_if_unsafe()
self.assertTrue('Too many deletes' in ctx.exception.message)
def test_safe_deletes_min_existing_pcent(self): def test_safe_deletes_min_existing_pcent(self):
# MAX_SAFE_DELETE_PCENT is safe when more # MAX_SAFE_DELETE_PCENT is safe when more
# than MIN_EXISTING_RECORDS exist # than MIN_EXISTING_RECORDS exist


+ 1
- 1
tests/test_octodns_provider_cloudflare.py View File

@ -33,7 +33,7 @@ class TestCloudflareProvider(TestCase):
})) }))
for record in list(expected.records): for record in list(expected.records):
if record.name == 'sub' and record._type == 'NS': if record.name == 'sub' and record._type == 'NS':
expected.records.remove(record)
expected._remove_record(record)
break break
empty = {'result': [], 'result_info': {'count': 0, 'per_page': 0}} empty = {'result': [], 'result_info': {'count': 0, 'per_page': 0}}


+ 1
- 1
tests/test_octodns_provider_dnsimple.py View File

@ -33,7 +33,7 @@ class TestDnsimpleProvider(TestCase):
})) }))
for record in list(expected.records): for record in list(expected.records):
if record.name == 'sub' and record._type == 'NS': if record.name == 'sub' and record._type == 'NS':
expected.records.remove(record)
expected._remove_record(record)
break break
def test_populate(self): def test_populate(self):


+ 36
- 1
tests/test_octodns_provider_ns1.py View File

@ -196,7 +196,8 @@ class TestNs1Provider(TestCase):
provider = Ns1Provider('test', 'api-key') provider = Ns1Provider('test', 'api-key')
desired = Zone('unit.tests.', []) desired = Zone('unit.tests.', [])
desired.records.update(self.expected)
for r in self.expected:
desired.add_record(r)
plan = provider.plan(desired) plan = provider.plan(desired)
# everything except the root NS # everything except the root NS
@ -277,3 +278,37 @@ class TestNs1Provider(TestCase):
call.update(answers=[u'1.2.3.4'], ttl=32), call.update(answers=[u'1.2.3.4'], ttl=32),
call.delete() call.delete()
]) ])
def test_escaping(self):
provider = Ns1Provider('test', 'api-key')
record = {
'ttl': 31,
'short_answers': ['foo; bar baz; blip']
}
self.assertEquals(['foo\; bar baz\; blip'],
provider._data_for_SPF('SPF', record)['values'])
record = {
'ttl': 31,
'short_answers': ['no', 'foo; bar baz; blip', 'yes']
}
self.assertEquals(['no', 'foo\; bar baz\; blip', 'yes'],
provider._data_for_TXT('TXT', record)['values'])
zone = Zone('unit.tests.', [])
record = Record.new(zone, 'spf', {
'ttl': 34,
'type': 'SPF',
'value': 'foo\; bar baz\; blip'
})
self.assertEquals(['foo; bar baz; blip'],
provider._params_for_SPF(record)['answers'])
record = Record.new(zone, 'txt', {
'ttl': 35,
'type': 'TXT',
'value': 'foo\; bar baz\; blip'
})
self.assertEquals(['foo; bar baz; blip'],
provider._params_for_TXT(record)['answers'])

+ 1
- 1
tests/test_octodns_provider_powerdns.py View File

@ -253,7 +253,7 @@ class TestPowerDnsProvider(TestCase):
plan = provider.plan(expected) plan = provider.plan(expected)
self.assertFalse(plan) self.assertFalse(plan)
# remove it now that we don't need the unrelated change any longer # remove it now that we don't need the unrelated change any longer
expected.records.remove(unrelated_record)
expected._remove_record(unrelated_record)
# ttl diff # ttl diff
with requests_mock() as mock: with requests_mock() as mock:


+ 14
- 6
tests/test_octodns_provider_route53.py View File

@ -372,11 +372,11 @@ class TestRoute53Provider(TestCase):
# Delete by monkey patching in a populate that includes an extra record # Delete by monkey patching in a populate that includes an extra record
def add_extra_populate(existing, target, lenient): def add_extra_populate(existing, target, lenient):
for record in self.expected.records: for record in self.expected.records:
existing.records.add(record)
existing.add_record(record)
record = Record.new(existing, 'extra', record = Record.new(existing, 'extra',
{'ttl': 99, 'type': 'A', {'ttl': 99, 'type': 'A',
'values': ['9.9.9.9']}) 'values': ['9.9.9.9']})
existing.records.add(record)
existing.add_record(record)
provider.populate = add_extra_populate provider.populate = add_extra_populate
change_resource_record_sets_params = { change_resource_record_sets_params = {
@ -409,7 +409,7 @@ class TestRoute53Provider(TestCase):
def mod_geo_populate(existing, target, lenient): def mod_geo_populate(existing, target, lenient):
for record in self.expected.records: for record in self.expected.records:
if record._type != 'A' or not record.geo: if record._type != 'A' or not record.geo:
existing.records.add(record)
existing.add_record(record)
record = Record.new(existing, '', { record = Record.new(existing, '', {
'ttl': 61, 'ttl': 61,
'type': 'A', 'type': 'A',
@ -420,7 +420,7 @@ class TestRoute53Provider(TestCase):
'NA-US-KY': ['7.2.3.4'] 'NA-US-KY': ['7.2.3.4']
} }
}) })
existing.records.add(record)
existing.add_record(record)
provider.populate = mod_geo_populate provider.populate = mod_geo_populate
change_resource_record_sets_params = { change_resource_record_sets_params = {
@ -505,7 +505,7 @@ class TestRoute53Provider(TestCase):
def mod_add_geo_populate(existing, target, lenient): def mod_add_geo_populate(existing, target, lenient):
for record in self.expected.records: for record in self.expected.records:
if record._type != 'A' or record.geo: if record._type != 'A' or record.geo:
existing.records.add(record)
existing.add_record(record)
record = Record.new(existing, 'simple', { record = Record.new(existing, 'simple', {
'ttl': 61, 'ttl': 61,
'type': 'A', 'type': 'A',
@ -514,7 +514,7 @@ class TestRoute53Provider(TestCase):
'OC': ['3.2.3.4', '4.2.3.4'], 'OC': ['3.2.3.4', '4.2.3.4'],
} }
}) })
existing.records.add(record)
existing.add_record(record)
provider.populate = mod_add_geo_populate provider.populate = mod_add_geo_populate
change_resource_record_sets_params = { change_resource_record_sets_params = {
@ -1232,6 +1232,14 @@ class TestRoute53Provider(TestCase):
'Type': 'TXT', 'Type': 'TXT',
})) }))
def test_client_max_attempts(self):
provider = Route53Provider('test', 'abc', '123',
client_max_attempts=42)
# NOTE: this will break if boto ever changes the impl details...
self.assertEquals(43, provider._conn.meta.events
._unique_id_handlers['retry-config-route53']
['handler']._checker.__dict__['_max_attempts'])
class TestRoute53Records(TestCase): class TestRoute53Records(TestCase):


+ 2
- 2
tests/test_octodns_yaml.py View File

@ -48,8 +48,8 @@ class TestYaml(TestCase):
'*.11.2': 'd' '*.11.2': 'd'
'*.10.1': 'c' '*.10.1': 'c'
''') ''')
self.assertEquals('keys out of order: *.2.2, *.1.2, *.11.2, *.10.1',
ctx.exception.problem)
self.assertTrue('keys out of order: expected *.1.2 got *.2.2 at' in
ctx.exception.problem)
buf = StringIO() buf = StringIO()
safe_dump({ safe_dump({


+ 1
- 1
tests/test_octodns_zone.py View File

@ -77,7 +77,7 @@ class TestZone(TestCase):
# add a record, delete a record -> [Delete, Create] # add a record, delete a record -> [Delete, Create]
c = ARecord(before, 'c', {'ttl': 42, 'value': '1.1.1.1'}) c = ARecord(before, 'c', {'ttl': 42, 'value': '1.1.1.1'})
after.add_record(c) after.add_record(c)
after.records.remove(b)
after._remove_record(b)
self.assertEquals(after.records, set([a, c])) self.assertEquals(after.records, set([a, c]))
changes = before.changes(after, target) changes = before.changes(after, target)
self.assertEquals(2, len(changes)) self.assertEquals(2, len(changes))


Loading…
Cancel
Save