Browse Source

Merge branch 'master' into powerdns-4.3.x-support

* master: (21 commits)
  Add Canadian provinces to geo_data.py
  Fix comment < 80 chars
  Add support for geo-targeting of CA provinces - For providers that support such
  Update geo_data to pick up a couple renames
  Ignore E741, flags single-letter var names in comprehensions which I want to allow
  Bump pycodestyle from 2.5.0 to 2.6.0
  Bump boto3 from 1.13.0 to 1.13.19
  Bump botocore from 1.16.0 to 1.16.19
  Bump six from 1.14.0 to 1.15.0
  Bump ns1-python from 0.15.0 to 0.16.0
  Bump setuptools from 44.1.0 to 44.1.1
  Cloudflare: Rename _try to _try_request
  Cloudflare: Add Support for Rate Limit
  Cloudflare: Add Support for PTR Records
  Update NS1 _REGION_FILTER to include remove_no_georegion in config
  Fix code coverage for NS1
  Docs and changelog for TCP health check support
  Fix Dyn python3 error with dict_values that needed a list
  TCP healthcheck support for Route53
  NS1 support for TCP healthchecks
  ...
pull/564/head
Maikel Poot 6 years ago
parent
commit
5b87649295
22 changed files with 362 additions and 64 deletions
  1. +4
    -0
      CHANGELOG.md
  2. +3
    -1
      README.md
  3. +1
    -1
      docs/dynamic_records.md
  4. +46
    -12
      octodns/provider/cloudflare.py
  5. +1
    -1
      octodns/provider/dyn.py
  6. +19
    -12
      octodns/provider/ns1.py
  7. +8
    -4
      octodns/provider/route53.py
  8. +9
    -3
      octodns/record/__init__.py
  9. +9
    -3
      octodns/record/geo.py
  10. +16
    -3
      octodns/record/geo_data.py
  11. +1
    -1
      requirements-dev.txt
  12. +5
    -5
      requirements.txt
  13. +6
    -0
      script/coverage
  14. +2
    -2
      script/generate-geo-data
  15. +1
    -1
      script/lint
  16. +1
    -1
      tests/fixtures/cloudflare-dns_records-page-1.json
  17. +19
    -2
      tests/fixtures/cloudflare-dns_records-page-2.json
  18. +129
    -12
      tests/test_octodns_provider_cloudflare.py
  19. +17
    -0
      tests/test_octodns_provider_ns1.py
  20. +29
    -0
      tests/test_octodns_provider_route53.py
  21. +34
    -0
      tests/test_octodns_record.py
  22. +2
    -0
      tests/test_octodns_record_geo.py

+ 4
- 0
CHANGELOG.md View File

@ -1,3 +1,7 @@
## v0.9.11 - 2020-??-?? - ???????????????
* Added support for TCP health checking to dynamic records
## v0.9.10 - 2020-04-20 - Dynamic NS1 and lots of misc ## v0.9.10 - 2020-04-20 - Dynamic NS1 and lots of misc
* Added support for dynamic records to Ns1Provider, updated client and rate * Added support for dynamic records to Ns1Provider, updated client and rate


+ 3
- 1
README.md View File

