Browse Source

Merge pull request #1 from maikelpoot/powerdns-4.3.x-support

Powerdns 4.3.x support
pull/540/head
Adam Mielke 6 years ago
committed by GitHub
parent
commit
836d6daee2
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 541 additions and 106 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. +80
    -16
      octodns/provider/powerdns.py
  8. +8
    -4
      octodns/provider/route53.py
  9. +9
    -3
      octodns/record/__init__.py
  10. +9
    -3
      octodns/record/geo.py
  11. +16
    -3
      octodns/record/geo_data.py
  12. +1
    -1
      requirements-dev.txt
  13. +5
    -5
      requirements.txt
  14. +6
    -0
      script/coverage
  15. +2
    -2
      script/generate-geo-data
  16. +1
    -1
      script/lint
  17. +1
    -1
      tests/fixtures/cloudflare-dns_records-page-1.json
  18. +19
    -2
      tests/fixtures/cloudflare-dns_records-page-2.json
  19. +129
    -12
      tests/test_octodns_provider_cloudflare.py
  20. +17
    -0
      tests/test_octodns_provider_ns1.py
  21. +99
    -26
      tests/test_octodns_provider_powerdns.py
  22. +29
    -0
      tests/test_octodns_provider_route53.py
  23. +34
    -0
      tests/test_octodns_record.py
  24. +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
