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


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

@ -19,13 +19,15 @@ class PowerDnsBaseProvider(BaseProvider):
'PTR', 'SPF', 'SSHFP', 'SRV', 'TXT')) 'PTR', 'SPF', 'SSHFP', 'SRV', 'TXT'))
TIMEOUT = 5 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): scheme="http", timeout=TIMEOUT, *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.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.scheme = scheme
self.timeout = timeout self.timeout = timeout
@ -37,7 +39,8 @@ class PowerDnsBaseProvider(BaseProvider):
self.log.debug('_request: method=%s, path=%s', method, path) self.log.debug('_request: method=%s, path=%s', method, path)
url = '{}://{}:{}/api/v1/servers/localhost/{}' \ 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) 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()
@ -166,20 +169,75 @@ class PowerDnsBaseProvider(BaseProvider):
'ttl': rrset['ttl'] '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): def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient) target, lenient)
self.detect_version()
resp = None resp = None
try: try:
resp = self._get('zones/{}'.format(zone.name)) resp = self._get('zones/{}'.format(zone.name))
self.log.debug('populate: loaded') self.log.debug('populate: loaded')
except HTTPError as e: except HTTPError as e:
error = self._get_error(e)
if e.response.status_code == 401: if e.response.status_code == 401:
# Nicer error message for auth problems # Nicer error message for auth problems
raise Exception('PowerDNS unauthorized host={}' raise Exception('PowerDNS unauthorized host={}'
.format(self.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 # 404 or 422 means powerdns doesn't know anything about the
# requested domain. We'll just ignore it here and leave the # requested domain. We'll just ignore it here and leave the
# zone untouched. # zone untouched.
@ -339,13 +397,22 @@ class PowerDnsBaseProvider(BaseProvider):
self.log.debug('_apply: patched') self.log.debug('_apply: patched')
except HTTPError as e: except HTTPError as e:
error = self._get_error(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 raise
self.log.info('_apply: creating zone=%s', desired.name) self.log.info('_apply: creating zone=%s', desired.name)
# 404 or 422 means powerdns doesn't know anything about the # 404 or 422 means powerdns doesn't know anything about the
# requested domain. We'll try to create it with the correct # 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, 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): *args, **kwargs):
self.log = logging.getLogger('PowerDnsProvider[{}]'.format(id)) self.log = logging.getLogger('PowerDnsProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, host=%s, port=%d, ' 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, super(PowerDnsProvider, self).__init__(id, host=host, api_key=api_key,
port=port, port=port,
soa_edit_api=soa_edit_api,
*args, **kwargs) *args, **kwargs)
self.nameserver_values = nameserver_values 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 # 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')


+ 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): 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): def test_provider(self):
provider = PowerDnsProvider('test', 'non.existent', 'api-key', provider = PowerDnsProvider('test', 'non.existent', 'api-key',
nameserver_values=['8.8.8.8.', nameserver_values=['8.8.8.8.',
'9.9.9.9.']) '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 # Bad auth
with requests_mock() as mock: with requests_mock() as mock:
mock.get(ANY, status_code=401, text='Unauthorized') mock.get(ANY, status_code=401, text='Unauthorized')
@ -68,18 +151,19 @@ class TestPowerDnsProvider(TestCase):
with requests_mock() as mock: with requests_mock() as mock:
mock.get(ANY, status_code=422, mock.get(ANY, status_code=422,
json={'error': "Could not find domain 'unit.tests.'"}) json={'error': "Could not find domain 'unit.tests.'"})
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(set(), zone.records) 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: with requests_mock() as mock:
mock.get(ANY, status_code=404, text='Not Found') mock.get(ANY, status_code=404, text='Not Found')
provider.configure_for_version("4.2.0")
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
provider.populate(zone) provider.populate(zone)
self.assertEquals(set(), zone.records) 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 # 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.'"} not_found = {'error': "Could not find domain 'unit.tests.'"}
with requests_mock() as mock: with requests_mock() as mock:
# get 422's, unknown zone # 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 # patch 422's, unknown zone
mock.patch(ANY, status_code=422, text=dumps(not_found)) mock.patch(ANY, status_code=422, text=dumps(not_found))
# post 201, is response to the create with data # post 201, is response to the create with data
@ -136,6 +220,7 @@ class TestPowerDnsProvider(TestCase):
self.assertFalse(plan.exists) self.assertFalse(plan.exists)
with requests_mock() as mock: with requests_mock() as mock:
provider.configure_for_version('4.2.0')
# get 404's, unknown zone # get 404's, unknown zone
mock.get(ANY, status_code=404, text='') mock.get(ANY, status_code=404, text='')
# patch 404's, unknown zone # patch 404's, unknown zone
@ -147,10 +232,11 @@ class TestPowerDnsProvider(TestCase):
self.assertEquals(expected_n, len(plan.changes)) self.assertEquals(expected_n, len(plan.changes))
self.assertEquals(expected_n, provider.apply(plan)) self.assertEquals(expected_n, provider.apply(plan))
self.assertFalse(plan.exists) self.assertFalse(plan.exists)
provider.configure_for_version('4.1.0')
with requests_mock() as mock: with requests_mock() as mock:
# get 422's, unknown zone # 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, # patch 422's,
data = {'error': "Key 'name' not present or not a String"} data = {'error': "Key 'name' not present or not a String"}
mock.patch(ANY, status_code=422, text=dumps(data)) mock.patch(ANY, status_code=422, text=dumps(data))
@ -164,7 +250,7 @@ class TestPowerDnsProvider(TestCase):
with requests_mock() as mock: with requests_mock() as mock:
# get 422's, unknown zone # 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 # patch 500's, things just blew up
mock.patch(ANY, status_code=500, text='') mock.patch(ANY, status_code=500, text='')
@ -174,7 +260,7 @@ class TestPowerDnsProvider(TestCase):
with requests_mock() as mock: with requests_mock() as mock:
# get 422's, unknown zone # 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 # patch 500's, things just blew up
mock.patch(ANY, status_code=422, text=dumps(not_found)) mock.patch(ANY, status_code=422, text=dumps(not_found))
# post 422's, something wrong with create # post 422's, something wrong with create
@ -195,6 +281,8 @@ class TestPowerDnsProvider(TestCase):
# A small change to a single record # A small change to a single record
with requests_mock() as mock: with requests_mock() as mock:
mock.get(ANY, status_code=200, text=FULL_TEXT) 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, []) missing = Zone(expected.name, [])
# Find and delete the SPF record # Find and delete the SPF record
@ -266,6 +354,8 @@ class TestPowerDnsProvider(TestCase):
}] }]
} }
mock.get(ANY, status_code=200, json=data) 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, '', { unrelated_record = Record.new(expected, '', {
'type': 'A', 'type': 'A',
@ -299,6 +389,8 @@ class TestPowerDnsProvider(TestCase):
}] }]
} }
mock.get(ANY, status_code=200, json=data) 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) plan = provider.plan(expected)
self.assertEquals(1, len(plan.changes)) self.assertEquals(1, len(plan.changes))
@ -312,22 +404,3 @@ class TestPowerDnsProvider(TestCase):
plan = provider.plan(expected) plan = provider.plan(expected)
self.assertEquals(1, len(plan.changes)) 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) 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