@ -11,6 +11,8 @@ It is similar to [Netflix/denominator](https://github.com/Netflix/denominator).
## Table of Contents ## Table of Contents
- [DNS as code - Tools for managing DNS across multiple providers](#dns-as-code---tools-for-managing-dns-across-multiple-providers)
- [Table of Contents](#table-of-contents)
- [Getting started](#getting-started) - [Getting started](#getting-started)
- [Workspace](#workspace) - [Workspace](#workspace)
- [Config](#config) - [Config](#config)
@ -178,7 +180,7 @@ The above command pulled the existing data out of Route53 and placed the results
|--|--|--|--|--| |--|--|--|--|--|
| [AzureProvider](/octodns/provider/azuredns.py) | azure-mgmt-dns | A, AAAA, CAA, CNAME, MX, NS, PTR, SRV, TXT | No | | | [AzureProvider](/octodns/provider/azuredns.py) | azure-mgmt-dns | A, AAAA, CAA, CNAME, MX, NS, PTR, SRV, TXT | No | |
| [Akamai](/octodns/provider/edgedns.py) | edgegrid-python | A, AAAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT | No | | | [Akamai](/octodns/provider/edgedns.py) | edgegrid-python | A, AAAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT | No | |
| [CloudflareProvider](/octodns/provider/cloudflare.py) | | A, AAAA, ALIAS, CAA, CNAME, MX, NS, SPF, SRV, TXT | No | CAA tags restricted |
| [CloudflareProvider](/octodns/provider/cloudflare.py) | | A, AAAA, ALIAS, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted |
| [ConstellixProvider](/octodns/provider/constellix.py) | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted | | [ConstellixProvider](/octodns/provider/constellix.py) | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted |
| [DigitalOceanProvider](/octodns/provider/digitalocean.py) | | A, AAAA, CAA, CNAME, MX, NS, TXT, SRV | No | CAA tags restricted | | [DigitalOceanProvider](/octodns/provider/digitalocean.py) | | A, AAAA, CAA, CNAME, MX, NS, TXT, SRV | No | CAA tags restricted |
| [DnsMadeEasyProvider](/octodns/provider/dnsmadeeasy.py) | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted | | [DnsMadeEasyProvider](/octodns/provider/dnsmadeeasy.py) | | A, AAAA, ALIAS (ANAME), CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | CAA tags restricted |


+ 1
- 1
docs/dynamic_records.md View File

@ -103,7 +103,7 @@ test:
| host | FQDN for host header and SNI | - | | host | FQDN for host header and SNI | - |
| path | path to check | _dns | | path | path to check | _dns |
| port | port to check | 443 | | port | port to check | 443 |
| protocol | HTTP/HTTPS | HTTPS |
| protocol | HTTP/HTTPS/TCP | HTTPS |
#### Route53 Healtch Check Options #### Route53 Healtch Check Options


+ 46
- 12
octodns/provider/cloudflare.py View File

@ -9,6 +9,7 @@ from collections import defaultdict
from copy import deepcopy from copy import deepcopy
from logging import getLogger from logging import getLogger
from requests import Session from requests import Session
from time import sleep
from ..record import Record, Update from ..record import Record, Update
from .base import BaseProvider from .base import BaseProvider
@ -18,7 +19,7 @@ class CloudflareError(Exception):
def __init__(self, data): def __init__(self, data):
try: try:
message = data['errors'][0]['message'] message = data['errors'][0]['message']
except (IndexError, KeyError):
except (IndexError, KeyError, TypeError):
message = 'Cloudflare error' message = 'Cloudflare error'
super(CloudflareError, self).__init__(message) super(CloudflareError, self).__init__(message)
@ -28,6 +29,11 @@ class CloudflareAuthenticationError(CloudflareError):
CloudflareError.__init__(self, data) CloudflareError.__init__(self, data)
class CloudflareRateLimitError(CloudflareError):
def __init__(self, data):
CloudflareError.__init__(self, data)
_PROXIABLE_RECORD_TYPES = {'A', 'AAAA', 'ALIAS', 'CNAME'} _PROXIABLE_RECORD_TYPES = {'A', 'AAAA', 'ALIAS', 'CNAME'}
@ -47,6 +53,11 @@ class CloudflareProvider(BaseProvider):
# #
# See: https://support.cloudflare.com/hc/en-us/articles/115000830351 # See: https://support.cloudflare.com/hc/en-us/articles/115000830351
cdn: false cdn: false
# Optional. Default: 4. Number of times to retry if a 429 response
# is received.
retry_count: 4
# Optional. Default: 300. Number of seconds to wait before retrying.
retry_period: 300
Note: The "proxied" flag of "A", "AAAA" and "CNAME" records can be managed Note: The "proxied" flag of "A", "AAAA" and "CNAME" records can be managed
via the YAML provider like so: via the YAML provider like so:
@ -60,13 +71,14 @@ class CloudflareProvider(BaseProvider):
''' '''
SUPPORTS_GEO = False SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False SUPPORTS_DYNAMIC = False
SUPPORTS = set(('ALIAS', 'A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SRV',
'SPF', 'TXT'))
SUPPORTS = set(('ALIAS', 'A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'PTR',
'SRV', 'SPF', 'TXT'))
MIN_TTL = 120 MIN_TTL = 120
TIMEOUT = 15 TIMEOUT = 15
def __init__(self, id, email=None, token=None, cdn=False, *args, **kwargs):
def __init__(self, id, email=None, token=None, cdn=False, retry_count=4,
retry_period=300, *args, **kwargs):
self.log = getLogger('CloudflareProvider[{}]'.format(id)) self.log = getLogger('CloudflareProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, email=%s, token=***, cdn=%s', id, self.log.debug('__init__: id=%s, email=%s, token=***, cdn=%s', id,
email, cdn) email, cdn)
@ -85,11 +97,27 @@ class CloudflareProvider(BaseProvider):
'Authorization': 'Bearer {}'.format(token), 'Authorization': 'Bearer {}'.format(token),
}) })
self.cdn = cdn self.cdn = cdn
self.retry_count = retry_count
self.retry_period = retry_period
self._sess = sess self._sess = sess
self._zones = None self._zones = None
self._zone_records = {} self._zone_records = {}
def _try_request(self, *args, **kwargs):
tries = self.retry_count
while True: # We'll raise to break after our tries expire
try:
return self._request(*args, **kwargs)
except CloudflareRateLimitError:
if tries <= 1:
raise
tries -= 1
self.log.warn('rate limit encountered, pausing '
'for %ds and trying again, %d remaining',
self.retry_period, tries)
sleep(self.retry_period)
def _request(self, method, path, params=None, data=None): def _request(self, method, path, params=None, data=None):
self.log.debug('_request: method=%s, path=%s', method, path) self.log.debug('_request: method=%s, path=%s', method, path)
@ -101,6 +129,8 @@ class CloudflareProvider(BaseProvider):
raise CloudflareError(resp.json()) raise CloudflareError(resp.json())
if resp.status_code == 403: if resp.status_code == 403:
raise CloudflareAuthenticationError(resp.json()) raise CloudflareAuthenticationError(resp.json())
if resp.status_code == 429:
raise CloudflareRateLimitError(resp.json())
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
@ -111,7 +141,8 @@ class CloudflareProvider(BaseProvider):
page = 1 page = 1
zones = [] zones = []
while page: while page:
resp = self._request('GET', '/zones', params={'page': page})
resp = self._try_request('GET', '/zones',
params={'page': page})
zones += resp['result'] zones += resp['result']
info = resp['result_info'] info = resp['result_info']
if info['count'] > 0 and info['count'] == info['per_page']: if info['count'] > 0 and info['count'] == info['per_page']:
@ -173,6 +204,7 @@ class CloudflareProvider(BaseProvider):
} }
_data_for_ALIAS = _data_for_CNAME _data_for_ALIAS = _data_for_CNAME
_data_for_PTR = _data_for_CNAME
def _data_for_MX(self, _type, records): def _data_for_MX(self, _type, records):
values = [] values = []
@ -219,7 +251,7 @@ class CloudflareProvider(BaseProvider):
path = '/zones/{}/dns_records'.format(zone_id) path = '/zones/{}/dns_records'.format(zone_id)
page = 1 page = 1
while page: while page:
resp = self._request('GET', path, params={'page': page})
resp = self._try_request('GET', path, params={'page': page})
records += resp['result'] records += resp['result']
info = resp['result_info'] info = resp['result_info']
if info['count'] > 0 and info['count'] == info['per_page']: if info['count'] > 0 and info['count'] == info['per_page']:
@ -339,6 +371,8 @@ class CloudflareProvider(BaseProvider):
def _contents_for_CNAME(self, record): def _contents_for_CNAME(self, record):
yield {'content': record.value} yield {'content': record.value}
_contents_for_PTR = _contents_for_CNAME
def _contents_for_MX(self, record): def _contents_for_MX(self, record):
for value in record.values: for value in record.values:
yield { yield {
@ -430,7 +464,7 @@ class CloudflareProvider(BaseProvider):
zone_id = self.zones[new.zone.name] zone_id = self.zones[new.zone.name]
path = '/zones/{}/dns_records'.format(zone_id) path = '/zones/{}/dns_records'.format(zone_id)
for content in self._gen_data(new): for content in self._gen_data(new):
self._request('POST', path, data=content)
self._try_request('POST', path, data=content)
def _apply_Update(self, change): def _apply_Update(self, change):
zone = change.new.zone zone = change.new.zone
@ -519,7 +553,7 @@ class CloudflareProvider(BaseProvider):
path = '/zones/{}/dns_records'.format(zone_id) path = '/zones/{}/dns_records'.format(zone_id)
for _, data in sorted(creates.items()): for _, data in sorted(creates.items()):
self.log.debug('_apply_Update: creating %s', data) self.log.debug('_apply_Update: creating %s', data)
self._request('POST', path, data=data)
self._try_request('POST', path, data=data)
# Updates # Updates
for _, info in sorted(updates.items()): for _, info in sorted(updates.items()):
@ -529,7 +563,7 @@ class CloudflareProvider(BaseProvider):
path = '/zones/{}/dns_records/{}'.format(zone_id, record_id) path = '/zones/{}/dns_records/{}'.format(zone_id, record_id)
self.log.debug('_apply_Update: updating %s, %s -> %s', self.log.debug('_apply_Update: updating %s, %s -> %s',
record_id, data, old_data) record_id, data, old_data)
self._request('PUT', path, data=data)
self._try_request('PUT', path, data=data)
# Deletes # Deletes
for _, info in sorted(deletes.items()): for _, info in sorted(deletes.items()):
@ -538,7 +572,7 @@ class CloudflareProvider(BaseProvider):
path = '/zones/{}/dns_records/{}'.format(zone_id, record_id) path = '/zones/{}/dns_records/{}'.format(zone_id, record_id)
self.log.debug('_apply_Update: removing %s, %s', record_id, self.log.debug('_apply_Update: removing %s, %s', record_id,
old_data) old_data)
self._request('DELETE', path)
self._try_request('DELETE', path)
def _apply_Delete(self, change): def _apply_Delete(self, change):
existing = change.existing existing = change.existing
@ -551,7 +585,7 @@ class CloudflareProvider(BaseProvider):
existing_type == record['type']: existing_type == record['type']:
path = '/zones/{}/dns_records/{}'.format(record['zone_id'], path = '/zones/{}/dns_records/{}'.format(record['zone_id'],
record['id']) record['id'])
self._request('DELETE', path)
self._try_request('DELETE', path)
def _apply(self, plan): def _apply(self, plan):
desired = plan.desired desired = plan.desired
@ -566,7 +600,7 @@ class CloudflareProvider(BaseProvider):
'name': name[:-1], 'name': name[:-1],
'jump_start': False, 'jump_start': False,
} }
resp = self._request('POST', '/zones', data=data)
resp = self._try_request('POST', '/zones', data=data)
zone_id = resp['result']['id'] zone_id = resp['result']['id']
self.zones[name] = zone_id self.zones[name] = zone_id
self._zone_records[name] = {} self._zone_records[name] = {}