* 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
- [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)
- [Workspace](#workspace)
- [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 | |
| [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 |
| [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 |


+ 1
- 1
docs/dynamic_records.md View File

@ -103,7 +103,7 @@ test:
| host | FQDN for host header and SNI | - |
| path | path to check | _dns |
| port | port to check | 443 |
| protocol | HTTP/HTTPS | HTTPS |
| protocol | HTTP/HTTPS/TCP | HTTPS |
#### 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 logging import getLogger
from requests import Session
from time import sleep
from ..record import Record, Update
from .base import BaseProvider
@ -18,7 +19,7 @@ class CloudflareError(Exception):
def __init__(self, data):
try:
message = data['errors'][0]['message']
except (IndexError, KeyError):
except (IndexError, KeyError, TypeError):
message = 'Cloudflare error'
super(CloudflareError, self).__init__(message)
@ -28,6 +29,11 @@ class CloudflareAuthenticationError(CloudflareError):
CloudflareError.__init__(self, data)
class CloudflareRateLimitError(CloudflareError):
def __init__(self, data):
CloudflareError.__init__(self, data)
_PROXIABLE_RECORD_TYPES = {'A', 'AAAA', 'ALIAS', 'CNAME'}
@ -47,6 +53,11 @@ class CloudflareProvider(BaseProvider):
#
# See: https://support.cloudflare.com/hc/en-us/articles/115000830351
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
via the YAML provider like so:
@ -60,13 +71,14 @@ class CloudflareProvider(BaseProvider):
'''
SUPPORTS_GEO = 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
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.debug('__init__: id=%s, email=%s, token=***, cdn=%s', id,
email, cdn)
@ -85,11 +97,27 @@ class CloudflareProvider(BaseProvider):
'Authorization': 'Bearer {}'.format(token),
})
self.cdn = cdn
self.retry_count = retry_count
self.retry_period = retry_period
self._sess = sess
self._zones = None
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):
self.log.debug('_request: method=%s, path=%s', method, path)
@ -101,6 +129,8 @@ class CloudflareProvider(BaseProvider):
raise CloudflareError(resp.json())
if resp.status_code == 403:
raise CloudflareAuthenticationError(resp.json())
if resp.status_code == 429:
raise CloudflareRateLimitError(resp.json())
resp.raise_for_status()
return resp.json()
@ -111,7 +141,8 @@ class CloudflareProvider(BaseProvider):
page = 1
zones = []
while page:
resp = self._request('GET', '/zones', params={'page': page})
resp = self._try_request('GET', '/zones',
params={'page': page})
zones += resp['result']
info = resp['result_info']
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_PTR = _data_for_CNAME
def _data_for_MX(self, _type, records):
values = []
@ -219,7 +251,7 @@ class CloudflareProvider(BaseProvider):
path = '/zones/{}/dns_records'.format(zone_id)
page = 1
while page:
resp = self._request('GET', path, params={'page': page})
resp = self._try_request('GET', path, params={'page': page})
records += resp['result']
info = resp['result_info']
if info['count'] > 0 and info['count'] == info['per_page']:
@ -339,6 +371,8 @@ class CloudflareProvider(BaseProvider):
def _contents_for_CNAME(self, record):
yield {'content': record.value}
_contents_for_PTR = _contents_for_CNAME
def _contents_for_MX(self, record):
for value in record.values:
yield {
@ -430,7 +464,7 @@ class CloudflareProvider(BaseProvider):
zone_id = self.zones[new.zone.name]
path = '/zones/{}/dns_records'.format(zone_id)
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):
zone = change.new.zone
@ -519,7 +553,7 @@ class CloudflareProvider(BaseProvider):
path = '/zones/{}/dns_records'.format(zone_id)
for _, data in sorted(creates.items()):
self.log.debug('_apply_Update: creating %s', data)
self._request('POST', path, data=data)
self._try_request('POST', path, data=data)
# Updates
for _, info in sorted(updates.items()):
@ -529,7 +563,7 @@ class CloudflareProvider(BaseProvider):
path = '/zones/{}/dns_records/{}'.format(zone_id, record_id)
self.log.debug('_apply_Update: updating %s, %s -> %s',
record_id, data, old_data)
self._request('PUT', path, data=data)
self._try_request('PUT', path, data=data)
# Deletes
for _, info in sorted(deletes.items()):
@ -538,7 +572,7 @@ class CloudflareProvider(BaseProvider):
path = '/zones/{}/dns_records/{}'.format(zone_id, record_id)
self.log.debug('_apply_Update: removing %s, %s', record_id,
old_data)
self._request('DELETE', path)
self._try_request('DELETE', path)
def _apply_Delete(self, change):
existing = change.existing
@ -551,7 +585,7 @@ class CloudflareProvider(BaseProvider):
existing_type == record['type']:
path = '/zones/{}/dns_records/{}'.format(record['zone_id'],
record['id'])
self._request('DELETE', path)
self._try_request('DELETE', path)
def _apply(self, plan):
desired = plan.desired
@ -566,7 +600,7 @@ class CloudflareProvider(BaseProvider):
'name': name[:-1],
'jump_start': False,
}
resp = self._request('POST', '/zones', data=data)
resp = self._try_request('POST', '/zones', data=data)
zone_id = resp['result']['id']
self.zones[name] = zone_id
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)
# now that we have full objects for the complete set of existing pools,
# a list will be more useful
pools = pools.values()
pools = list(pools.values())
# Rulesets


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

@ -253,7 +253,9 @@ class Ns1Provider(BaseProvider):
def _REGION_FILTER(self, with_disabled):
return self._update_filter({
'config': {},
'config': {
'remove_no_georegion': True
},
'filter': u'geofence_regional'
}, with_disabled)
@ -766,8 +768,7 @@ class Ns1Provider(BaseProvider):
for iso_region, target in record.geo.items():
key = 'iso_region_code'
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
for answer in target.values:
params['answers'].append(
@ -856,18 +857,13 @@ class Ns1Provider(BaseProvider):
host = record.fqdn[:-1]
_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,
'config': {
'connect_timeout': 2000,
'host': value,
'port': record.healthcheck_port,
'response_timeout': 10000,
'send': request,
'ssl': record.healthcheck_protocol == 'HTTPS',
},
'frequency': 60,
@ -881,12 +877,23 @@ class Ns1Provider(BaseProvider):
'rapid_recheck': False,
'region_scope': 'fixed',
'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',
'key': 'output',
'value': '200 OK',
}],
}
}]
return ret
def _monitor_is_match(self, expected, have):
# Make sure what we have matches what's in expected exactly. Anything


+ 80
- 16
octodns/provider/powerdns.py View File

@ -19,13 +19,15 @@ class PowerDnsBaseProvider(BaseProvider):
'PTR', 'SPF', 'SSHFP', 'SRV', 'TXT'))
TIMEOUT = 5
def __init__(self, id, host, api_key, soa_edit_api, port=8081,
def __init__(self, id, host, api_key, port=8081,
scheme="http", timeout=TIMEOUT, *args, **kwargs):
super(PowerDnsBaseProvider, self).__init__(id, *args, **kwargs)
self.host = host
self.port = port
self.soa_edit_api = soa_edit_api
self.version_detected = ''
self.soa_edit_api = "INCEPTION-INCREMENT"
self.check_status_not_found = False
self.scheme = scheme
self.timeout = timeout
@ -37,7 +39,8 @@ class PowerDnsBaseProvider(BaseProvider):
self.log.debug('_request: method=%s, path=%s', method, path)
url = '{}://{}:{}/api/v1/servers/localhost/{}' \
.format(self.scheme, self.host, self.port, path)
.format(self.scheme, self.host, self.port, path).rstrip("/")
# Strip trailing / from url.
resp = self._sess.request(method, url, json=data, timeout=self.timeout)
self.log.debug('_request: status=%d', resp.status_code)
resp.raise_for_status()
@ -166,20 +169,75 @@ class PowerDnsBaseProvider(BaseProvider):
'ttl': rrset['ttl']
}
def detect_version(self):
# Only detect version once
if self.version_detected != '':
self.log.debug('detect_version: version %s allready detected',
self.version_detected)
return self.version_detected
try:
self.log.debug('detect_version: getting version from server')
resp = self._get('')
except HTTPError as e:
if e.response.status_code == 401:
# Nicer error message for auth problems
raise Exception('PowerDNS unauthorized host={}'
.format(self.host))
else:
raise
self.version_detected = resp.json()["version"]
self.log.debug('detect_version: got version %s from server',
self.version_detected)
self.configure_for_version(self.version_detected)
def configure_for_version(self, version):
major, minor, patch = version.split('.', 2)
major, minor, patch = int(major), int(minor), int(patch)
self.log.debug('configure_for_version: configure for '
'major: %s, minor: %s, patch: %s',
major, minor, patch)
# Defaults for v4.0.0
self.soa_edit_api = "INCEPTION-INCREMENT"
self.check_status_not_found = False
if major == 4 and minor >= 2:
self.log.debug("configure_for_version: Version >= 4.2")
self.soa_edit_api = "INCEPTION-INCREMENT"
self.check_status_not_found = True
if major == 4 and minor >= 3:
self.log.debug("configure_for_version: Version >= 4.3")
self.soa_edit_api = "DEFAULT"
self.check_status_not_found = True
def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient)
self.detect_version()
resp = None
try:
resp = self._get('zones/{}'.format(zone.name))
self.log.debug('populate: loaded')
except HTTPError as e:
error = self._get_error(e)
if e.response.status_code == 401:
# Nicer error message for auth problems
raise Exception('PowerDNS unauthorized host={}'
.format(self.host))
elif e.response.status_code in (404, 422):
elif e.response.status_code == 404 \
and self.check_status_not_found:
# 404 or 422 means powerdns doesn't know anything about the
# requested domain. We'll just ignore it here and leave the
# zone untouched.
pass
elif e.response.status_code == 422 \
and error.startswith('Could not find domain ') \
and not self.check_status_not_found:
# 404 or 422 means powerdns doesn't know anything about the
# requested domain. We'll just ignore it here and leave the
# zone untouched.
@ -339,13 +397,22 @@ class PowerDnsBaseProvider(BaseProvider):
self.log.debug('_apply: patched')
except HTTPError as e:
error = self._get_error(e)
if e.response.status_code != 404 and \
not (e.response.status_code == 422 and
error.startswith('Could not find domain ')):
self.log.error('_apply: status=%d, text=%s',
e.response.status_code,
e.response.text)
if not (
(
e.response.status_code == 404 and
self.check_status_not_found
) or (
e.response.status_code == 422 and
error.startswith('Could not find domain ') and
not self.check_status_not_found
)
):
self.log.error(
'_apply: status=%d, text=%s',
e.response.status_code,
e.response.text)
raise
self.log.info('_apply: creating zone=%s', desired.name)
# 404 or 422 means powerdns doesn't know anything about the
# requested domain. We'll try to create it with the correct
@ -398,17 +465,14 @@ class PowerDnsProvider(PowerDnsBaseProvider):
'''
def __init__(self, id, host, api_key, port=8081, nameserver_values=None,
nameserver_ttl=600, soa_edit_api='INCEPTION-INCREMENT',
nameserver_ttl=600,
*args, **kwargs):
self.log = logging.getLogger('PowerDnsProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, host=%s, port=%d, '
'nameserver_values=%s, nameserver_ttl=%d'
'soa_edit_api=%s',
id, host, port, nameserver_values, nameserver_ttl,
soa_edit_api)
'nameserver_values=%s, nameserver_ttl=%d',
id, host, port, nameserver_values, nameserver_ttl)
super(PowerDnsProvider, self).__init__(id, host=host, api_key=api_key,
port=port,
soa_edit_api=soa_edit_api,
*args, **kwargs)
self.nameserver_values = nameserver_values


+ 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
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 \
measure_latency == config['MeasureLatency'] and \
value == config_ip_address
@ -1103,13 +1106,14 @@ class Route53Provider(BaseProvider):
config = {
'EnableSNI': healthcheck_protocol == 'HTTPS',
'FailureThreshold': 6,
'FullyQualifiedDomainName': healthcheck_host,
'MeasureLatency': healthcheck_latency,
'Port': healthcheck_port,
'RequestInterval': 10,
'ResourcePath': healthcheck_path,
'Type': healthcheck_protocol,
}
if healthcheck_protocol != 'TCP':
config['FullyQualifiedDomainName'] = healthcheck_host
config['ResourcePath'] = healthcheck_path
if value:
config['IPAddress'] = value


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

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


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

@ -63,9 +63,15 @@ class GeoCodes(object):
@classmethod
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"',
province)
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'},
'SS': {'name': 'South Sudan'},
'ST': {'name': 'Sao Tome and Principe'},
'SZ': {'name': 'Swaziland'},
'SZ': {'name': 'Eswatini'},
'TD': {'name': 'Chad'},
'TG': {'name': 'Togo'},
'TN': {'name': 'Tunisia'},
@ -157,7 +157,7 @@ geo_data = \
'MC': {'name': 'Monaco'},
'MD': {'name': 'Moldova, Republic of'},
'ME': {'name': 'Montenegro'},
'MK': {'name': 'Macedonia, Republic of'},
'MK': {'name': 'North Macedonia'},
'MT': {'name': 'Malta'},
'NL': {'name': 'Netherlands'},
'NO': {'name': 'Norway'},
@ -183,7 +183,20 @@ geo_data = \
'BQ': {'name': 'Bonaire, Sint Eustatius and Saba'},
'BS': {'name': 'Bahamas'},
'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'},
'CU': {'name': 'Cuba'},
'CW': {'name': 'Curaçao'},


+ 1
- 1
requirements-dev.txt View File

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


+ 5
- 5
requirements.txt View File

@ -1,8 +1,8 @@
PyYaml==5.3.1
azure-common==1.1.25
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
docutils==0.16
dyn==1.8.1
@ -14,13 +14,13 @@ ipaddress==1.0.23
jmespath==0.9.5
msrestazure==0.6.3
natsort==6.2.1
ns1-python==0.15.0
ns1-python==0.16.0
ovh==0.5.0
pycountry-convert==0.7.2
pycountry==19.8.18
python-dateutil==2.8.1
requests==2.23.0
s3transfer==0.3.3
setuptools==44.1.0
six==1.14.0
setuptools==44.1.1
six==1.15.0
transip==2.1.2

+ 6
- 0
script/coverage View File

@ -26,6 +26,12 @@ export DYN_PASSWORD=
export DYN_USERNAME=
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 html
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)
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
subs[subdivision.country_code][subdivision.code[3:]] = {
'name': subdivision.name


+ 1
- 1
script/lint View File

@ -17,5 +17,5 @@ fi
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

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

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


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

@ -157,6 +157,23 @@
"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",
"type": "SRV",
@ -212,8 +229,8 @@
"page": 2,
"per_page": 11,
"total_pages": 2,
"count": 9,
"total_count": 21
"count": 10,
"total_count": 20
},
"success": true,
"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.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.zone import Zone
@ -52,7 +53,7 @@ class TestCloudflareProvider(TestCase):
empty = {'result': [], 'result_info': {'count': 0, 'per_page': 0}}
def test_populate(self):
provider = CloudflareProvider('test', 'email', 'token')
provider = CloudflareProvider('test', 'email', 'token', retry_period=0)
# Bad requests
with requests_mock() as mock:
@ -103,6 +104,36 @@ class TestCloudflareProvider(TestCase):
provider.populate(zone)
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
with requests_mock() as mock:
mock.get(ANY, status_code=200, json=self.empty)
@ -149,7 +180,7 @@ class TestCloudflareProvider(TestCase):
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(12, len(zone.records))
self.assertEquals(13, len(zone.records))
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
again = Zone('unit.tests.', [])
provider.populate(again)
self.assertEquals(12, len(again.records))
self.assertEquals(13, len(again.records))
def test_apply(self):
provider = CloudflareProvider('test', 'email', 'token')
provider = CloudflareProvider('test', 'email', 'token', retry_period=0)
provider._request = Mock()
@ -172,12 +203,12 @@ class TestCloudflareProvider(TestCase):
'id': 42,
}
}, # zone create
] + [None] * 20 # individual record creates
] + [None] * 22 # individual record creates
# non-existent zone, create everything
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)
provider._request.assert_has_calls([
@ -203,7 +234,7 @@ class TestCloudflareProvider(TestCase):
}),
], True)
# expected number of total calls
self.assertEquals(22, provider._request.call_count)
self.assertEquals(23, provider._request.call_count)
provider._request.reset_mock()
@ -280,7 +311,11 @@ class TestCloudflareProvider(TestCase):
# we don't care about the POST/create return values
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.add_record(Record.new(wanted, 'nc', {
@ -316,7 +351,7 @@ class TestCloudflareProvider(TestCase):
])
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=[
{
@ -357,6 +392,7 @@ class TestCloudflareProvider(TestCase):
provider._request = Mock()
provider._request.side_effect = [
CloudflareRateLimitError('{}'),
self.empty, # no zones
{
'result': {
@ -423,7 +459,7 @@ class TestCloudflareProvider(TestCase):
def test_update_delete(self):
# We need another run so that we can delete, we can't both add and
# 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=[
{
@ -464,6 +500,7 @@ class TestCloudflareProvider(TestCase):
provider._request = Mock()
provider._request.side_effect = [
CloudflareRateLimitError('{}'),
self.empty, # no zones
{
'result': {
@ -510,6 +547,25 @@ class TestCloudflareProvider(TestCase):
'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):
provider = CloudflareProvider('test', 'email', 'token')
@ -1223,3 +1279,64 @@ class TestCloudflareProvider(TestCase):
provider = CloudflareProvider('test', token='token 123')
headers = provider._sess.headers
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)
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):
provider = Ns1Provider('test', 'api-key')
@ -1059,6 +1066,16 @@ class TestNs1ProviderDynamic(TestCase):
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):
provider = Ns1Provider('test', 'api-key')


+ 99
- 26
tests/test_octodns_provider_powerdns.py View File

@ -41,11 +41,94 @@ with open('./tests/fixtures/powerdns-full-data.json') as fh:
class TestPowerDnsProvider(TestCase):
def test_provider_version_detection(self):
provider = PowerDnsProvider('test', 'non.existent', 'api-key',
nameserver_values=['8.8.8.8.',
'9.9.9.9.'])
# Bad auth
with requests_mock() as mock:
mock.get(ANY, status_code=401, text='Unauthorized')
with self.assertRaises(Exception) as ctx:
provider.detect_version()
self.assertTrue('unauthorized' in text_type(ctx.exception))
# Api not found
with requests_mock() as mock:
mock.get(ANY, status_code=404, text='Not Found')
with self.assertRaises(Exception) as ctx:
provider.detect_version()
self.assertTrue('404' in text_type(ctx.exception))
# Test version detection
with requests_mock() as mock:
mock.get('http://non.existent:8081/api/v1/servers/localhost',
status_code=200, json={'version': "4.1.10"})
provider.detect_version()
self.assertEquals(provider.version_detected, '4.1.10')
# Test version detection for second time (should stay at 4.1.10)
with requests_mock() as mock:
mock.get('http://non.existent:8081/api/v1/servers/localhost',
status_code=200, json={'version': "4.2.0"})
provider.detect_version()
self.assertEquals(provider.version_detected, '4.1.10')
# Test version detection
with requests_mock() as mock:
mock.get('http://non.existent:8081/api/v1/servers/localhost',
status_code=200, json={'version': "4.2.0"})
# Reset version, so detection will try again
provider.version_detected = ''
provider.detect_version()
self.assertNotEquals(provider.version_detected, '4.1.10')
def test_provider_version_config(self):
provider = PowerDnsProvider('test', 'non.existent', 'api-key',
nameserver_values=['8.8.8.8.',
'9.9.9.9.'])
provider.check_status_not_found = None
provider.soa_edit_api = 'something else'
# Test version 4.1.0
provider.configure_for_version("4.1.0")
self.assertEquals(provider.soa_edit_api, 'INCEPTION-INCREMENT')
self.assertFalse(
provider.check_status_not_found,
'check_status_not_found should be false '
'for version 4.1.x and below')
# Test version 4.2.0
provider.configure_for_version("4.2.0")
self.assertEquals(provider.soa_edit_api, 'INCEPTION-INCREMENT')
self.assertTrue(
provider.check_status_not_found,
'check_status_not_found should be true for version 4.2.x')
# Test version 4.3.0
provider.configure_for_version("4.3.0")
self.assertEquals(provider.soa_edit_api, 'DEFAULT')
self.assertTrue(
provider.check_status_not_found,
'check_status_not_found should be true for version 4.3.x')
def test_provider(self):
provider = PowerDnsProvider('test', 'non.existent', 'api-key',
nameserver_values=['8.8.8.8.',
'9.9.9.9.'])
# Test version detection
with requests_mock() as mock:
mock.get('http://non.existent:8081/api/v1/servers/localhost',
status_code=200, json={'version': "4.1.10"})
provider.detect_version()
self.assertEquals(provider.version_detected, '4.1.10')
# Bad auth
with requests_mock() as mock:
mock.get(ANY, status_code=401, text='Unauthorized')
@ -68,18 +151,19 @@ class TestPowerDnsProvider(TestCase):
with requests_mock() as mock:
mock.get(ANY, status_code=422,
json={'error': "Could not find domain 'unit.tests.'"})
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(set(), zone.records)
# Non-existent zone in PowerDNS >=4.3.0 doesn't populate anything
# Non-existent zone in PowerDNS >=4.2.0 doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404, text='Not Found')
provider.configure_for_version("4.2.0")
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(set(), zone.records)
provider.configure_for_version("4.1.0")
# The rest of this is messy/complicated b/c it's dealing with mocking
@ -124,7 +208,7 @@ class TestPowerDnsProvider(TestCase):
not_found = {'error': "Could not find domain 'unit.tests.'"}
with requests_mock() as mock:
# get 422's, unknown zone
mock.get(ANY, status_code=422, text='')
mock.get(ANY, status_code=422, text=dumps(not_found))
# patch 422's, unknown zone
mock.patch(ANY, status_code=422, text=dumps(not_found))
# post 201, is response to the create with data
@ -136,6 +220,7 @@ class TestPowerDnsProvider(TestCase):
self.assertFalse(plan.exists)
with requests_mock() as mock:
provider.configure_for_version('4.2.0')
# get 404's, unknown zone
mock.get(ANY, status_code=404, text='')
# patch 404's, unknown zone
@ -147,10 +232,11 @@ class TestPowerDnsProvider(TestCase):
self.assertEquals(expected_n, len(plan.changes))
self.assertEquals(expected_n, provider.apply(plan))
self.assertFalse(plan.exists)
provider.configure_for_version('4.1.0')
with requests_mock() as mock:
# get 422's, unknown zone
mock.get(ANY, status_code=422, text='')
mock.get(ANY, status_code=422, text=dumps(not_found))
# patch 422's,
data = {'error': "Key 'name' not present or not a String"}
mock.patch(ANY, status_code=422, text=dumps(data))
@ -164,7 +250,7 @@ class TestPowerDnsProvider(TestCase):
with requests_mock() as mock:
# get 422's, unknown zone
mock.get(ANY, status_code=422, text='')
mock.get(ANY, status_code=422, text=dumps(not_found))
# patch 500's, things just blew up
mock.patch(ANY, status_code=500, text='')
@ -174,7 +260,7 @@ class TestPowerDnsProvider(TestCase):
with requests_mock() as mock:
# get 422's, unknown zone
mock.get(ANY, status_code=422, text='')
mock.get(ANY, status_code=422, text=dumps(not_found))
# patch 500's, things just blew up
mock.patch(ANY, status_code=422, text=dumps(not_found))
# post 422's, something wrong with create
@ -195,6 +281,8 @@ class TestPowerDnsProvider(TestCase):
# A small change to a single record
with requests_mock() as mock:
mock.get(ANY, status_code=200, text=FULL_TEXT)
mock.get('http://non.existent:8081/api/v1/servers/localhost',
status_code=200, json={'version': '4.1.0'})
missing = Zone(expected.name, [])
# Find and delete the SPF record
@ -266,6 +354,8 @@ class TestPowerDnsProvider(TestCase):
}]
}
mock.get(ANY, status_code=200, json=data)
mock.get('http://non.existent:8081/api/v1/servers/localhost',
status_code=200, json={'version': '4.1.0'})
unrelated_record = Record.new(expected, '', {
'type': 'A',
@ -299,6 +389,8 @@ class TestPowerDnsProvider(TestCase):
}]
}
mock.get(ANY, status_code=200, json=data)
mock.get('http://non.existent:8081/api/v1/servers/localhost',
status_code=200, json={'version': '4.1.0'})
plan = provider.plan(expected)
self.assertEquals(1, len(plan.changes))
@ -312,22 +404,3 @@ class TestPowerDnsProvider(TestCase):
plan = provider.plan(expected)
self.assertEquals(1, len(plan.changes))
def test_soa_edit_api(self):
provider = PowerDnsProvider('test', 'non.existent', 'api-key',
soa_edit_api='DEFAULT')
def assert_soa_edit_api_callback(request, context):
data = loads(request.body)
self.assertEquals('DEFAULT', data['soa_edit_api'])
return ''
with requests_mock() as mock:
mock.get(ANY, status_code=404)
mock.patch(ANY, status_code=404)
mock.post(ANY, status_code=204, text=assert_soa_edit_api_callback)
zone = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(zone)
plan = provider.plan(zone)
provider.apply(plan)

+ 29
- 0
tests/test_octodns_provider_route53.py View File

@ -1213,6 +1213,35 @@ class TestRoute53Provider(TestCase):
self.assertEquals('42', id)
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):
provider, stubber = self._get_stubbed_provider()
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(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):
new = Record.new(self.zone, 'txt', {
'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):
self.assertEquals('NA-US-OR', GeoCodes.province_to_code('OR'))
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'))

Loading…
Cancel
Save