+ 1
- 1
octodns/provider/dyn.py View File

@ -1141,7 +1141,7 @@ class DynProvider(BaseProvider):
pools[rpid] = get_response_pool(rpid, td) pools[rpid] = get_response_pool(rpid, td)
# now that we have full objects for the complete set of existing pools, # now that we have full objects for the complete set of existing pools,
# a list will be more useful # a list will be more useful
pools = pools.values()
pools = list(pools.values())
# Rulesets # Rulesets


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

@ -253,7 +253,9 @@ class Ns1Provider(BaseProvider):
def _REGION_FILTER(self, with_disabled): def _REGION_FILTER(self, with_disabled):
return self._update_filter({ return self._update_filter({
'config': {},
'config': {
'remove_no_georegion': True
},
'filter': u'geofence_regional' 'filter': u'geofence_regional'
}, with_disabled) }, with_disabled)
@ -766,8 +768,7 @@ class Ns1Provider(BaseProvider):
for iso_region, target in record.geo.items(): for iso_region, target in record.geo.items():
key = 'iso_region_code' key = 'iso_region_code'
value = iso_region value = iso_region
if not has_country and \
len(value.split('-')) > 1: # pragma: nocover
if not has_country and len(value.split('-')) > 1:
has_country = True has_country = True
for answer in target.values: for answer in target.values:
params['answers'].append( params['answers'].append(
@ -856,18 +857,13 @@ class Ns1Provider(BaseProvider):
host = record.fqdn[:-1] host = record.fqdn[:-1]
_type = record._type _type = record._type
request = r'GET {path} HTTP/1.0\r\nHost: {host}\r\n' \
r'User-agent: NS1\r\n\r\n'.format(path=record.healthcheck_path,
host=record.healthcheck_host)
return {
ret = {
'active': True, 'active': True,
'config': { 'config': {
'connect_timeout': 2000, 'connect_timeout': 2000,
'host': value, 'host': value,
'port': record.healthcheck_port, 'port': record.healthcheck_port,
'response_timeout': 10000, 'response_timeout': 10000,
'send': request,
'ssl': record.healthcheck_protocol == 'HTTPS', 'ssl': record.healthcheck_protocol == 'HTTPS',
}, },
'frequency': 60, 'frequency': 60,
@ -881,12 +877,23 @@ class Ns1Provider(BaseProvider):
'rapid_recheck': False, 'rapid_recheck': False,
'region_scope': 'fixed', 'region_scope': 'fixed',
'regions': self.monitor_regions, 'regions': self.monitor_regions,
'rules': [{
}
if record.healthcheck_protocol != 'TCP':
# IF it's HTTP we need to send the request string
path = record.healthcheck_path
host = record.healthcheck_host
request = r'GET {path} HTTP/1.0\r\nHost: {host}\r\n' \
r'User-agent: NS1\r\n\r\n'.format(path=path, host=host)
ret['config']['send'] = request
# We'll also expect a HTTP response
ret['rules'] = [{
'comparison': 'contains', 'comparison': 'contains',
'key': 'output', 'key': 'output',
'value': '200 OK', 'value': '200 OK',
}],
}
}]
return ret
def _monitor_is_match(self, expected, have): def _monitor_is_match(self, expected, have):
# Make sure what we have matches what's in expected exactly. Anything # Make sure what we have matches what's in expected exactly. Anything


+ 8
- 4
octodns/provider/route53.py View File

@ -1046,8 +1046,11 @@ class Route53Provider(BaseProvider):
# No value so give this a None to match value's # No value so give this a None to match value's
config_ip_address = None config_ip_address = None
return host == config['FullyQualifiedDomainName'] and \
path == config['ResourcePath'] and protocol == config['Type'] \
fully_qualified_domain_name = config.get('FullyQualifiedDomainName',
None)
resource_path = config.get('ResourcePath', None)
return host == fully_qualified_domain_name and \
path == resource_path and protocol == config['Type'] \
and port == config['Port'] and \ and port == config['Port'] and \
measure_latency == config['MeasureLatency'] and \ measure_latency == config['MeasureLatency'] and \
value == config_ip_address value == config_ip_address
@ -1103,13 +1106,14 @@ class Route53Provider(BaseProvider):
config = { config = {
'EnableSNI': healthcheck_protocol == 'HTTPS', 'EnableSNI': healthcheck_protocol == 'HTTPS',
'FailureThreshold': 6, 'FailureThreshold': 6,
'FullyQualifiedDomainName': healthcheck_host,
'MeasureLatency': healthcheck_latency, 'MeasureLatency': healthcheck_latency,
'Port': healthcheck_port, 'Port': healthcheck_port,
'RequestInterval': 10, 'RequestInterval': 10,
'ResourcePath': healthcheck_path,
'Type': healthcheck_protocol, 'Type': healthcheck_protocol,
} }
if healthcheck_protocol != 'TCP':
config['FullyQualifiedDomainName'] = healthcheck_host
config['ResourcePath'] = healthcheck_path
if value: if value:
config['IPAddress'] = value config['IPAddress'] = value


+ 9
- 3
octodns/record/__init__.py View File

@ -137,7 +137,7 @@ class Record(EqualityTupleMixin):
reasons.append('missing ttl') reasons.append('missing ttl')
try: try:
if data['octodns']['healthcheck']['protocol'] \ if data['octodns']['healthcheck']['protocol'] \
not in ('HTTP', 'HTTPS'):
not in ('HTTP', 'HTTPS', 'TCP'):
reasons.append('invalid healthcheck protocol') reasons.append('invalid healthcheck protocol')
except KeyError: except KeyError:
pass pass
@ -181,15 +181,21 @@ class Record(EqualityTupleMixin):
@property @property
def healthcheck_host(self): def healthcheck_host(self):
healthcheck = self._octodns.get('healthcheck', {})
if healthcheck.get('protocol', None) == 'TCP':
return None
try: try:
return self._octodns['healthcheck']['host']
return healthcheck['host']
except KeyError: except KeyError:
return self.fqdn[:-1] return self.fqdn[:-1]
@property @property
def healthcheck_path(self): def healthcheck_path(self):
healthcheck = self._octodns.get('healthcheck', {})
if healthcheck.get('protocol', None) == 'TCP':
return None
try: try:
return self._octodns['healthcheck']['path']
return healthcheck['path']
except KeyError: except KeyError:
return '/_dns' return '/_dns'


+ 9
- 3
octodns/record/geo.py View File

@ -63,9 +63,15 @@ class GeoCodes(object):
@classmethod @classmethod
def province_to_code(cls, province): def province_to_code(cls, province):
# We get to cheat on this one since we only support provinces in NA-US
if province not in geo_data['NA']['US']['provinces']:
# We cheat on this one a little since we only support provinces in
# NA-US, NA-CA
if (province not in geo_data['NA']['US']['provinces'] and
province not in geo_data['NA']['CA']['provinces']):
cls.log.warn('country_to_code: unrecognized province "%s"', cls.log.warn('country_to_code: unrecognized province "%s"',
province) province)
return return
return 'NA-US-{}'.format(province)
if province in geo_data['NA']['US']['provinces']:
country = 'US'
if province in geo_data['NA']['CA']['provinces']:
country = 'CA'
return 'NA-{}-{}'.format(country, province)

+ 16
- 3
octodns/record/geo_data.py View File

@ -55,7 +55,7 @@ geo_data = \
'SO': {'name': 'Somalia'}, 'SO': {'name': 'Somalia'},
'SS': {'name': 'South Sudan'}, 'SS': {'name': 'South Sudan'},
'ST': {'name': 'Sao Tome and Principe'}, 'ST': {'name': 'Sao Tome and Principe'},
'SZ': {'name': 'Swaziland'},
'SZ': {'name': 'Eswatini'},
'TD': {'name': 'Chad'}, 'TD': {'name': 'Chad'},
'TG': {'name': 'Togo'}, 'TG': {'name': 'Togo'},
'TN': {'name': 'Tunisia'}, 'TN': {'name': 'Tunisia'},
@ -157,7 +157,7 @@ geo_data = \
'MC': {'name': 'Monaco'}, 'MC': {'name': 'Monaco'},
'MD': {'name': 'Moldova, Republic of'}, 'MD': {'name': 'Moldova, Republic of'},
'ME': {'name': 'Montenegro'}, 'ME': {'name': 'Montenegro'},
'MK': {'name': 'Macedonia, Republic of'},
'MK': {'name': 'North Macedonia'},
'MT': {'name': 'Malta'}, 'MT': {'name': 'Malta'},
'NL': {'name': 'Netherlands'}, 'NL': {'name': 'Netherlands'},
'NO': {'name': 'Norway'}, 'NO': {'name': 'Norway'},
@ -183,7 +183,20 @@ geo_data = \
'BQ': {'name': 'Bonaire, Sint Eustatius and Saba'}, 'BQ': {'name': 'Bonaire, Sint Eustatius and Saba'},
'BS': {'name': 'Bahamas'}, 'BS': {'name': 'Bahamas'},
'BZ': {'name': 'Belize'}, 'BZ': {'name': 'Belize'},
'CA': {'name': 'Canada'},
'CA': {'name': 'Canada',
'provinces': {'AB': {'name': 'Alberta'},
'BC': {'name': 'British Columbia'},
'MB': {'name': 'Manitoba'},
'NB': {'name': 'New Brunswick'},
'NL': {'name': 'Newfoundland and Labrador'},
'NS': {'name': 'Nova Scotia'},
'NT': {'name': 'Northwest Territories'},
'NU': {'name': 'Nunavut'},
'ON': {'name': 'Ontario'},
'PE': {'name': 'Prince Edward Island'},
'QC': {'name': 'Quebec'},
'SK': {'name': 'Saskatchewan'},
'YT': {'name': 'Yukon Territory'}}},
'CR': {'name': 'Costa Rica'}, 'CR': {'name': 'Costa Rica'},
'CU': {'name': 'Cuba'}, 'CU': {'name': 'Cuba'},
'CW': {'name': 'Curaçao'}, 'CW': {'name': 'Curaçao'},


+ 1
- 1
requirements-dev.txt View File

@ -1,7 +1,7 @@
coverage coverage
mock mock
nose nose
pycodestyle==2.5.0
pycodestyle==2.6.0
pyflakes==2.2.0 pyflakes==2.2.0
readme_renderer[md]==26.0 readme_renderer[md]==26.0
requests_mock requests_mock


+ 5
- 5
requirements.txt View File

@ -1,8 +1,8 @@
PyYaml==5.3.1 PyYaml==5.3.1
azure-common==1.1.25 azure-common==1.1.25
azure-mgmt-dns==3.0.0 azure-mgmt-dns==3.0.0
boto3==1.13.0
botocore==1.16.0
boto3==1.13.19
botocore==1.16.19
dnspython==1.16.0 dnspython==1.16.0
docutils==0.16 docutils==0.16
dyn==1.8.1 dyn==1.8.1
@ -14,13 +14,13 @@ ipaddress==1.0.23
jmespath==0.9.5 jmespath==0.9.5
msrestazure==0.6.3 msrestazure==0.6.3
natsort==6.2.1 natsort==6.2.1
ns1-python==0.15.0
ns1-python==0.16.0
ovh==0.5.0 ovh==0.5.0
pycountry-convert==0.7.2 pycountry-convert==0.7.2
pycountry==19.8.18 pycountry==19.8.18
python-dateutil==2.8.1 python-dateutil==2.8.1
requests==2.23.0 requests==2.23.0
s3transfer==0.3.3 s3transfer==0.3.3
setuptools==44.1.0
six==1.14.0
setuptools==44.1.1
six==1.15.0
transip==2.1.2 transip==2.1.2

+ 6
- 0
script/coverage View File

@ -26,6 +26,12 @@ export DYN_PASSWORD=
export DYN_USERNAME= export DYN_USERNAME=
export GOOGLE_APPLICATION_CREDENTIALS= export GOOGLE_APPLICATION_CREDENTIALS=
# Don't allow disabling coverage
grep -r -I --line-number "# pragma: nocover" octodns && {
echo "Code coverage should not be disabled"
exit 1
}
coverage run --branch --source=octodns --omit=octodns/cmds/* "$(command -v nosetests)" --with-xunit "$@" coverage run --branch --source=octodns --omit=octodns/cmds/* "$(command -v nosetests)" --with-xunit "$@"
coverage html coverage html
coverage xml coverage xml


+ 2
- 2
script/generate-geo-data View File

@ -8,8 +8,8 @@ from pycountry_convert import country_alpha2_to_continent_code
subs = defaultdict(dict) subs = defaultdict(dict)
for subdivision in subdivisions: for subdivision in subdivisions:
# Route53 only supports US states, Dyn supports US states and CA provinces, but for now we'll just do US
if subdivision.country_code not in ('US'):
# Route53 only supports US states, Dyn (and others) support US states and CA provinces
if subdivision.country_code not in ('US', 'CA'):
continue continue
subs[subdivision.country_code][subdivision.code[3:]] = { subs[subdivision.country_code][subdivision.code[3:]] = {
'name': subdivision.name 'name': subdivision.name


+ 1
- 1
script/lint View File

@ -17,5 +17,5 @@ fi
SOURCES="*.py octodns/*.py octodns/*/*.py tests/*.py" SOURCES="*.py octodns/*.py octodns/*/*.py tests/*.py"
pycodestyle --ignore=E221,E241,E251,E722,W504 $SOURCES
pycodestyle --ignore=E221,E241,E251,E722,E741,W504 $SOURCES
pyflakes $SOURCES pyflakes $SOURCES

+ 1
- 1
tests/fixtures/cloudflare-dns_records-page-1.json View File

@ -180,7 +180,7 @@
"per_page": 10, "per_page": 10,
"total_pages": 2, "total_pages": 2,
"count": 10, "count": 10,
"total_count": 19
"total_count": 20
}, },
"success": true, "success": true,
"errors": [], "errors": [],


+ 19
- 2
tests/fixtures/cloudflare-dns_records-page-2.json View File

@ -157,6 +157,23 @@
"auto_added": false "auto_added": false
} }
}, },
{
"id": "fc12ab34cd5611334422ab3322997677",
"type": "PTR",
"name": "ptr.unit.tests",
"content": "foo.bar.com",
"proxiable": true,
"proxied": false,
"ttl": 300,
"locked": false,
"zone_id": "ff12ab34cd5611334422ab3322997650",
"zone_name": "unit.tests",
"modified_on": "2017-03-11T18:01:43.940682Z",
"created_on": "2017-03-11T18:01:43.940682Z",
"meta": {
"auto_added": false
}
},
{ {
"id": "fc12ab34cd5611334422ab3322997656", "id": "fc12ab34cd5611334422ab3322997656",
"type": "SRV", "type": "SRV",
@ -212,8 +229,8 @@
"page": 2, "page": 2,
"per_page": 11, "per_page": 11,
"total_pages": 2, "total_pages": 2,
"count": 9,
"total_count": 21
"count": 10,
"total_count": 20
}, },
"success": true, "success": true,
"errors": [], "errors": [],


+ 129
- 12
tests/test_octodns_provider_cloudflare.py View File

@ -14,7 +14,8 @@ from unittest import TestCase
from octodns.record import Record, Update from octodns.record import Record, Update
from octodns.provider.base import Plan from octodns.provider.base import Plan
from octodns.provider.cloudflare import CloudflareProvider
from octodns.provider.cloudflare import CloudflareProvider, \
CloudflareRateLimitError
from octodns.provider.yaml import YamlProvider from octodns.provider.yaml import YamlProvider
from octodns.zone import Zone from octodns.zone import Zone
@ -52,7 +53,7 @@ class TestCloudflareProvider(TestCase):
empty = {'result': [], 'result_info': {'count': 0, 'per_page': 0}} empty = {'result': [], 'result_info': {'count': 0, 'per_page': 0}}
def test_populate(self): def test_populate(self):
provider = CloudflareProvider('test', 'email', 'token')
provider = CloudflareProvider('test', 'email', 'token', retry_period=0)
# Bad requests # Bad requests
with requests_mock() as mock: with requests_mock() as mock:
@ -103,6 +104,36 @@ class TestCloudflareProvider(TestCase):
provider.populate(zone) provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code) self.assertEquals(502, ctx.exception.response.status_code)
# Rate Limit error
with requests_mock() as mock:
mock.get(ANY, status_code=429,
text='{"success":false,"errors":[{"code":10100,'
'"message":"More than 1200 requests per 300 seconds '
'reached. Please wait and consider throttling your '
'request speed"}],"messages":[],"result":null}')
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals('CloudflareRateLimitError',
type(ctx.exception).__name__)
self.assertEquals('More than 1200 requests per 300 seconds '
'reached. Please wait and consider throttling '
'your request speed', text_type(ctx.exception))
# Rate Limit error, unknown resp
with requests_mock() as mock:
mock.get(ANY, status_code=429, text='{}')
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals('CloudflareRateLimitError',
type(ctx.exception).__name__)
self.assertEquals('Cloudflare error', text_type(ctx.exception))
# Non-existent zone doesn't populate anything # Non-existent zone doesn't populate anything
with requests_mock() as mock: with requests_mock() as mock:
mock.get(ANY, status_code=200, json=self.empty) mock.get(ANY, status_code=200, json=self.empty)
@ -149,7 +180,7 @@ class TestCloudflareProvider(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(12, len(zone.records))
self.assertEquals(13, len(zone.records))
changes = self.expected.changes(zone, provider) changes = self.expected.changes(zone, provider)
@ -158,10 +189,10 @@ class TestCloudflareProvider(TestCase):
# re-populating the same zone/records comes out of cache, no calls # re-populating the same zone/records comes out of cache, no calls
again = Zone('unit.tests.', []) again = Zone('unit.tests.', [])
provider.populate(again) provider.populate(again)
self.assertEquals(12, len(again.records))
self.assertEquals(13, len(again.records))
def test_apply(self): def test_apply(self):
provider = CloudflareProvider('test', 'email', 'token')
provider = CloudflareProvider('test', 'email', 'token', retry_period=0)
provider._request = Mock() provider._request = Mock()
@ -172,12 +203,12 @@ class TestCloudflareProvider(TestCase):
'id': 42, 'id': 42,
} }
}, # zone create }, # zone create
] + [None] * 20 # individual record creates
] + [None] * 22 # individual record creates
# non-existent zone, create everything # non-existent zone, create everything
plan = provider.plan(self.expected) plan = provider.plan(self.expected)
self.assertEquals(12, len(plan.changes))
self.assertEquals(12, provider.apply(plan))
self.assertEquals(13, len(plan.changes))
self.assertEquals(13, provider.apply(plan))
self.assertFalse(plan.exists) self.assertFalse(plan.exists)
provider._request.assert_has_calls([ provider._request.assert_has_calls([
@ -203,7 +234,7 @@ class TestCloudflareProvider(TestCase):
}), }),
], True) ], True)
# expected number of total calls # expected number of total calls
self.assertEquals(22, provider._request.call_count)
self.assertEquals(23, provider._request.call_count)
provider._request.reset_mock() provider._request.reset_mock()
@ -280,7 +311,11 @@ class TestCloudflareProvider(TestCase):
# we don't care about the POST/create return values # we don't care about the POST/create return values
provider._request.return_value = {} provider._request.return_value = {}
provider._request.side_effect = None
# Test out the create rate-limit handling, then 9 successes
provider._request.side_effect = [
CloudflareRateLimitError('{}'),
] + ([None] * 3)
wanted = Zone('unit.tests.', []) wanted = Zone('unit.tests.', [])
wanted.add_record(Record.new(wanted, 'nc', { wanted.add_record(Record.new(wanted, 'nc', {
@ -316,7 +351,7 @@ class TestCloudflareProvider(TestCase):
]) ])
def test_update_add_swap(self): def test_update_add_swap(self):
provider = CloudflareProvider('test', 'email', 'token')
provider = CloudflareProvider('test', 'email', 'token', retry_period=0)
provider.zone_records = Mock(return_value=[ provider.zone_records = Mock(return_value=[
{ {
@ -357,6 +392,7 @@ class TestCloudflareProvider(TestCase):
provider._request = Mock() provider._request = Mock()
provider._request.side_effect = [ provider._request.side_effect = [
CloudflareRateLimitError('{}'),
self.empty, # no zones self.empty, # no zones
{ {
'result': { 'result': {
@ -423,7 +459,7 @@ class TestCloudflareProvider(TestCase):
def test_update_delete(self): def test_update_delete(self):
# We need another run so that we can delete, we can't both add and # We need another run so that we can delete, we can't both add and
# delete in one go b/c of swaps # delete in one go b/c of swaps
provider = CloudflareProvider('test', 'email', 'token')
provider = CloudflareProvider('test', 'email', 'token', retry_period=0)
provider.zone_records = Mock(return_value=[ provider.zone_records = Mock(return_value=[
{ {
@ -464,6 +500,7 @@ class TestCloudflareProvider(TestCase):
provider._request = Mock() provider._request = Mock()
provider._request.side_effect = [ provider._request.side_effect = [
CloudflareRateLimitError('{}'),
self.empty, # no zones self.empty, # no zones
{ {
'result': { 'result': {
@ -510,6 +547,25 @@ class TestCloudflareProvider(TestCase):
'fc12ab34cd5611334422ab3322997653') 'fc12ab34cd5611334422ab3322997653')
]) ])
def test_ptr(self):
provider = CloudflareProvider('test', 'email', 'token')
zone = Zone('unit.tests.', [])
# PTR record
ptr_record = Record.new(zone, 'ptr', {
'ttl': 300,
'type': 'PTR',
'value': 'foo.bar.com.'
})
ptr_record_contents = provider._gen_data(ptr_record)
self.assertEquals({
'name': 'ptr.unit.tests',
'ttl': 300,
'type': 'PTR',
'content': 'foo.bar.com.'
}, list(ptr_record_contents)[0])
def test_srv(self): def test_srv(self):
provider = CloudflareProvider('test', 'email', 'token') provider = CloudflareProvider('test', 'email', 'token')
@ -1223,3 +1279,64 @@ class TestCloudflareProvider(TestCase):
provider = CloudflareProvider('test', token='token 123') provider = CloudflareProvider('test', token='token 123')
headers = provider._sess.headers headers = provider._sess.headers
self.assertEquals('Bearer token 123', headers['Authorization']) self.assertEquals('Bearer token 123', headers['Authorization'])
def test_retry_behavior(self):
provider = CloudflareProvider('test', token='token 123',
email='email 234', retry_period=0)
result = {
"success": True,
"errors": [],
"messages": [],
"result": [],
"result_info": {
"count": 1,
"per_page": 50
}
}
zone = Zone('unit.tests.', [])
provider._request = Mock()
# No retry required, just calls and is returned
provider._zones = None
provider._request.reset_mock()
provider._request.side_effect = [result]
self.assertEquals([], provider.zone_records(zone))
provider._request.assert_has_calls([call('GET', '/zones',
params={'page': 1})])
# One retry required
provider._zones = None
provider._request.reset_mock()
provider._request.side_effect = [
CloudflareRateLimitError('{}'),
result
]
self.assertEquals([], provider.zone_records(zone))
provider._request.assert_has_calls([call('GET', '/zones',
params={'page': 1})])
# Two retries required
provider._zones = None
provider._request.reset_mock()
provider._request.side_effect = [
CloudflareRateLimitError('{}'),
CloudflareRateLimitError('{}'),
result
]
self.assertEquals([], provider.zone_records(zone))
provider._request.assert_has_calls([call('GET', '/zones',
params={'page': 1})])
# # Exhaust our retries
provider._zones = None
provider._request.reset_mock()
provider._request.side_effect = [
CloudflareRateLimitError({"errors": [{"message": "first"}]}),
CloudflareRateLimitError({"errors": [{"message": "boo"}]}),
CloudflareRateLimitError({"errors": [{"message": "boo"}]}),
CloudflareRateLimitError({"errors": [{"message": "boo"}]}),
CloudflareRateLimitError({"errors": [{"message": "last"}]}),
]
with self.assertRaises(CloudflareRateLimitError) as ctx:
provider.zone_records(zone)
self.assertEquals('last', text_type(ctx.exception))

+ 17
- 0
tests/test_octodns_provider_ns1.py View File

@ -717,6 +717,13 @@ class TestNs1ProviderDynamic(TestCase):
monitor = provider._monitor_gen(self.record, value) monitor = provider._monitor_gen(self.record, value)
self.assertTrue(monitor['config']['ssl']) self.assertTrue(monitor['config']['ssl'])
self.record._octodns['healthcheck']['protocol'] = 'TCP'
monitor = provider._monitor_gen(self.record, value)
# No http send done
self.assertFalse('send' in monitor['config'])
# No http response expected
self.assertFalse('rules' in monitor)
def test_monitor_is_match(self): def test_monitor_is_match(self):
provider = Ns1Provider('test', 'api-key') provider = Ns1Provider('test', 'api-key')
@ -1059,6 +1066,16 @@ class TestNs1ProviderDynamic(TestCase):
call(self.record, '3.4.5.6', 'mid-3'), call(self.record, '3.4.5.6', 'mid-3'),
]) ])
record = Record.new(self.zone, 'geo', {
'ttl': 34,
'type': 'A',
'values': ['101.102.103.104', '101.102.103.105'],
'geo': {'EU': ['201.202.203.204']},
'meta': {},
})
params, _ = provider._params_for_geo_A(record)
self.assertEquals([], params['filters'])
def test_data_for_dynamic_A(self): def test_data_for_dynamic_A(self):
provider = Ns1Provider('test', 'api-key') provider = Ns1Provider('test', 'api-key')


+ 29
- 0
tests/test_octodns_provider_route53.py View File

@ -1213,6 +1213,35 @@ class TestRoute53Provider(TestCase):
self.assertEquals('42', id) self.assertEquals('42', id)
stubber.assert_no_pending_responses() stubber.assert_no_pending_responses()
# TCP health check
health_check_config = {
'EnableSNI': False,
'FailureThreshold': 6,
'MeasureLatency': True,
'Port': 8080,
'RequestInterval': 10,
'Type': 'TCP'
}
stubber.add_response('create_health_check', {
'HealthCheck': {
'Id': '42',
'CallerReference': self.caller_ref,
'HealthCheckConfig': health_check_config,
'HealthCheckVersion': 1,
},
'Location': 'http://url',
}, {
'CallerReference': ANY,
'HealthCheckConfig': health_check_config,
})
stubber.add_response('change_tags_for_resource', {})
record._octodns['healthcheck']['protocol'] = 'TCP'
id = provider.get_health_check_id(record, 'target-1.unit.tests.', True)
self.assertEquals('42', id)
stubber.assert_no_pending_responses()
def test_health_check_measure_latency(self): def test_health_check_measure_latency(self):
provider, stubber = self._get_stubbed_provider() provider, stubber = self._get_stubbed_provider()
record_true = Record.new(self.expected, 'a', { record_true = Record.new(self.expected, 'a', {


+ 34
- 0
tests/test_octodns_record.py View File

@ -886,6 +886,40 @@ class TestRecord(TestCase):
self.assertEquals('HTTPS', new.healthcheck_protocol) self.assertEquals('HTTPS', new.healthcheck_protocol)
self.assertEquals(443, new.healthcheck_port) self.assertEquals(443, new.healthcheck_port)
def test_healthcheck_tcp(self):
new = Record.new(self.zone, 'a', {
'ttl': 44,
'type': 'A',
'value': '1.2.3.4',
'octodns': {
'healthcheck': {
'path': '/ignored',
'host': 'completely.ignored',
'protocol': 'TCP',
'port': 8080,
}
}
})
self.assertIsNone(new.healthcheck_path)
self.assertIsNone(new.healthcheck_host)
self.assertEquals('TCP', new.healthcheck_protocol)
self.assertEquals(8080, new.healthcheck_port)
new = Record.new(self.zone, 'a', {
'ttl': 44,
'type': 'A',
'value': '1.2.3.4',
'octodns': {
'healthcheck': {
'protocol': 'TCP',
}
}
})
self.assertIsNone(new.healthcheck_path)
self.assertIsNone(new.healthcheck_host)
self.assertEquals('TCP', new.healthcheck_protocol)
self.assertEquals(443, new.healthcheck_port)
def test_inored(self): def test_inored(self):
new = Record.new(self.zone, 'txt', { new = Record.new(self.zone, 'txt', {
'ttl': 44, 'ttl': 44,


+ 2
- 0
tests/test_octodns_record_geo.py View File

@ -77,4 +77,6 @@ class TestRecordGeoCodes(TestCase):
def test_province_to_code(self): def test_province_to_code(self):
self.assertEquals('NA-US-OR', GeoCodes.province_to_code('OR')) self.assertEquals('NA-US-OR', GeoCodes.province_to_code('OR'))
self.assertEquals('NA-US-KY', GeoCodes.province_to_code('KY')) self.assertEquals('NA-US-KY', GeoCodes.province_to_code('KY'))
self.assertEquals('NA-CA-AB', GeoCodes.province_to_code('AB'))
self.assertEquals('NA-CA-BC', GeoCodes.province_to_code('BC'))
self.assertFalse(GeoCodes.province_to_code('XX')) self.assertFalse(GeoCodes.province_to_code('XX'))

Loading…
Cancel
Save