Browse Source

Merge remote-tracking branch 'origin/master' into python3-start

pull/384/head
Ross McFarland 6 years ago
parent
commit
f252aa3b98
No known key found for this signature in database GPG Key ID: 61C10C4FC8FE4A89
31 changed files with 3918 additions and 36 deletions
  1. +4
    -0
      .gitignore
  2. +13
    -1
      CHANGELOG.md
  3. +6
    -2
      README.md
  4. +1
    -1
      octodns/__init__.py
  5. +447
    -0
      octodns/provider/constellix.py
  6. +525
    -0
      octodns/provider/fastdns.py
  7. +11
    -11
      octodns/provider/route53.py
  8. +305
    -0
      octodns/provider/selectel.py
  9. +353
    -0
      octodns/provider/transip.py
  10. +1
    -0
      requirements-dev.txt
  11. +3
    -1
      requirements.txt
  12. +1
    -0
      script/release
  13. +35
    -1
      setup.py
  14. +28
    -0
      tests/fixtures/constellix-domains.json
  15. +598
    -0
      tests/fixtures/constellix-records.json
  16. +35
    -0
      tests/fixtures/fastdns-invalid-content.json
  17. +166
    -0
      tests/fixtures/fastdns-records-prev-other.json
  18. +166
    -0
      tests/fixtures/fastdns-records-prev.json
  19. +157
    -0
      tests/fixtures/fastdns-records.json
  20. +3
    -3
      tests/test_octodns_provider_cloudflare.py
  21. +218
    -0
      tests/test_octodns_provider_constellix.py
  22. +2
    -2
      tests/test_octodns_provider_digitalocean.py
  23. +2
    -2
      tests/test_octodns_provider_dnsimple.py
  24. +2
    -2
      tests/test_octodns_provider_dnsmadeeasy.py
  25. +150
    -0
      tests/test_octodns_provider_fastdns.py
  26. +2
    -2
      tests/test_octodns_provider_ns1.py
  27. +4
    -4
      tests/test_octodns_provider_powerdns.py
  28. +1
    -1
      tests/test_octodns_provider_route53.py
  29. +401
    -0
      tests/test_octodns_provider_selectel.py
  30. +275
    -0
      tests/test_octodns_provider_transip.py
  31. +3
    -3
      tests/test_octodns_record.py

+ 4
- 0
.gitignore View File

@ -1,3 +1,7 @@
#
# Do not add editor or OS specific ignores here. Have a look at adding
# `excludesfile` to your `~/.gitconfig` to globally ignore such things.
#
*.pyc
.coverage
.env


+ 13
- 1
CHANGELOG.md View File

@ -1,3 +1,15 @@
## v0.9.8 - 2019-09-30 - One with no changes b/c PyPi description problems
* No material changes
## v0.9.7 - 2019-09-30 - It's about time
* AkamaiProvider, ConstellixProvider, MythicBeastsProvider, SelectelProvider,
& TransipPovider providers added
* Route53Provider seperator fix
* YamlProvider export error around stringification
* PyPi markdown rendering fix
## v0.9.6 - 2019-07-16 - The little one that fixes stuff from the big one
* Reduced dynamic record value weight range to 0-15 so that Dyn and Route53
@ -112,7 +124,7 @@ Adds an OVH provider.
## v0.8.6 - 2017-09-06 - CAA record type,
Misc fixes and improvments.
Misc fixes and improvements.
* Azure TXT record fix
* PowerDNS api support for https


+ 6
- 2
README.md View File

@ -90,8 +90,8 @@ Now that we have something to tell OctoDNS about our providers & zones we need t
ttl: 60
type: A
values:
- 1.2.3.4
- 1.2.3.5
- 1.2.3.4
- 1.2.3.5
```
Further information can be found in [Records Documentation](/docs/records.md).
@ -172,7 +172,9 @@ The above command pulled the existing data out of Route53 and placed the results
| Provider | Requirements | Record Support | Dynamic/Geo Support | Notes |
|--|--|--|--|--|
| [AzureProvider](/octodns/provider/azuredns.py) | azure-mgmt-dns | A, AAAA, CAA, CNAME, MX, NS, PTR, SRV, TXT | No | |
| [Akamai](/octodns/provider/fastdns.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 |
| [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 |
| [DnsimpleProvider](/octodns/provider/dnsimple.py) | | All | No | CAA tags restricted |
@ -185,6 +187,8 @@ The above command pulled the existing data out of Route53 and placed the results
| [PowerDnsProvider](/octodns/provider/powerdns.py) | | All | No | |
| [Rackspace](/octodns/provider/rackspace.py) | | A, AAAA, ALIAS, CNAME, MX, NS, PTR, SPF, TXT | No | |
| [Route53](/octodns/provider/route53.py) | boto3 | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | Both | CNAME health checks don't support a Host header |
| [Selectel](/octodns/provider/selectel.py) | | A, AAAA, CNAME, MX, NS, SPF, SRV, TXT | No | |
| [Transip](/octodns/provider/transip.py) | transip | A, AAAA, CNAME, MX, SRV, SPF, TXT, SSHFP, CAA | No | |
| [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [ZoneFileSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [TinyDnsFileSource](/octodns/source/tinydns.py) | | A, CNAME, MX, NS, PTR | No | read-only |


+ 1
- 1
octodns/__init__.py View File

@ -3,4 +3,4 @@
from __future__ import absolute_import, division, print_function, \
unicode_literals
__VERSION__ = '0.9.6'
__VERSION__ = '0.9.8'

+ 447
- 0
octodns/provider/constellix.py View File

@ -0,0 +1,447 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from collections import defaultdict
from requests import Session
from base64 import b64encode
from ipaddress import ip_address
import hashlib
import hmac
import logging
import time
from ..record import Record
from .base import BaseProvider
class ConstellixClientException(Exception):
pass
class ConstellixClientBadRequest(ConstellixClientException):
def __init__(self, resp):
errors = resp.json()['errors']
super(ConstellixClientBadRequest, self).__init__(
'\n - {}'.format('\n - '.join(errors)))
class ConstellixClientUnauthorized(ConstellixClientException):
def __init__(self):
super(ConstellixClientUnauthorized, self).__init__('Unauthorized')
class ConstellixClientNotFound(ConstellixClientException):
def __init__(self):
super(ConstellixClientNotFound, self).__init__('Not Found')
class ConstellixClient(object):
BASE = 'https://api.dns.constellix.com/v1/domains'
def __init__(self, api_key, secret_key, ratelimit_delay=0.0):
self.api_key = api_key
self.secret_key = secret_key
self.ratelimit_delay = ratelimit_delay
self._sess = Session()
self._sess.headers.update({'x-cnsdns-apiKey': self.api_key})
self._domains = None
def _current_time(self):
return str(int(time.time() * 1000))
def _hmac_hash(self, now):
return hmac.new(self.secret_key.encode('utf-8'), now.encode('utf-8'),
digestmod=hashlib.sha1).digest()
def _request(self, method, path, params=None, data=None):
now = self._current_time()
hmac_hash = self._hmac_hash(now)
headers = {
'x-cnsdns-hmac': b64encode(hmac_hash),
'x-cnsdns-requestDate': now
}
url = '{}{}'.format(self.BASE, path)
resp = self._sess.request(method, url, headers=headers,
params=params, json=data)
if resp.status_code == 400:
raise ConstellixClientBadRequest(resp)
if resp.status_code == 401:
raise ConstellixClientUnauthorized()
if resp.status_code == 404:
raise ConstellixClientNotFound()
resp.raise_for_status()
time.sleep(self.ratelimit_delay)
return resp
@property
def domains(self):
if self._domains is None:
zones = []
resp = self._request('GET', '/').json()
zones += resp
self._domains = {'{}.'.format(z['name']): z['id'] for z in zones}
return self._domains
def domain(self, name):
path = '/{}'.format(self.domains.get(name))
return self._request('GET', path).json()
def domain_create(self, name):
self._request('POST', '/', data={'names': [name]})
def _absolutize_value(self, value, zone_name):
if value == '':
value = zone_name
elif not value.endswith('.'):
value = '{}.{}'.format(value, zone_name)
return value
def records(self, zone_name):
zone_id = self.domains.get(zone_name, False)
path = '/{}/records'.format(zone_id)
resp = self._request('GET', path).json()
for record in resp:
# change ANAME records to ALIAS
if record['type'] == 'ANAME':
record['type'] = 'ALIAS'
# change relative values to absolute
value = record['value']
if record['type'] in ['ALIAS', 'CNAME', 'MX', 'NS', 'SRV']:
if isinstance(value, unicode):
record['value'] = self._absolutize_value(value,
zone_name)
if isinstance(value, list):
for v in value:
v['value'] = self._absolutize_value(v['value'],
zone_name)
# compress IPv6 addresses
if record['type'] == 'AAAA':
for i, v in enumerate(value):
value[i] = str(ip_address(v))
return resp
def record_create(self, zone_name, record_type, params):
# change ALIAS records to ANAME
if record_type == 'ALIAS':
record_type = 'ANAME'
zone_id = self.domains.get(zone_name, False)
path = '/{}/records/{}'.format(zone_id, record_type)
self._request('POST', path, data=params)
def record_delete(self, zone_name, record_type, record_id):
zone_id = self.domains.get(zone_name, False)
path = '/{}/records/{}/{}'.format(zone_id, record_type, record_id)
self._request('DELETE', path)
class ConstellixProvider(BaseProvider):
'''
Constellix DNS provider
constellix:
class: octodns.provider.constellix.ConstellixProvider
# Your Contellix api key (required)
api_key: env/CONSTELLIX_API_KEY
# Your Constellix secret key (required)
secret_key: env/CONSTELLIX_SECRET_KEY
# Amount of time to wait between requests to avoid
# ratelimit (optional)
ratelimit_delay: 0.0
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX',
'NS', 'PTR', 'SPF', 'SRV', 'TXT'))
def __init__(self, id, api_key, secret_key, ratelimit_delay=0.0,
*args, **kwargs):
self.log = logging.getLogger('ConstellixProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, api_key=***, secret_key=***', id)
super(ConstellixProvider, self).__init__(id, *args, **kwargs)
self._client = ConstellixClient(api_key, secret_key, ratelimit_delay)
self._zone_records = {}
def _data_for_multiple(self, _type, records):
record = records[0]
return {
'ttl': record['ttl'],
'type': _type,
'values': record['value']
}
_data_for_A = _data_for_multiple
_data_for_AAAA = _data_for_multiple
def _data_for_CAA(self, _type, records):
values = []
record = records[0]
for value in record['value']:
values.append({
'flags': value['flag'],
'tag': value['tag'],
'value': value['data']
})
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
def _data_for_NS(self, _type, records):
record = records[0]
return {
'ttl': record['ttl'],
'type': _type,
'values': [value['value'] for value in record['value']]
}
def _data_for_ALIAS(self, _type, records):
record = records[0]
return {
'ttl': record['ttl'],
'type': _type,
'value': record['value'][0]['value']
}
_data_for_PTR = _data_for_ALIAS
def _data_for_TXT(self, _type, records):
values = [value['value'].replace(';', '\\;')
for value in records[0]['value']]
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
_data_for_SPF = _data_for_TXT
def _data_for_MX(self, _type, records):
values = []
record = records[0]
for value in record['value']:
values.append({
'preference': value['level'],
'exchange': value['value']
})
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values
}
def _data_for_single(self, _type, records):
record = records[0]
return {
'ttl': record['ttl'],
'type': _type,
'value': record['value']
}
_data_for_CNAME = _data_for_single
def _data_for_SRV(self, _type, records):
values = []
record = records[0]
for value in record['value']:
values.append({
'port': value['port'],
'priority': value['priority'],
'target': value['value'],
'weight': value['weight']
})
return {
'type': _type,
'ttl': records[0]['ttl'],
'values': values
}
def zone_records(self, zone):
if zone.name not in self._zone_records:
try:
self._zone_records[zone.name] = \
self._client.records(zone.name)
except ConstellixClientNotFound:
return []
return self._zone_records[zone.name]
def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient)
values = defaultdict(lambda: defaultdict(list))
for record in self.zone_records(zone):
_type = record['type']
if _type not in self.SUPPORTS:
self.log.warning('populate: skipping unsupported %s record',
_type)
continue
values[record['name']][record['type']].append(record)
before = len(zone.records)
for name, types in values.items():
for _type, records in types.items():
data_for = getattr(self, '_data_for_{}'.format(_type))
record = Record.new(zone, name, data_for(_type, records),
source=self, lenient=lenient)
zone.add_record(record, lenient=lenient)
exists = zone.name in self._zone_records
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _params_for_multiple(self, record):
yield {
'name': record.name,
'ttl': record.ttl,
'roundRobin': [{
'value': value
} for value in record.values]
}
_params_for_A = _params_for_multiple
_params_for_AAAA = _params_for_multiple
# An A record with this name must exist in this domain for
# this NS record to be valid. Need to handle checking if
# there is an A record before creating NS
_params_for_NS = _params_for_multiple
def _params_for_single(self, record):
yield {
'name': record.name,
'ttl': record.ttl,
'host': record.value,
}
_params_for_CNAME = _params_for_single
def _params_for_ALIAS(self, record):
yield {
'name': record.name,
'ttl': record.ttl,
'roundRobin': [{
'value': record.value,
'disableFlag': False
}]
}
_params_for_PTR = _params_for_ALIAS
def _params_for_MX(self, record):
values = []
for value in record.values:
values.append({
'value': value.exchange,
'level': value.preference
})
yield {
'value': value.exchange,
'name': record.name,
'ttl': record.ttl,
'roundRobin': values
}
def _params_for_SRV(self, record):
values = []
for value in record.values:
values.append({
'value': value.target,
'priority': value.priority,
'weight': value.weight,
'port': value.port
})
for value in record.values:
yield {
'name': record.name,
'ttl': record.ttl,
'roundRobin': values
}
def _params_for_TXT(self, record):
# Constellix does not want values escaped
values = []
for value in record.chunked_values:
values.append({
'value': value.replace('\\;', ';')
})
yield {
'name': record.name,
'ttl': record.ttl,
'roundRobin': values
}
_params_for_SPF = _params_for_TXT
def _params_for_CAA(self, record):
values = []
for value in record.values:
values.append({
'tag': value.tag,
'data': value.value,
'flag': value.flags,
})
yield {
'name': record.name,
'ttl': record.ttl,
'roundRobin': values
}
def _apply_Create(self, change):
new = change.new
params_for = getattr(self, '_params_for_{}'.format(new._type))
for params in params_for(new):
self._client.record_create(new.zone.name, new._type, params)
def _apply_Update(self, change):
self._apply_Delete(change)
self._apply_Create(change)
def _apply_Delete(self, change):
existing = change.existing
zone = existing.zone
for record in self.zone_records(zone):
if existing.name == record['name'] and \
existing._type == record['type']:
self._client.record_delete(zone.name, record['type'],
record['id'])
def _apply(self, plan):
desired = plan.desired
changes = plan.changes
self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
len(changes))
try:
self._client.domain(desired.name)
except ConstellixClientNotFound:
self.log.debug('_apply: no matching zone, creating domain')
self._client.domain_create(desired.name[:-1])
for change in changes:
class_name = change.__class__.__name__
getattr(self, '_apply_{}'.format(class_name))(change)
# Clear out the cache if any
self._zone_records.pop(desired.name, None)

+ 525
- 0
octodns/provider/fastdns.py View File

@ -0,0 +1,525 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from requests import Session
from akamai.edgegrid import EdgeGridAuth
from urlparse import urljoin
from collections import defaultdict
from logging import getLogger
from ..record import Record
from .base import BaseProvider
class AkamaiClientNotFound(Exception):
def __init__(self, resp):
message = "404: Resource not found"
super(AkamaiClientNotFound, self).__init__(message)
class AkamaiClient(object):
'''
Client for making calls to Akamai Fast DNS API using Python Requests
Fast DNS Zone Management API V2, found here:
developer.akamai.com/api/web_performance/fast_dns_zone_management/v2.html
Info on Python Requests library:
https://2.python-requests.org/en/master/
'''
def __init__(self, client_secret, host, access_token, client_token):
self.base = "https://" + host + "/config-dns/v2/"
sess = Session()
sess.auth = EdgeGridAuth(
client_token=client_token,
client_secret=client_secret,
access_token=access_token
)
self._sess = sess
def _request(self, method, path, params=None, data=None, v1=False):
url = urljoin(self.base, path)
resp = self._sess.request(method, url, params=params, json=data)
if resp.status_code == 404:
raise AkamaiClientNotFound(resp)
resp.raise_for_status()
return resp
def record_create(self, zone, name, record_type, content):
path = 'zones/{}/names/{}/types/{}'.format(zone, name, record_type)
result = self._request('POST', path, data=content)
return result
def record_delete(self, zone, name, record_type):
path = 'zones/{}/names/{}/types/{}'.format(zone, name, record_type)
result = self._request('DELETE', path)
return result
def record_replace(self, zone, name, record_type, content):
path = 'zones/{}/names/{}/types/{}'.format(zone, name, record_type)
result = self._request('PUT', path, data=content)
return result
def zone_get(self, zone):
path = 'zones/{}'.format(zone)
result = self._request('GET', path)
return result
def zone_create(self, contractId, params, gid=None):
path = 'zones?contractId={}'.format(contractId)
if gid is not None:
path += '&gid={}'.format(gid)
result = self._request('POST', path, data=params)
return result
def zone_recordset_get(self, zone, page=None, pageSize=None, search=None,
showAll="true", sortBy="name", types=None):
params = {
'page': page,
'pageSize': pageSize,
'search': search,
'showAll': showAll,
'sortBy': sortBy,
'types': types
}
path = 'zones/{}/recordsets'.format(zone)
result = self._request('GET', path, params=params)
return result
class AkamaiProvider(BaseProvider):
'''
Akamai Fast DNS Provider
fastdns.py:
Example config file with variables:
"
---
providers:
config:
class: octodns.provider.yaml.YamlProvider
directory: ./config (example path to directory of zone files)
fastdns:
class: octodns.provider.fastdns.AkamaiProvider
client_secret: env/AKAMAI_CLIENT_SECRET
host: env/AKAMAI_HOST
access_token: env/AKAMAI_ACCESS_TOKEN
client_token: env/AKAMAI_CLIENT_TOKEN
contract_id: env/AKAMAI_CONTRACT_ID (optional)
zones:
example.com.:
sources:
- config
targets:
- fastdns
"
The first four variables above can be hidden in environment variables
and octoDNS will automatically search for them in the shell. It is
possible to also hard-code into the config file: eg, contract_id.
The first four values can be found by generating credentials:
https://control.akamai.com/
Configure > Organization > Manage APIs > New API Client for me
Select appropriate group, and fill relevant fields.
For API Service Name, select DNS-Zone Record Management
and then set appropriate Access level (Read-Write to make changes).
Then select the "New Credential" button to generate values for above
The contract_id paramater is optional, and only required for creating
a new zone. If the zone being managed already exists in Akamai for the
user in question, then this paramater is not needed.
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NAPTR', 'NS', 'PTR', 'SPF',
'SRV', 'SSHFP', 'TXT'))
def __init__(self, id, client_secret, host, access_token, client_token,
contract_id=None, gid=None, *args, **kwargs):
self.log = getLogger('AkamaiProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, ')
super(AkamaiProvider, self).__init__(id, *args, **kwargs)
self._dns_client = AkamaiClient(client_secret, host, access_token,
client_token)
self._zone_records = {}
self._contractId = contract_id
self._gid = gid
def zone_records(self, zone):
""" returns records for a zone, looks for it if not present, or
returns empty [] if can't find a match
"""
if zone.name not in self._zone_records:
try:
name = zone.name[:-1]
response = self._dns_client.zone_recordset_get(name)
self._zone_records[zone.name] = response.json()["recordsets"]
except (AkamaiClientNotFound, KeyError):
return []
return self._zone_records[zone.name]
def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s', zone.name)
values = defaultdict(lambda: defaultdict(list))
for record in self.zone_records(zone):
_type = record.get('type')
# Akamai sends down prefix.zonename., while octodns expects prefix
_name = record.get('name').split("." + zone.name[:-1], 1)[0]
if _name == zone.name[:-1]:
_name = '' # root / @
if _type not in self.SUPPORTS:
continue
values[_name][_type].append(record)
before = len(zone.records)
for name, types in values.items():
for _type, records in types.items():
data_for = getattr(self, '_data_for_{}'.format(_type))
record = Record.new(zone, name, data_for(_type, records[0]),
source=self, lenient=lenient)
zone.add_record(record, lenient=lenient)
exists = zone.name in self._zone_records
found = len(zone.records) - before
self.log.info('populate: found %s records, exists=%s', found, exists)
return exists
def _apply(self, plan):
desired = plan.desired
changes = plan.changes
self.log.debug('apply: zone=%s, chnges=%d', desired.name, len(changes))
zone_name = desired.name[:-1]
try:
self._dns_client.zone_get(zone_name)
except AkamaiClientNotFound:
self.log.info("zone not found, creating zone")
params = self._build_zone_config(zone_name)
self._dns_client.zone_create(self._contractId, params, self._gid)
for change in changes:
class_name = change.__class__.__name__
getattr(self, '_apply_{}'.format(class_name))(change)
# Clear out the cache if any
self._zone_records.pop(desired.name, None)
def _apply_Create(self, change):
new = change.new
record_type = new._type
params_for = getattr(self, '_params_for_{}'.format(record_type))
values = self._get_values(new.data)
rdata = params_for(values)
zone = new.zone.name[:-1]
name = self._set_full_name(new.name, zone)
content = {
"name": name,
"type": record_type,
"ttl": new.ttl,
"rdata": rdata
}
self._dns_client.record_create(zone, name, record_type, content)
return
def _apply_Delete(self, change):
zone = change.existing.zone.name[:-1]
name = self._set_full_name(change.existing.name, zone)
record_type = change.existing._type
self._dns_client.record_delete(zone, name, record_type)
return
def _apply_Update(self, change):
new = change.new
record_type = new._type
params_for = getattr(self, '_params_for_{}'.format(record_type))
values = self._get_values(new.data)
rdata = params_for(values)
zone = new.zone.name[:-1]
name = self._set_full_name(new.name, zone)
content = {
"name": name,
"type": record_type,
"ttl": new.ttl,
"rdata": rdata
}
self._dns_client.record_replace(zone, name, record_type, content)
return
def _data_for_multiple(self, _type, records):
return {
'ttl': records['ttl'],
'type': _type,
'values': [r for r in records['rdata']]
}
_data_for_A = _data_for_multiple
_data_for_AAAA = _data_for_multiple
_data_for_NS = _data_for_multiple
_data_for_SPF = _data_for_multiple
def _data_for_CNAME(self, _type, records):
value = records['rdata'][0]
if (value[-1] != '.'):
value = '{}.'.format(value)
return {
'ttl': records['ttl'],
'type': _type,
'value': value
}
def _data_for_MX(self, _type, records):
values = []
for r in records['rdata']:
preference, exchange = r.split(" ", 1)
values.append({
'preference': preference,
'exchange': exchange
})
return {
'ttl': records['ttl'],
'type': _type,
'values': values
}
def _data_for_NAPTR(self, _type, records):
values = []
for r in records['rdata']:
order, preference, flags, service, regexp, repl = r.split(' ', 5)
values.append({
'flags': flags[1:-1],
'order': order,
'preference': preference,
'regexp': regexp[1:-1],
'replacement': repl,
'service': service[1:-1]
})
return {
'type': _type,
'ttl': records['ttl'],
'values': values
}
def _data_for_PTR(self, _type, records):
return {
'ttl': records['ttl'],
'type': _type,
'value': records['rdata'][0]
}
def _data_for_SRV(self, _type, records):
values = []
for r in records['rdata']:
priority, weight, port, target = r.split(' ', 3)
values.append({
'port': port,
'priority': priority,
'target': target,
'weight': weight
})
return {
'type': _type,
'ttl': records['ttl'],
'values': values
}
def _data_for_SSHFP(self, _type, records):
values = []
for r in records['rdata']:
algorithm, fp_type, fingerprint = r.split(' ', 2)
values.append({
'algorithm': algorithm,
'fingerprint': fingerprint.lower(),
'fingerprint_type': fp_type
})
return {
'type': _type,
'ttl': records['ttl'],
'values': values
}
def _data_for_TXT(self, _type, records):
values = []
for r in records['rdata']:
r = r[1:-1]
values.append(r.replace(';', '\\;'))
return {
'ttl': records['ttl'],
'type': _type,
'values': values
}
def _params_for_multiple(self, values):
return [r for r in values]
def _params_for_single(self, values):
return values
_params_for_A = _params_for_multiple
_params_for_AAAA = _params_for_multiple
_params_for_NS = _params_for_multiple
_params_for_CNAME = _params_for_single
_params_for_PTR = _params_for_single
def _params_for_MX(self, values):
rdata = []
for r in values:
preference = r['preference']
exchange = r['exchange']
record = '{} {}'.format(preference, exchange)
rdata.append(record)
return rdata
def _params_for_NAPTR(self, values):
rdata = []
for r in values:
ordr = r['order']
prf = r['preference']
flg = "\"" + r['flags'] + "\""
srvc = "\"" + r['service'] + "\""
rgx = "\"" + r['regexp'] + "\""
rpl = r['replacement']
record = '{} {} {} {} {} {}'.format(ordr, prf, flg, srvc, rgx, rpl)
rdata.append(record)
return rdata
def _params_for_SPF(self, values):
rdata = []
for r in values:
txt = "\"" + r.replace('\\;', ';') + "\""
rdata.append(txt)
return rdata
def _params_for_SRV(self, values):
rdata = []
for r in values:
priority = r['priority']
weight = r['weight']
port = r['port']
target = r['target']
record = '{} {} {} {}'.format(priority, weight, port, target)
rdata.append(record)
return rdata
def _params_for_SSHFP(self, values):
rdata = []
for r in values:
algorithm = r['algorithm']
fp_type = r['fingerprint_type']
fp = r['fingerprint']
record = '{} {} {}'.format(algorithm, fp_type, fp)
rdata.append(record)
return rdata
def _params_for_TXT(self, values):
rdata = []
for r in values:
txt = "\"" + r.replace('\\;', ';') + "\""
rdata.append(txt)
return rdata
def _build_zone_config(self, zone, _type="primary", comment=None,
masters=[]):
if self._contractId is None:
raise NameError("contractId not specified to create zone")
return {
"zone": zone,
"type": _type,
"comment": comment,
"masters": masters
}
def _get_values(self, data):
try:
vals = data['values']
except KeyError:
vals = [data['value']]
return vals
def _set_full_name(self, name, zone):
name = name + '.' + zone
# octodns's name for root is ''
if (name[0] == '.'):
name = name[1:]
return name

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

@ -536,13 +536,13 @@ def _mod_keyer(mod):
# before all changes, followed by a "CREATE", internally in the AWS API.
# Because of this, we order changes as follows:
# - Delete any records that we wish to delete that are GEOS
# (because they are never targetted by anything)
# (because they are never targeted by anything)
# - Delete any records that we wish to delete that are SECONDARY
# (because they are no longer targetted by GEOS)
# (because they are no longer targeted by GEOS)
# - Delete any records that we wish to delete that are PRIMARY
# (because they are no longer targetted by SECONDARY)
# (because they are no longer targeted by SECONDARY)
# - Delete any records that we wish to delete that are VALUES
# (because they are no longer targetted by PRIMARY)
# (because they are no longer targeted by PRIMARY)
# - CREATE/UPSERT any records that are VALUES
# (because they don't depend on other records)
# - CREATE/UPSERT any records that are PRIMARY
@ -731,7 +731,7 @@ class Route53Provider(BaseProvider):
def _data_for_CAA(self, rrset):
values = []
for rr in rrset['ResourceRecords']:
flags, tag, value = rr['Value'].split(' ')
flags, tag, value = rr['Value'].split()
values.append({
'flags': flags,
'tag': tag,
@ -769,7 +769,7 @@ class Route53Provider(BaseProvider):
def _data_for_MX(self, rrset):
values = []
for rr in rrset['ResourceRecords']:
preference, exchange = rr['Value'].split(' ')
preference, exchange = rr['Value'].split()
values.append({
'preference': preference,
'exchange': exchange,
@ -784,7 +784,7 @@ class Route53Provider(BaseProvider):
values = []
for rr in rrset['ResourceRecords']:
order, preference, flags, service, regexp, replacement = \
rr['Value'].split(' ')
rr['Value'].split()
flags = flags[1:-1]
service = service[1:-1]
regexp = regexp[1:-1]
@ -812,7 +812,7 @@ class Route53Provider(BaseProvider):
def _data_for_SRV(self, rrset):
values = []
for rr in rrset['ResourceRecords']:
priority, weight, port, target = rr['Value'].split(' ')
priority, weight, port, target = rr['Value'].split()
values.append({
'priority': priority,
'weight': weight,
@ -1036,7 +1036,7 @@ class Route53Provider(BaseProvider):
.get('healthcheck', {}) \
.get('measure_latency', True)
def _health_check_equivilent(self, host, path, protocol, port,
def _health_check_equivalent(self, host, path, protocol, port,
measure_latency, health_check, value=None):
config = health_check['HealthCheckConfig']
@ -1088,7 +1088,7 @@ class Route53Provider(BaseProvider):
if not health_check['CallerReference'].startswith(expected_ref):
# not match, ignore
continue
if self._health_check_equivilent(healthcheck_host,
if self._health_check_equivalent(healthcheck_host,
healthcheck_path,
healthcheck_protocol,
healthcheck_port,
@ -1245,7 +1245,7 @@ class Route53Provider(BaseProvider):
health_check = self.health_checks[health_check_id]
caller_ref = health_check['CallerReference']
if caller_ref.startswith(self.HEALTH_CHECK_VERSION):
if self._health_check_equivilent(healthcheck_host,
if self._health_check_equivalent(healthcheck_host,
healthcheck_path,
healthcheck_protocol,
healthcheck_port,


+ 305
- 0
octodns/provider/selectel.py View File

@ -0,0 +1,305 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from collections import defaultdict
from logging import getLogger
from requests import Session
from ..record import Record, Update
from .base import BaseProvider
class SelectelAuthenticationRequired(Exception):
def __init__(self, msg):
message = 'Authorization failed. Invalid or empty token.'
super(SelectelAuthenticationRequired, self).__init__(message)
class SelectelProvider(BaseProvider):
SUPPORTS_GEO = False
SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SPF', 'SRV'))
MIN_TTL = 60
PAGINATION_LIMIT = 50
API_URL = 'https://api.selectel.ru/domains/v1'
def __init__(self, id, token, *args, **kwargs):
self.log = getLogger('SelectelProvider[{}]'.format(id))
self.log.debug('__init__: id=%s', id)
super(SelectelProvider, self).__init__(id, *args, **kwargs)
self._sess = Session()
self._sess.headers.update({
'X-Token': token,
'Content-Type': 'application/json',
})
self._zone_records = {}
self._domain_list = self.domain_list()
self._zones = None
def _request(self, method, path, params=None, data=None):
self.log.debug('_request: method=%s, path=%s', method, path)
url = '{}{}'.format(self.API_URL, path)
resp = self._sess.request(method, url, params=params, json=data)
self.log.debug('_request: status=%s', resp.status_code)
if resp.status_code == 401:
raise SelectelAuthenticationRequired(resp.text)
elif resp.status_code == 404:
return {}
resp.raise_for_status()
if method == 'DELETE':
return {}
return resp.json()
def _get_total_count(self, path):
url = '{}{}'.format(self.API_URL, path)
resp = self._sess.request('HEAD', url)
return int(resp.headers['X-Total-Count'])
def _request_with_pagination(self, path, total_count):
result = []
for offset in range(0, total_count, self.PAGINATION_LIMIT):
result += self._request('GET', path,
params={'limit': self.PAGINATION_LIMIT,
'offset': offset})
return result
def _include_change(self, change):
if isinstance(change, Update):
existing = change.existing.data
new = change.new.data
new['ttl'] = max(self.MIN_TTL, new['ttl'])
if new == existing:
self.log.debug('_include_changes: new=%s, found existing=%s',
new, existing)
return False
return True
def _apply(self, plan):
desired = plan.desired
changes = plan.changes
self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
len(changes))
zone_name = desired.name[:-1]
for change in changes:
class_name = change.__class__.__name__
getattr(self, '_apply_{}'.format(class_name).lower())(zone_name,
change)
def _apply_create(self, zone_name, change):
new = change.new
params_for = getattr(self, '_params_for_{}'.format(new._type))
for params in params_for(new):
self.create_record(zone_name, params)
def _apply_update(self, zone_name, change):
self._apply_delete(zone_name, change)
self._apply_create(zone_name, change)
def _apply_delete(self, zone_name, change):
existing = change.existing
self.delete_record(zone_name, existing._type, existing.name)
def _params_for_multiple(self, record):
for value in record.values:
yield {
'content': value,
'name': record.fqdn,
'ttl': max(self.MIN_TTL, record.ttl),
'type': record._type,
}
def _params_for_single(self, record):
yield {
'content': record.value,
'name': record.fqdn,
'ttl': max(self.MIN_TTL, record.ttl),
'type': record._type
}
def _params_for_MX(self, record):
for value in record.values:
yield {
'content': value.exchange,
'name': record.fqdn,
'ttl': max(self.MIN_TTL, record.ttl),
'type': record._type,
'priority': value.preference
}
def _params_for_SRV(self, record):
for value in record.values:
yield {
'name': record.fqdn,
'target': value.target,
'ttl': max(self.MIN_TTL, record.ttl),
'type': record._type,
'port': value.port,
'weight': value.weight,
'priority': value.priority
}
_params_for_A = _params_for_multiple
_params_for_AAAA = _params_for_multiple
_params_for_NS = _params_for_multiple
_params_for_TXT = _params_for_multiple
_params_for_SPF = _params_for_multiple
_params_for_CNAME = _params_for_single
def _data_for_A(self, _type, records):
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': [r['content'] for r in records],
}
_data_for_AAAA = _data_for_A
def _data_for_NS(self, _type, records):
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': ['{}.'.format(r['content']) for r in records],
}
def _data_for_MX(self, _type, records):
values = []
for record in records:
values.append({
'preference': record['priority'],
'exchange': '{}.'.format(record['content']),
})
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': values,
}
def _data_for_CNAME(self, _type, records):
only = records[0]
return {
'ttl': only['ttl'],
'type': _type,
'value': '{}.'.format(only['content'])
}
def _data_for_TXT(self, _type, records):
return {
'ttl': records[0]['ttl'],
'type': _type,
'values': [r['content'] for r in records],
}
def _data_for_SRV(self, _type, records):
values = []
for record in records:
values.append({
'priority': record['priority'],
'weight': record['weight'],
'port': record['port'],
'target': '{}.'.format(record['target']),
})
return {
'type': _type,
'ttl': records[0]['ttl'],
'values': values,
}
def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s',
zone.name, target, lenient)
before = len(zone.records)
records = self.zone_records(zone)
if records:
values = defaultdict(lambda: defaultdict(list))
for record in records:
name = zone.hostname_from_fqdn(record['name'])
_type = record['type']
if _type in self.SUPPORTS:
values[name][record['type']].append(record)
for name, types in values.items():
for _type, records in types.items():
data_for = getattr(self, '_data_for_{}'.format(_type))
data = data_for(_type, records)
record = Record.new(zone, name, data, source=self,
lenient=lenient)
zone.add_record(record)
self.log.info('populate: found %s records',
len(zone.records) - before)
def domain_list(self):
path = '/'
domains = {}
domains_list = []
total_count = self._get_total_count(path)
domains_list = self._request_with_pagination(path, total_count)
for domain in domains_list:
domains[domain['name']] = domain
return domains
def zone_records(self, zone):
path = '/{}/records/'.format(zone.name[:-1])
zone_records = []
total_count = self._get_total_count(path)
zone_records = self._request_with_pagination(path, total_count)
self._zone_records[zone.name] = zone_records
return self._zone_records[zone.name]
def create_domain(self, name, zone=""):
path = '/'
data = {
'name': name,
'bind_zone': zone,
}
resp = self._request('POST', path, data=data)
self._domain_list[name] = resp
return resp
def create_record(self, zone_name, data):
self.log.debug('Create record. Zone: %s, data %s', zone_name, data)
if zone_name in self._domain_list.keys():
domain_id = self._domain_list[zone_name]['id']
else:
domain_id = self.create_domain(zone_name)['id']
path = '/{}/records/'.format(domain_id)
return self._request('POST', path, data=data)
def delete_record(self, domain, _type, zone):
self.log.debug('Delete record. Domain: %s, Type: %s', domain, _type)
domain_id = self._domain_list[domain]['id']
records = self._zone_records.get('{}.'.format(domain), False)
if not records:
path = '/{}/records/'.format(domain_id)
records = self._request('GET', path)
for record in records:
full_domain = domain
if zone:
full_domain = '{}{}'.format(zone, domain)
if record['type'] == _type and record['name'] == full_domain:
path = '/{}/records/{}'.format(domain_id, record['id'])
return self._request('DELETE', path)
self.log.debug('Delete record failed (Record not found)')

+ 353
- 0
octodns/provider/transip.py View File

@ -0,0 +1,353 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from suds import WebFault
from collections import defaultdict
from .base import BaseProvider
from logging import getLogger
from ..record import Record
from transip.service.domain import DomainService
from transip.service.objects import DnsEntry
class TransipException(Exception):
pass
class TransipConfigException(TransipException):
pass
class TransipNewZoneException(TransipException):
pass
class TransipProvider(BaseProvider):
'''
Transip DNS provider
transip:
class: octodns.provider.transip.TransipProvider
# Your Transip account name (required)
account: yourname
# Path to a private key file (required if key is not used)
key_file: /path/to/file
# The api key as string (required if key_file is not used)
key: |
\'''
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
\'''
# if both `key_file` and `key` are presented `key_file` is used
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(
('A', 'AAAA', 'CNAME', 'MX', 'SRV', 'SPF', 'TXT', 'SSHFP', 'CAA'))
# unsupported by OctoDNS: 'TLSA'
MIN_TTL = 120
TIMEOUT = 15
ROOT_RECORD = '@'
def __init__(self, id, account, key=None, key_file=None, *args, **kwargs):
self.log = getLogger('TransipProvider[{}]'.format(id))
self.log.debug('__init__: id=%s, account=%s, token=***', id,
account)
super(TransipProvider, self).__init__(id, *args, **kwargs)
if key_file is not None:
self._client = DomainService(account, private_key_file=key_file)
elif key is not None:
self._client = DomainService(account, private_key=key)
else:
raise TransipConfigException(
'Missing `key` of `key_file` parameter in config'
)
self.account = account
self.key = key
self._currentZone = {}
def populate(self, zone, target=False, lenient=False):
exists = False
self._currentZone = zone
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient)
before = len(zone.records)
try:
zoneInfo = self._client.get_info(zone.name[:-1])
except WebFault as e:
if e.fault.faultcode == '102' and target is False:
# Zone not found in account, and not a target so just
# leave an empty zone.
return exists
elif e.fault.faultcode == '102' and target is True:
self.log.warning('populate: Transip can\'t create new zones')
raise TransipNewZoneException(
('populate: ({}) Transip used ' +
'as target for non-existing zone: {}').format(
e.fault.faultcode, zone.name))
else:
self.log.error('populate: (%s) %s ', e.fault.faultcode,
e.fault.faultstring)
raise e
self.log.debug('populate: found %s records for zone %s',
len(zoneInfo.dnsEntries), zone.name)
exists = True
if zoneInfo.dnsEntries:
values = defaultdict(lambda: defaultdict(list))
for record in zoneInfo.dnsEntries:
name = zone.hostname_from_fqdn(record['name'])
if name == self.ROOT_RECORD:
name = ''
if record['type'] in self.SUPPORTS:
values[name][record['type']].append(record)
for name, types in values.items():
for _type, records in types.items():
data_for = getattr(self, '_data_for_{}'.format(_type))
record = Record.new(zone, name, data_for(_type, records),
source=self, lenient=lenient)
zone.add_record(record, lenient=lenient)
self.log.info('populate: found %s records, exists = %s',
len(zone.records) - before, exists)
self._currentZone = {}
return exists
def _apply(self, plan):
desired = plan.desired
changes = plan.changes
self.log.debug('apply: zone=%s, changes=%d', desired.name,
len(changes))
self._currentZone = plan.desired
try:
self._client.get_info(plan.desired.name[:-1])
except WebFault as e:
self.log.warning('_apply: %s ', e.message)
raise e
_dns_entries = []
for record in plan.desired.records:
if record._type in self.SUPPORTS:
entries_for = getattr(self,
'_entries_for_{}'.format(record._type))
# Root records have '@' as name
name = record.name
if name == '':
name = self.ROOT_RECORD
_dns_entries.extend(entries_for(name, record))
try:
self._client.set_dns_entries(plan.desired.name[:-1], _dns_entries)
except WebFault as e:
self.log.warning(('_apply: Set DNS returned ' +
'one or more errors: {}').format(
e.fault.faultstring))
raise TransipException(200, e.fault.faultstring)
self._currentZone = {}
def _entries_for_multiple(self, name, record):
_entries = []
for value in record.values:
_entries.append(DnsEntry(name, record.ttl, record._type, value))
return _entries
def _entries_for_single(self, name, record):
return [DnsEntry(name, record.ttl, record._type, record.value)]
_entries_for_A = _entries_for_multiple
_entries_for_AAAA = _entries_for_multiple
_entries_for_NS = _entries_for_multiple
_entries_for_SPF = _entries_for_multiple
_entries_for_CNAME = _entries_for_single
def _entries_for_MX(self, name, record):
_entries = []
for value in record.values:
content = "{} {}".format(value.preference, value.exchange)
_entries.append(DnsEntry(name, record.ttl, record._type, content))
return _entries
def _entries_for_SRV(self, name, record):
_entries = []
for value in record.values:
content = "{} {} {} {}".format(value.priority, value.weight,
value.port, value.target)
_entries.append(DnsEntry(name, record.ttl, record._type, content))
return _entries
def _entries_for_SSHFP(self, name, record):
_entries = []
for value in record.values:
content = "{} {} {}".format(value.algorithm,
value.fingerprint_type,
value.fingerprint)
_entries.append(DnsEntry(name, record.ttl, record._type, content))
return _entries
def _entries_for_CAA(self, name, record):
_entries = []
for value in record.values:
content = "{} {} {}".format(value.flags, value.tag,
value.value)
_entries.append(DnsEntry(name, record.ttl, record._type, content))
return _entries
def _entries_for_TXT(self, name, record):
_entries = []
for value in record.values:
value = value.replace('\\;', ';')
_entries.append(DnsEntry(name, record.ttl, record._type, value))
return _entries
def _parse_to_fqdn(self, value):
# Enforce switch from suds.sax.text.Text to string
value = str(value)
# TransIP allows '@' as value to alias the root record.
# this provider won't set an '@' value, but can be an existing record
if value == self.ROOT_RECORD:
value = self._currentZone.name
if value[-1] != '.':
self.log.debug('parseToFQDN: changed %s to %s', value,
'{}.{}'.format(value, self._currentZone.name))
value = '{}.{}'.format(value, self._currentZone.name)
return value
def _get_lowest_ttl(self, records):
_ttl = 100000
for record in records:
_ttl = min(_ttl, record['expire'])
return _ttl
def _data_for_multiple(self, _type, records):
_values = []
for record in records:
# Enforce switch from suds.sax.text.Text to string
_values.append(str(record['content']))
return {
'ttl': self._get_lowest_ttl(records),
'type': _type,
'values': _values
}
_data_for_A = _data_for_multiple
_data_for_AAAA = _data_for_multiple
_data_for_NS = _data_for_multiple
_data_for_SPF = _data_for_multiple
def _data_for_CNAME(self, _type, records):
return {
'ttl': records[0]['expire'],
'type': _type,
'value': self._parse_to_fqdn(records[0]['content'])
}
def _data_for_MX(self, _type, records):
_values = []
for record in records:
preference, exchange = record['content'].split(" ", 1)
_values.append({
'preference': preference,
'exchange': self._parse_to_fqdn(exchange)
})
return {
'ttl': self._get_lowest_ttl(records),
'type': _type,
'values': _values
}
def _data_for_SRV(self, _type, records):
_values = []
for record in records:
priority, weight, port, target = record['content'].split(' ', 3)
_values.append({
'port': port,
'priority': priority,
'target': self._parse_to_fqdn(target),
'weight': weight
})
return {
'type': _type,
'ttl': self._get_lowest_ttl(records),
'values': _values
}
def _data_for_SSHFP(self, _type, records):
_values = []
for record in records:
algorithm, fp_type, fingerprint = record['content'].split(' ', 2)
_values.append({
'algorithm': algorithm,
'fingerprint': fingerprint.lower(),
'fingerprint_type': fp_type
})
return {
'type': _type,
'ttl': self._get_lowest_ttl(records),
'values': _values
}
def _data_for_CAA(self, _type, records):
_values = []
for record in records:
flags, tag, value = record['content'].split(' ', 2)
_values.append({
'flags': flags,
'tag': tag,
'value': value
})
return {
'type': _type,
'ttl': self._get_lowest_ttl(records),
'values': _values
}
def _data_for_TXT(self, _type, records):
_values = []
for record in records:
_values.append(record['content'].replace(';', '\\;'))
return {
'type': _type,
'ttl': self._get_lowest_ttl(records),
'values': _values
}

+ 1
- 0
requirements-dev.txt View File

@ -5,5 +5,6 @@ pycodestyle==2.4.0
pycountry>=18.12.8
pycountry_convert>=0.7.2
pyflakes==1.6.0
readme_renderer[md]==24.0
requests_mock
twine==1.13.0

+ 3
- 1
requirements.txt View File

@ -7,6 +7,7 @@ dnspython==1.15.0
docutils==0.14
dyn==1.8.1
futures==3.2.0; python_version < '3.0'
edgegrid-python==1.1.1
google-cloud-core==0.28.1
google-cloud-dns==0.29.0
incf.countryutils==1.0
@ -17,7 +18,8 @@ natsort==5.5.0
nsone==0.9.100
ovh==0.4.8
python-dateutil==2.6.1
requests==2.20.0
requests==2.22.0
s3transfer==0.1.13
six==1.12.0
setuptools==38.5.2
transip==2.0.0

+ 1
- 0
script/release View File

@ -22,5 +22,6 @@ git tag -s "v$VERSION" -m "Release $VERSION"
git push origin "v$VERSION"
echo "Tagged and pushed v$VERSION"
python setup.py sdist
twine check dist/*$VERSION.tar.gz
twine upload dist/*$VERSION.tar.gz
echo "Uploaded $VERSION"

+ 35
- 1
setup.py View File

@ -1,5 +1,6 @@
#!/usr/bin/env python
from StringIO import StringIO
from os.path import dirname, join
import octodns
@ -21,6 +22,39 @@ console_scripts = {
for name in cmds
}
def long_description():
buf = StringIO()
yaml_block = False
supported_providers = False
with open('README.md') as fh:
for line in fh:
if line == '```yaml\n':
yaml_block = True
continue
elif yaml_block and line == '---\n':
# skip the line
continue
elif yaml_block and line == '```\n':
yaml_block = False
continue
elif supported_providers:
if line.startswith('## '):
supported_providers = False
# write this line out, no continue
else:
# We're ignoring this one
continue
elif line == '## Supported providers\n':
supported_providers = True
continue
buf.write(line)
buf = buf.getvalue()
with open('/tmp/mod', 'w') as fh:
fh.write(buf)
return buf
setup(
author='Ross McFarland',
author_email='rwmcfa1@gmail.com',
@ -40,7 +74,7 @@ setup(
'requests>=2.20.0'
],
license='MIT',
long_description=open('README.md').read(),
long_description=long_description(),
long_description_content_type='text/markdown',
name='octodns',
packages=find_packages(),


+ 28
- 0
tests/fixtures/constellix-domains.json View File

@ -0,0 +1,28 @@
[{
"id": 123123,
"name": "unit.tests",
"soa": {
"primaryNameserver": "ns11.constellix.com.",
"email": "dns.constellix.com.",
"ttl": 86400,
"serial": 2015010102,
"refresh": 43200,
"retry": 3600,
"expire": 1209600,
"negCache": 180
},
"createdTs": "2019-08-07T03:36:02Z",
"modifiedTs": "2019-08-07T03:36:02Z",
"typeId": 1,
"domainTags": [],
"folder": null,
"hasGtdRegions": false,
"hasGeoIP": false,
"nameserverGroup": 1,
"nameservers": ["ns11.constellix.com.", "ns21.constellix.com.", "ns31.constellix.com.", "ns41.constellix.net.", "ns51.constellix.net.", "ns61.constellix.net."],
"note": "",
"version": 0,
"status": "ACTIVE",
"tags": [],
"contactIds": []
}]

+ 598
- 0
tests/fixtures/constellix-records.json View File

@ -0,0 +1,598 @@
[{
"id": 1808529,
"type": "CAA",
"recordType": "caa",
"name": "",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 3600,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565149569216,
"value": [{
"flag": 0,
"tag": "issue",
"data": "ca.unit.tests",
"caaProviderId": 1,
"disableFlag": false
}],
"roundRobin": [{
"flag": 0,
"tag": "issue",
"data": "ca.unit.tests",
"caaProviderId": 1,
"disableFlag": false
}]
}, {
"id": 1808516,
"type": "A",
"recordType": "a",
"name": "",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 300,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565149623640,
"value": ["1.2.3.4", "1.2.3.5"],
"roundRobin": [{
"value": "1.2.3.4",
"disableFlag": false
}, {
"value": "1.2.3.5",
"disableFlag": false
}],
"geolocation": null,
"recordFailover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"failover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"roundRobinFailover": [],
"pools": [],
"poolsDetail": []
}, {
"id": 1808527,
"type": "SRV",
"recordType": "srv",
"name": "_srv._tcp",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 600,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565149714387,
"value": [{
"value": "foo-1.unit.tests.",
"priority": 10,
"weight": 20,
"port": 30,
"disableFlag": false
}, {
"value": "foo-2.unit.tests.",
"priority": 12,
"weight": 20,
"port": 30,
"disableFlag": false
}],
"roundRobin": [{
"value": "foo-1.unit.tests.",
"priority": 10,
"weight": 20,
"port": 30,
"disableFlag": false
}, {
"value": "foo-2.unit.tests.",
"priority": 12,
"weight": 20,
"port": 30,
"disableFlag": false
}]
}, {
"id": 1808515,
"type": "AAAA",
"recordType": "aaaa",
"name": "aaaa",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 600,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565149739464,
"value": ["2601:644:500:e210:62f8:1dff:feb8:947a"],
"roundRobin": [{
"value": "2601:644:500:e210:62f8:1dff:feb8:947a",
"disableFlag": false
}],
"geolocation": null,
"recordFailover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"failover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"pools": [],
"poolsDetail": [],
"roundRobinFailover": []
}, {
"id": 1808530,
"type": "ANAME",
"recordType": "aname",
"name": "",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 1800,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565150251379,
"value": [{
"value": "aname.unit.tests.",
"disableFlag": false
}],
"roundRobin": [{
"value": "aname.unit.tests.",
"disableFlag": false
}],
"geolocation": null,
"recordFailover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"failover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"pools": [],
"poolsDetail": []
}, {
"id": 1808521,
"type": "CNAME",
"recordType": "cname",
"name": "cname",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 300,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565152113825,
"value": "",
"roundRobin": [{
"value": "",
"disableFlag": false
}],
"recordFailover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": [{
"id": null,
"value": "",
"disableFlag": false,
"failedFlag": false,
"status": "N/A",
"sortOrder": 1,
"markedActive": false
}, {
"id": null,
"value": "",
"disableFlag": false,
"failedFlag": false,
"status": "N/A",
"sortOrder": 2,
"markedActive": false
}]
},
"failover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": [{
"id": null,
"value": "",
"disableFlag": false,
"failedFlag": false,
"status": "N/A",
"sortOrder": 1,
"markedActive": false
}, {
"id": null,
"value": "",
"disableFlag": false,
"failedFlag": false,
"status": "N/A",
"sortOrder": 2,
"markedActive": false
}]
},
"pools": [],
"poolsDetail": [],
"geolocation": null,
"host": ""
}, {
"id": 1808522,
"type": "CNAME",
"recordType": "cname",
"name": "included",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 3600,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565152119137,
"value": "",
"roundRobin": [{
"value": "",
"disableFlag": false
}],
"recordFailover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": [{
"id": null,
"value": "",
"disableFlag": false,
"failedFlag": false,
"status": "N/A",
"sortOrder": 1,
"markedActive": false
}, {
"id": null,
"value": "",
"disableFlag": false,
"failedFlag": false,
"status": "N/A",
"sortOrder": 2,
"markedActive": false
}]
},
"failover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": [{
"id": null,
"value": "",
"disableFlag": false,
"failedFlag": false,
"status": "N/A",
"sortOrder": 1,
"markedActive": false
}, {
"id": null,
"value": "",
"disableFlag": false,
"failedFlag": false,
"status": "N/A",
"sortOrder": 2,
"markedActive": false
}]
},
"pools": [],
"poolsDetail": [],
"geolocation": null,
"host": ""
}, {
"id": 1808523,
"type": "MX",
"recordType": "mx",
"name": "mx",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 300,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565149879856,
"value": [{
"value": "smtp-3.unit.tests.",
"level": 30,
"disableFlag": false
}, {
"value": "smtp-2.unit.tests.",
"level": 20,
"disableFlag": false
}, {
"value": "smtp-4.unit.tests.",
"level": 10,
"disableFlag": false
}, {
"value": "smtp-1.unit.tests.",
"level": 40,
"disableFlag": false
}],
"roundRobin": [{
"value": "smtp-3.unit.tests.",
"level": 30,
"disableFlag": false
}, {
"value": "smtp-2.unit.tests.",
"level": 20,
"disableFlag": false
}, {
"value": "smtp-4.unit.tests.",
"level": 10,
"disableFlag": false
}, {
"value": "smtp-1.unit.tests.",
"level": 40,
"disableFlag": false
}]
}, {
"id": 1808525,
"type": "PTR",
"recordType": "ptr",
"name": "ptr",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 300,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565150115139,
"value": [{
"value": "foo.bar.com.",
"disableFlag": false
}],
"roundRobin": [{
"value": "foo.bar.com.",
"disableFlag": false
}]
}, {
"id": 1808526,
"type": "SPF",
"recordType": "spf",
"name": "spf",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 600,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565149916132,
"value": [{
"value": "\"v=spf1 ip4:192.168.0.1/16-all\"",
"disableFlag": false
}],
"roundRobin": [{
"value": "\"v=spf1 ip4:192.168.0.1/16-all\"",
"disableFlag": false
}]
}, {
"id": 1808528,
"type": "TXT",
"recordType": "txt",
"name": "txt",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 600,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565149966915,
"value": [{
"value": "\"Bah bah black sheep\"",
"disableFlag": false
}, {
"value": "\"have you any wool.\"",
"disableFlag": false
}, {
"value": "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"",
"disableFlag": false
}],
"roundRobin": [{
"value": "\"Bah bah black sheep\"",
"disableFlag": false
}, {
"value": "\"have you any wool.\"",
"disableFlag": false
}, {
"value": "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"",
"disableFlag": false
}]
}, {
"id": 1808524,
"type": "NS",
"recordType": "ns",
"name": "under",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 3600,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565150062850,
"value": [{
"value": "ns1.unit.tests.",
"disableFlag": false
}, {
"value": "ns2",
"disableFlag": false
}],
"roundRobin": [{
"value": "ns1.unit.tests.",
"disableFlag": false
}, {
"value": "ns2",
"disableFlag": false
}]
}, {
"id": 1808531,
"type": "HTTPRedirection",
"recordType": "httpredirection",
"name": "unsupported",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 300,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565150348154,
"value": "https://redirect.unit.tests",
"roundRobin": [{
"value": "https://redirect.unit.tests"
}],
"title": "Unsupported Record",
"keywords": "unsupported",
"description": "unsupported record",
"hardlinkFlag": false,
"redirectTypeId": 1,
"url": "https://redirect.unit.tests"
}, {
"id": 1808519,
"type": "A",
"recordType": "a",
"name": "www",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 300,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565150079027,
"value": ["2.2.3.6"],
"roundRobin": [{
"value": "2.2.3.6",
"disableFlag": false
}],
"geolocation": null,
"recordFailover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"failover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"roundRobinFailover": [],
"pools": [],
"poolsDetail": []
}, {
"id": 1808603,
"type": "ANAME",
"recordType": "aname",
"name": "sub",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 1800,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565153387855,
"value": [{
"value": "aname.unit.tests.",
"disableFlag": false
}],
"roundRobin": [{
"value": "aname.unit.tests.",
"disableFlag": false
}],
"geolocation": null,
"recordFailover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"failover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"pools": [],
"poolsDetail": []
}, {
"id": 1808520,
"type": "A",
"recordType": "a",
"name": "www.sub",
"recordOption": "roundRobin",
"noAnswer": false,
"note": "",
"ttl": 300,
"gtdRegion": 1,
"parentId": 123123,
"parent": "domain",
"source": "Domain",
"modifiedTs": 1565150090588,
"value": ["2.2.3.6"],
"roundRobin": [{
"value": "2.2.3.6",
"disableFlag": false
}],
"geolocation": null,
"recordFailover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"failover": {
"disabled": false,
"failoverType": 1,
"failoverTypeStr": "Normal (always lowest level)",
"values": []
},
"roundRobinFailover": [],
"pools": [],
"poolsDetail": []
}]

+ 35
- 0
tests/fixtures/fastdns-invalid-content.json View File

@ -0,0 +1,35 @@
{
"recordsets": [
{
"rdata": [
"",
"12 20 foo-2.unit.tests."
],
"type": "SRV",
"name": "_srv._tcp.unit.tests",
"ttl": 600
},
{
"rdata": [
"",
"1 1"
],
"type": "SSHFP",
"name": "unit.tests",
"ttl": 3600
},
{
"rdata": [
"",
"100 \"U\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" ."
],
"type": "NAPTR",
"name": "naptr.unit.tests",
"ttl": 600
}
],
"metadata": {
"totalElements": 3,
"showAll": true
}
}

+ 166
- 0
tests/fixtures/fastdns-records-prev-other.json View File

@ -0,0 +1,166 @@
{
"recordsets": [
{
"rdata": [
"10 20 30 foo-1.other.tests.",
"12 20 30 foo-2.other.tests."
],
"type": "SRV",
"name": "_srv._tcp.old.other.tests",
"ttl": 600
},
{
"rdata": [
"10 20 30 foo-1.other.tests.",
"12 20 30 foo-2.other.tests."
],
"type": "SRV",
"name": "_srv._tcp.old.other.tests",
"ttl": 600
},
{
"rdata": [
"2601:644:500:e210:62f8:1dff:feb8:9471"
],
"type": "AAAA",
"name": "aaaa.old.other.tests",
"ttl": 600
},
{
"rdata": [
"ns1.akam.net.",
"ns2.akam.net.",
"ns3.akam.net.",
"ns4.akam.net."
],
"type": "NS",
"name": "old.other.tests",
"ttl": 3600
},
{
"rdata": [
"1.2.3.4",
"1.2.3.5"
],
"type": "A",
"name": "old.other.tests",
"ttl": 300
},
{
"rdata": [
"ns1.akam.net hostmaster.akamai.com 1489074932 86400 7200 604800 300"
],
"type": "SOA",
"name": "other.tests",
"ttl": 3600
},
{
"rdata": [
"1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49",
"1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73"
],
"type": "SSHFP",
"name": "old.other.tests",
"ttl": 3600
},
{
"rdata": [
"other.tests."
],
"type": "CNAME",
"name": "old.cname.other.tests",
"ttl": 300
},
{
"rdata": [
"other.tests."
],
"type": "CNAME",
"name": "excluded.old.other.tests",
"ttl": 3600
},
{
"rdata": [
"other.tests."
],
"type": "CNAME",
"name": "included.old.other.tests",
"ttl": 3600
},
{
"rdata": [
"10 smtp-4.other.tests.",
"20 smtp-2.other.tests.",
"30 smtp-3.other.tests.",
"40 smtp-1.other.tests."
],
"type": "MX",
"name": "mx.old.other.tests",
"ttl": 300
},
{
"rdata": [
"10 100 \"S\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" .",
"100 100 \"U\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" ."
],
"type": "NAPTR",
"name": "naptr.old.other.tests",
"ttl": 600
},
{
"rdata": [
"foo.bar.com."
],
"type": "PTR",
"name": "ptr.old.other.tests",
"ttl": 300
},
{
"rdata": [
"\"v=spf1 ip4:192.168.0.1/16-all\""
],
"type": "SPF",
"name": "spf.old.other.tests",
"ttl": 600
},
{
"rdata": [
"ns1.other.tests.",
"ns2.other.tests."
],
"type": "NS",
"name": "under.old.other.tests",
"ttl": 3600
},
{
"rdata": [
"\"Bah bah black sheep\"",
"\"have you any wool.\"",
"\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\""
],
"type": "TXT",
"name": "txt.old.other.tests",
"ttl": 600
},
{
"rdata": [
"2.2.3.7"
],
"type": "A",
"name": "www.other.tests",
"ttl": 300
},
{
"rdata": [
"2.2.3.6"
],
"type": "A",
"name": "www.sub.old.other.tests",
"ttl": 300
}
],
"metadata": {
"totalElements": 16,
"showAll": true
}
}

+ 166
- 0
tests/fixtures/fastdns-records-prev.json View File

@ -0,0 +1,166 @@
{
"recordsets": [
{
"rdata": [
"10 20 30 foo-1.unit.tests.",
"12 20 30 foo-2.unit.tests."
],
"type": "SRV",
"name": "_srv._tcp.old.unit.tests",
"ttl": 600
},
{
"rdata": [
"10 20 30 foo-1.unit.tests.",
"12 20 30 foo-2.unit.tests."
],
"type": "SRV",
"name": "_srv._tcp.old.unit.tests",
"ttl": 600
},
{
"rdata": [
"2601:644:500:e210:62f8:1dff:feb8:9471"
],
"type": "AAAA",
"name": "aaaa.old.unit.tests",
"ttl": 600
},
{
"rdata": [
"ns1.akam.net.",
"ns2.akam.net.",
"ns3.akam.net.",
"ns4.akam.net."
],
"type": "NS",
"name": "old.unit.tests",
"ttl": 3600
},
{
"rdata": [
"1.2.3.4",
"1.2.3.5"
],
"type": "A",
"name": "old.unit.tests",
"ttl": 300
},
{
"rdata": [
"ns1.akam.net hostmaster.akamai.com 1489074932 86400 7200 604800 300"
],
"type": "SOA",
"name": "unit.tests",
"ttl": 3600
},
{
"rdata": [
"1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49",
"1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73"
],
"type": "SSHFP",
"name": "old.unit.tests",
"ttl": 3600
},
{
"rdata": [
"unit.tests"
],
"type": "CNAME",
"name": "old.cname.unit.tests",
"ttl": 300
},
{
"rdata": [
"unit.tests."
],
"type": "CNAME",
"name": "excluded.old.unit.tests",
"ttl": 3600
},
{
"rdata": [
"unit.tests."
],
"type": "CNAME",
"name": "included.old.unit.tests",
"ttl": 3600
},
{
"rdata": [
"10 smtp-4.unit.tests.",
"20 smtp-2.unit.tests.",
"30 smtp-3.unit.tests.",
"40 smtp-1.unit.tests."
],
"type": "MX",
"name": "mx.old.unit.tests",
"ttl": 300
},
{
"rdata": [
"10 100 \"S\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" .",
"100 100 \"U\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" ."
],
"type": "NAPTR",
"name": "naptr.old.unit.tests",
"ttl": 600
},
{
"rdata": [
"foo.bar.com."
],
"type": "PTR",
"name": "ptr.old.unit.tests",
"ttl": 300
},
{
"rdata": [
"\"v=spf1 ip4:192.168.0.1/16-all\""
],
"type": "SPF",
"name": "spf.old.unit.tests",
"ttl": 600
},
{
"rdata": [
"ns1.unit.tests.",
"ns2.unit.tests."
],
"type": "NS",
"name": "under.old.unit.tests",
"ttl": 3600
},
{
"rdata": [
"\"Bah bah black sheep\"",
"\"have you any wool.\"",
"\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\""
],
"type": "TXT",
"name": "txt.old.unit.tests",
"ttl": 600
},
{
"rdata": [
"2.2.3.7"
],
"type": "A",
"name": "www.unit.tests",
"ttl": 300
},
{
"rdata": [
"2.2.3.6"
],
"type": "A",
"name": "www.sub.old.unit.tests",
"ttl": 300
}
],
"metadata": {
"totalElements": 16,
"showAll": true
}
}

+ 157
- 0
tests/fixtures/fastdns-records.json View File

@ -0,0 +1,157 @@
{
"recordsets": [
{
"rdata": [
"10 20 30 foo-1.unit.tests.",
"12 20 30 foo-2.unit.tests."
],
"type": "SRV",
"name": "_srv._tcp.unit.tests",
"ttl": 600
},
{
"rdata": [
"2601:644:500:e210:62f8:1dff:feb8:947a"
],
"type": "AAAA",
"name": "aaaa.unit.tests",
"ttl": 600
},
{
"rdata": [
"ns1.akam.net.",
"ns2.akam.net.",
"ns3.akam.net.",
"ns4.akam.net."
],
"type": "NS",
"name": "unit.tests",
"ttl": 3600
},
{
"rdata": [
"1.2.3.4",
"1.2.3.5"
],
"type": "A",
"name": "unit.tests",
"ttl": 300
},
{
"rdata": [
"ns1.akam.net hostmaster.akamai.com 1489074932 86400 7200 604800 300"
],
"type": "SOA",
"name": "unit.tests",
"ttl": 3600
},
{
"rdata": [
"1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49",
"1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73"
],
"type": "SSHFP",
"name": "unit.tests",
"ttl": 3600
},
{
"rdata": [
"unit.tests."
],
"type": "CNAME",
"name": "cname.unit.tests",
"ttl": 300
},
{
"rdata": [
"unit.tests."
],
"type": "CNAME",
"name": "excluded.unit.tests",
"ttl": 3600
},
{
"rdata": [
"unit.tests."
],
"type": "CNAME",
"name": "included.unit.tests",
"ttl": 3600
},
{
"rdata": [
"10 smtp-4.unit.tests.",
"20 smtp-2.unit.tests.",
"30 smtp-3.unit.tests.",
"40 smtp-1.unit.tests."
],
"type": "MX",
"name": "mx.unit.tests",
"ttl": 300
},
{
"rdata": [
"10 100 \"S\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" .",
"100 100 \"U\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" ."
],
"type": "NAPTR",
"name": "naptr.unit.tests",
"ttl": 600
},
{
"rdata": [
"foo.bar.com."
],
"type": "PTR",
"name": "ptr.unit.tests",
"ttl": 300
},
{
"rdata": [
"\"v=spf1 ip4:192.168.0.1/16-all\""
],
"type": "SPF",
"name": "spf.unit.tests",
"ttl": 600
},
{
"rdata": [
"ns1.unit.tests.",
"ns2.unit.tests."
],
"type": "NS",
"name": "under.unit.tests",
"ttl": 3600
},
{
"rdata": [
"\"Bah bah black sheep\"",
"\"have you any wool.\"",
"\"v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs\""
],
"type": "TXT",
"name": "txt.unit.tests",
"ttl": 600
},
{
"rdata": [
"2.2.3.6"
],
"type": "A",
"name": "www.unit.tests",
"ttl": 300
},
{
"rdata": [
"2.2.3.6"
],
"type": "A",
"name": "www.sub.unit.tests",
"ttl": 300
}
],
"metadata": {
"totalElements": 16,
"showAll": true
}
}

+ 3
- 3
tests/test_octodns_provider_cloudflare.py View File

@ -103,7 +103,7 @@ class TestCloudflareProvider(TestCase):
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
# Non-existant zone doesn't populate anything
# Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=200, json=self.empty)
@ -111,7 +111,7 @@ class TestCloudflareProvider(TestCase):
provider.populate(zone)
self.assertEquals(set(), zone.records)
# re-populating the same non-existant zone uses cache and makes no
# re-populating the same non-existent zone uses cache and makes no
# calls
again = Zone('unit.tests.', [])
provider.populate(again)
@ -174,7 +174,7 @@ class TestCloudflareProvider(TestCase):
}, # zone create
] + [None] * 20 # individual record creates
# non-existant zone, create everything
# non-existent zone, create everything
plan = provider.plan(self.expected)
self.assertEquals(12, len(plan.changes))
self.assertEquals(12, provider.apply(plan))


+ 218
- 0
tests/test_octodns_provider_constellix.py View File

@ -0,0 +1,218 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from mock import Mock, call
from os.path import dirname, join
from requests import HTTPError
from requests_mock import ANY, mock as requests_mock
from unittest import TestCase
from octodns.record import Record
from octodns.provider.constellix import ConstellixClientNotFound, \
ConstellixProvider
from octodns.provider.yaml import YamlProvider
from octodns.zone import Zone
import json
class TestConstellixProvider(TestCase):
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected)
# Our test suite differs a bit, add our NS and remove the simple one
expected.add_record(Record.new(expected, 'under', {
'ttl': 3600,
'type': 'NS',
'values': [
'ns1.unit.tests.',
'ns2.unit.tests.',
]
}))
# Add some ALIAS records
expected.add_record(Record.new(expected, '', {
'ttl': 1800,
'type': 'ALIAS',
'value': 'aname.unit.tests.'
}))
expected.add_record(Record.new(expected, 'sub', {
'ttl': 1800,
'type': 'ALIAS',
'value': 'aname.unit.tests.'
}))
for record in list(expected.records):
if record.name == 'sub' and record._type == 'NS':
expected._remove_record(record)
break
def test_populate(self):
provider = ConstellixProvider('test', 'api', 'secret')
# Bad auth
with requests_mock() as mock:
mock.get(ANY, status_code=401,
text='{"errors": ["Unable to authenticate token"]}')
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals('Unauthorized', ctx.exception.message)
# Bad request
with requests_mock() as mock:
mock.get(ANY, status_code=400,
text='{"errors": ["\\"unittests\\" is not '
'a valid domain name"]}')
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals('\n - "unittests" is not a valid domain name',
ctx.exception.message)
# General error
with requests_mock() as mock:
mock.get(ANY, status_code=502, text='Things caught fire')
with self.assertRaises(HTTPError) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
# Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='<html><head></head><body></body></html>')
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(set(), zone.records)
# No diffs == no changes
with requests_mock() as mock:
base = 'https://api.dns.constellix.com/v1/domains'
with open('tests/fixtures/constellix-domains.json') as fh:
mock.get('{}{}'.format(base, '/'), text=fh.read())
with open('tests/fixtures/constellix-records.json') as fh:
mock.get('{}{}'.format(base, '/123123/records'),
text=fh.read())
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(15, len(zone.records))
changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes))
# 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', [])
provider.populate(again)
self.assertEquals(15, len(again.records))
# bust the cache
del provider._zone_records[zone.name]
def test_apply(self):
provider = ConstellixProvider('test', 'api', 'secret')
resp = Mock()
resp.json = Mock()
provider._client._request = Mock(return_value=resp)
with open('tests/fixtures/constellix-domains.json') as fh:
domains = json.load(fh)
# non-existent domain, create everything
resp.json.side_effect = [
ConstellixClientNotFound, # no zone in populate
ConstellixClientNotFound, # no domain during apply
domains
]
plan = provider.plan(self.expected)
# No root NS, no ignored, no excluded, no unsupported
n = len(self.expected.records) - 5
self.assertEquals(n, len(plan.changes))
self.assertEquals(n, provider.apply(plan))
provider._client._request.assert_has_calls([
# created the domain
call('POST', '/', data={'names': ['unit.tests']}),
# get all domains to build the cache
call('GET', '/'),
call('POST', '/123123/records/SRV', data={
'roundRobin': [{
'priority': 10,
'weight': 20,
'value': 'foo-1.unit.tests.',
'port': 30
}, {
'priority': 12,
'weight': 20,
'value': 'foo-2.unit.tests.',
'port': 30
}],
'name': '_srv._tcp',
'ttl': 600,
}),
])
self.assertEquals(20, provider._client._request.call_count)
provider._client._request.reset_mock()
provider._client.records = Mock(return_value=[
{
'id': 11189897,
'type': 'A',
'name': 'www',
'ttl': 300,
'value': [
'1.2.3.4',
'2.2.3.4',
]
}, {
'id': 11189898,
'type': 'A',
'name': 'ttl',
'ttl': 600,
'value': [
'3.2.3.4'
]
}
])
# Domain exists, we don't care about return
resp.json.side_effect = ['{}']
wanted = Zone('unit.tests.', [])
wanted.add_record(Record.new(wanted, 'ttl', {
'ttl': 300,
'type': 'A',
'value': '3.2.3.4'
}))
plan = provider.plan(wanted)
self.assertEquals(2, len(plan.changes))
self.assertEquals(2, provider.apply(plan))
# recreate for update, and deletes for the 2 parts of the other
provider._client._request.assert_has_calls([
call('POST', '/123123/records/A', data={
'roundRobin': [{
'value': '3.2.3.4'
}],
'name': 'ttl',
'ttl': 300
}),
call('DELETE', '/123123/records/A/11189897'),
call('DELETE', '/123123/records/A/11189898')
], any_order=True)

+ 2
- 2
tests/test_octodns_provider_digitalocean.py View File

@ -62,7 +62,7 @@ class TestDigitalOceanProvider(TestCase):
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
# Non-existant zone doesn't populate anything
# Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='{"id":"not_found","message":"The resource you '
@ -154,7 +154,7 @@ class TestDigitalOceanProvider(TestCase):
}
}
# non-existant domain, create everything
# non-existent domain, create everything
resp.json.side_effect = [
DigitalOceanClientNotFound, # no zone in populate
DigitalOceanClientNotFound, # no domain during apply


+ 2
- 2
tests/test_octodns_provider_dnsimple.py View File

@ -59,7 +59,7 @@ class TestDnsimpleProvider(TestCase):
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
# Non-existant zone doesn't populate anything
# Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='{"message": "Domain `foo.bar` not found"}')
@ -123,7 +123,7 @@ class TestDnsimpleProvider(TestCase):
resp.json = Mock()
provider._client._request = Mock(return_value=resp)
# non-existant domain, create everything
# non-existent domain, create everything
resp.json.side_effect = [
DnsimpleClientNotFound, # no zone in populate
DnsimpleClientNotFound, # no domain during apply


+ 2
- 2
tests/test_octodns_provider_dnsmadeeasy.py View File

@ -88,7 +88,7 @@ class TestDnsMadeEasyProvider(TestCase):
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
# Non-existant zone doesn't populate anything
# Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='<html><head></head><body></body></html>')
@ -131,7 +131,7 @@ class TestDnsMadeEasyProvider(TestCase):
with open('tests/fixtures/dnsmadeeasy-domains.json') as fh:
domains = json.load(fh)
# non-existant domain, create everything
# non-existent domain, create everything
resp.json.side_effect = [
DnsMadeEasyClientNotFound, # no zone in populate
DnsMadeEasyClientNotFound, # no domain during apply


+ 150
- 0
tests/test_octodns_provider_fastdns.py View File

@ -0,0 +1,150 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
# from mock import Mock, call
from os.path import dirname, join
from requests import HTTPError
from requests_mock import ANY, mock as requests_mock
from unittest import TestCase
from octodns.record import Record
from octodns.provider.fastdns import AkamaiProvider
from octodns.provider.yaml import YamlProvider
from octodns.zone import Zone
class TestFastdnsProvider(TestCase):
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected)
# Our test suite differs a bit, add our NS and remove the simple one
expected.add_record(Record.new(expected, 'under', {
'ttl': 3600,
'type': 'NS',
'values': [
'ns1.unit.tests.',
'ns2.unit.tests.',
]
}))
for record in list(expected.records):
if record.name == 'sub' and record._type == 'NS':
expected._remove_record(record)
break
def test_populate(self):
provider = AkamaiProvider("test", "secret", "akam.com", "atok", "ctok")
# Bad Auth
with requests_mock() as mock:
mock.get(ANY, status_code=401, text='{"message": "Unauthorized"}')
with self.assertRaises(Exception) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(401, ctx.exception.response.status_code)
# general error
with requests_mock() as mock:
mock.get(ANY, status_code=502, text='Things caught fire')
with self.assertRaises(HTTPError) as ctx:
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
# Non-existant zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404,
text='{"message": "Domain `foo.bar` not found"}')
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(set(), zone.records)
# No diffs == no changes
with requests_mock() as mock:
with open('tests/fixtures/fastdns-records.json') as fh:
mock.get(ANY, text=fh.read())
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEquals(16, len(zone.records))
changes = self.expected.changes(zone, provider)
self.assertEquals(0, len(changes))
# 2nd populate makes no network calls/all from cache
again = Zone('unit.tests.', [])
provider.populate(again)
self.assertEquals(16, len(again.records))
# bust the cache
del provider._zone_records[zone.name]
def test_apply(self):
provider = AkamaiProvider("test", "s", "akam.com", "atok", "ctok",
"cid", "gid")
# tests create update delete through previous state config json
with requests_mock() as mock:
with open('tests/fixtures/fastdns-records-prev.json') as fh:
mock.get(ANY, text=fh.read())
plan = provider.plan(self.expected)
mock.post(ANY, status_code=201)
mock.put(ANY, status_code=200)
mock.delete(ANY, status_code=204)
changes = provider.apply(plan)
self.assertEquals(29, changes)
# Test against a zone that doesn't exist yet
with requests_mock() as mock:
with open('tests/fixtures/fastdns-records-prev-other.json') as fh:
mock.get(ANY, status_code=404)
plan = provider.plan(self.expected)
mock.post(ANY, status_code=201)
mock.put(ANY, status_code=200)
mock.delete(ANY, status_code=204)
changes = provider.apply(plan)
self.assertEquals(14, changes)
# Test against a zone that doesn't exist yet, but gid not provided
with requests_mock() as mock:
with open('tests/fixtures/fastdns-records-prev-other.json') as fh:
mock.get(ANY, status_code=404)
provider = AkamaiProvider("test", "s", "akam.com", "atok", "ctok",
"cid")
plan = provider.plan(self.expected)
mock.post(ANY, status_code=201)
mock.put(ANY, status_code=200)
mock.delete(ANY, status_code=204)
changes = provider.apply(plan)
self.assertEquals(14, changes)
# Test against a zone that doesn't exist, but cid not provided
with requests_mock() as mock:
mock.get(ANY, status_code=404)
provider = AkamaiProvider("test", "s", "akam.com", "atok", "ctok")
plan = provider.plan(self.expected)
mock.post(ANY, status_code=201)
mock.put(ANY, status_code=200)
mock.delete(ANY, status_code=204)
try:
changes = provider.apply(plan)
except NameError as e:
expected = "contractId not specified to create zone"
self.assertEquals(e.message, expected)

+ 2
- 2
tests/test_octodns_provider_ns1.py View File

@ -191,7 +191,7 @@ class TestNs1Provider(TestCase):
self.assertEquals(load_mock.side_effect, ctx.exception)
self.assertEquals(('unit.tests',), load_mock.call_args[0])
# Non-existant zone doesn't populate anything
# Non-existent zone doesn't populate anything
load_mock.reset_mock()
load_mock.side_effect = \
ResourceException('server error: zone not found')
@ -323,7 +323,7 @@ class TestNs1Provider(TestCase):
provider.apply(plan)
self.assertEquals(create_mock.side_effect, ctx.exception)
# non-existant zone, create
# non-existent zone, create
load_mock.reset_mock()
create_mock.reset_mock()
load_mock.side_effect = \


+ 4
- 4
tests/test_octodns_provider_powerdns.py View File

@ -42,7 +42,7 @@ with open('./tests/fixtures/powerdns-full-data.json') as fh:
class TestPowerDnsProvider(TestCase):
def test_provider(self):
provider = PowerDnsProvider('test', 'non.existant', 'api-key',
provider = PowerDnsProvider('test', 'non.existent', 'api-key',
nameserver_values=['8.8.8.8.',
'9.9.9.9.'])
@ -64,7 +64,7 @@ class TestPowerDnsProvider(TestCase):
provider.populate(zone)
self.assertEquals(502, ctx.exception.response.status_code)
# Non-existant zone doesn't populate anything
# Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=422,
json={'error': "Could not find domain 'unit.tests.'"})
@ -164,7 +164,7 @@ class TestPowerDnsProvider(TestCase):
provider.apply(plan)
def test_small_change(self):
provider = PowerDnsProvider('test', 'non.existant', 'api-key')
provider = PowerDnsProvider('test', 'non.existent', 'api-key')
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
@ -204,7 +204,7 @@ class TestPowerDnsProvider(TestCase):
def test_existing_nameservers(self):
ns_values = ['8.8.8.8.', '9.9.9.9.']
provider = PowerDnsProvider('test', 'non.existant', 'api-key',
provider = PowerDnsProvider('test', 'non.existent', 'api-key',
nameserver_values=ns_values)
expected = Zone('unit.tests.', [])


+ 1
- 1
tests/test_octodns_provider_route53.py View File

@ -504,7 +504,7 @@ class TestRoute53Provider(TestCase):
'ResourceRecords': [{
'Value': '10 smtp-1.unit.tests.',
}, {
'Value': '20 smtp-2.unit.tests.',
'Value': '20 smtp-2.unit.tests.',
}],
'TTL': 64,
}, {


+ 401
- 0
tests/test_octodns_provider_selectel.py View File

@ -0,0 +1,401 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
from unittest import TestCase
import requests_mock
from octodns.provider.selectel import SelectelProvider
from octodns.record import Record, Update
from octodns.zone import Zone
class TestSelectelProvider(TestCase):
API_URL = 'https://api.selectel.ru/domains/v1'
api_record = []
zone = Zone('unit.tests.', [])
expected = set()
domain = [{"name": "unit.tests", "id": 100000}]
# A, subdomain=''
api_record.append({
'type': 'A',
'ttl': 100,
'content': '1.2.3.4',
'name': 'unit.tests',
'id': 1
})
expected.add(Record.new(zone, '', {
'ttl': 100,
'type': 'A',
'value': '1.2.3.4',
}))
# A, subdomain='sub'
api_record.append({
'type': 'A',
'ttl': 200,
'content': '1.2.3.4',
'name': 'sub.unit.tests',
'id': 2
})
expected.add(Record.new(zone, 'sub', {
'ttl': 200,
'type': 'A',
'value': '1.2.3.4',
}))
# CNAME
api_record.append({
'type': 'CNAME',
'ttl': 300,
'content': 'unit.tests',
'name': 'www2.unit.tests',
'id': 3
})
expected.add(Record.new(zone, 'www2', {
'ttl': 300,
'type': 'CNAME',
'value': 'unit.tests.',
}))
# MX
api_record.append({
'type': 'MX',
'ttl': 400,
'content': 'mx1.unit.tests',
'priority': 10,
'name': 'unit.tests',
'id': 4
})
expected.add(Record.new(zone, '', {
'ttl': 400,
'type': 'MX',
'values': [{
'preference': 10,
'exchange': 'mx1.unit.tests.',
}]
}))
# NS
api_record.append({
'type': 'NS',
'ttl': 600,
'content': 'ns1.unit.tests',
'name': 'unit.tests.',
'id': 6
})
api_record.append({
'type': 'NS',
'ttl': 600,
'content': 'ns2.unit.tests',
'name': 'unit.tests',
'id': 7
})
expected.add(Record.new(zone, '', {
'ttl': 600,
'type': 'NS',
'values': ['ns1.unit.tests.', 'ns2.unit.tests.'],
}))
# NS with sub
api_record.append({
'type': 'NS',
'ttl': 700,
'content': 'ns3.unit.tests',
'name': 'www3.unit.tests',
'id': 8
})
api_record.append({
'type': 'NS',
'ttl': 700,
'content': 'ns4.unit.tests',
'name': 'www3.unit.tests',
'id': 9
})
expected.add(Record.new(zone, 'www3', {
'ttl': 700,
'type': 'NS',
'values': ['ns3.unit.tests.', 'ns4.unit.tests.'],
}))
# SRV
api_record.append({
'type': 'SRV',
'ttl': 800,
'target': 'foo-1.unit.tests',
'weight': 20,
'priority': 10,
'port': 30,
'id': 10,
'name': '_srv._tcp.unit.tests'
})
api_record.append({
'type': 'SRV',
'ttl': 800,
'target': 'foo-2.unit.tests',
'name': '_srv._tcp.unit.tests',
'weight': 50,
'priority': 40,
'port': 60,
'id': 11
})
expected.add(Record.new(zone, '_srv._tcp', {
'ttl': 800,
'type': 'SRV',
'values': [{
'priority': 10,
'weight': 20,
'port': 30,
'target': 'foo-1.unit.tests.',
}, {
'priority': 40,
'weight': 50,
'port': 60,
'target': 'foo-2.unit.tests.',
}]
}))
# AAAA
aaaa_record = {
'type': 'AAAA',
'ttl': 200,
'content': '1:1ec:1::1',
'name': 'unit.tests',
'id': 15
}
api_record.append(aaaa_record)
expected.add(Record.new(zone, '', {
'ttl': 200,
'type': 'AAAA',
'value': '1:1ec:1::1',
}))
# TXT
api_record.append({
'type': 'TXT',
'ttl': 300,
'content': 'little text',
'name': 'text.unit.tests',
'id': 16
})
expected.add(Record.new(zone, 'text', {
'ttl': 200,
'type': 'TXT',
'value': 'little text',
}))
@requests_mock.Mocker()
def test_populate(self, fake_http):
zone = Zone('unit.tests.', [])
fake_http.get('{}/unit.tests/records/'.format(self.API_URL),
json=self.api_record)
fake_http.get('{}/'.format(self.API_URL), json=self.domain)
fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.api_record))})
fake_http.head('{}/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.domain))})
provider = SelectelProvider(123, 'secret_token')
provider.populate(zone)
self.assertEquals(self.expected, zone.records)
@requests_mock.Mocker()
def test_populate_invalid_record(self, fake_http):
more_record = self.api_record
more_record.append({"name": "unit.tests",
"id": 100001,
"content": "support.unit.tests.",
"ttl": 300, "ns": "ns1.unit.tests",
"type": "SOA",
"email": "support@unit.tests"})
zone = Zone('unit.tests.', [])
fake_http.get('{}/unit.tests/records/'.format(self.API_URL),
json=more_record)
fake_http.get('{}/'.format(self.API_URL), json=self.domain)
fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.api_record))})
fake_http.head('{}/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.domain))})
zone.add_record(Record.new(self.zone, 'unsup', {
'ttl': 200,
'type': 'NAPTR',
'value': {
'order': 40,
'preference': 70,
'flags': 'U',
'service': 'SIP+D2U',
'regexp': '!^.*$!sip:info@bar.example.com!',
'replacement': '.',
}
}))
provider = SelectelProvider(123, 'secret_token')
provider.populate(zone)
self.assertNotEqual(self.expected, zone.records)
@requests_mock.Mocker()
def test_apply(self, fake_http):
fake_http.get('{}/unit.tests/records/'.format(self.API_URL),
json=list())
fake_http.get('{}/'.format(self.API_URL), json=self.domain)
fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
headers={'X-Total-Count': '0'})
fake_http.head('{}/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.domain))})
fake_http.post('{}/100000/records/'.format(self.API_URL), json=list())
provider = SelectelProvider(123, 'test_token')
zone = Zone('unit.tests.', [])
for record in self.expected:
zone.add_record(record)
plan = provider.plan(zone)
self.assertEquals(8, len(plan.changes))
self.assertEquals(8, provider.apply(plan))
@requests_mock.Mocker()
def test_domain_list(self, fake_http):
fake_http.get('{}/'.format(self.API_URL), json=self.domain)
fake_http.head('{}/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.domain))})
expected = {'unit.tests': self.domain[0]}
provider = SelectelProvider(123, 'test_token')
result = provider.domain_list()
self.assertEquals(result, expected)
@requests_mock.Mocker()
def test_authentication_fail(self, fake_http):
fake_http.get('{}/'.format(self.API_URL), status_code=401)
fake_http.head('{}/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.domain))})
with self.assertRaises(Exception) as ctx:
SelectelProvider(123, 'fail_token')
self.assertEquals(ctx.exception.message,
'Authorization failed. Invalid or empty token.')
@requests_mock.Mocker()
def test_not_exist_domain(self, fake_http):
fake_http.get('{}/'.format(self.API_URL), status_code=404, json='')
fake_http.head('{}/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.domain))})
fake_http.post('{}/'.format(self.API_URL),
json={"name": "unit.tests",
"create_date": 1507154178,
"id": 100000})
fake_http.get('{}/unit.tests/records/'.format(self.API_URL),
json=list())
fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.api_record))})
fake_http.post('{}/100000/records/'.format(self.API_URL),
json=list())
provider = SelectelProvider(123, 'test_token')
zone = Zone('unit.tests.', [])
for record in self.expected:
zone.add_record(record)
plan = provider.plan(zone)
self.assertEquals(8, len(plan.changes))
self.assertEquals(8, provider.apply(plan))
@requests_mock.Mocker()
def test_delete_no_exist_record(self, fake_http):
fake_http.get('{}/'.format(self.API_URL), json=self.domain)
fake_http.get('{}/100000/records/'.format(self.API_URL), json=list())
fake_http.head('{}/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.domain))})
fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
headers={'X-Total-Count': '0'})
provider = SelectelProvider(123, 'test_token')
zone = Zone('unit.tests.', [])
provider.delete_record('unit.tests', 'NS', zone)
@requests_mock.Mocker()
def test_change_record(self, fake_http):
exist_record = [self.aaaa_record,
{"content": "6.6.5.7",
"ttl": 100,
"type": "A",
"id": 100001,
"name": "delete.unit.tests"},
{"content": "9.8.2.1",
"ttl": 100,
"type": "A",
"id": 100002,
"name": "unit.tests"}] # exist
fake_http.get('{}/unit.tests/records/'.format(self.API_URL),
json=exist_record)
fake_http.get('{}/'.format(self.API_URL), json=self.domain)
fake_http.get('{}/100000/records/'.format(self.API_URL),
json=exist_record)
fake_http.head('{}/unit.tests/records/'.format(self.API_URL),
headers={'X-Total-Count': str(len(exist_record))})
fake_http.head('{}/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.domain))})
fake_http.head('{}/100000/records/'.format(self.API_URL),
headers={'X-Total-Count': str(len(exist_record))})
fake_http.post('{}/100000/records/'.format(self.API_URL),
json=list())
fake_http.delete('{}/100000/records/100001'.format(self.API_URL),
text="")
fake_http.delete('{}/100000/records/100002'.format(self.API_URL),
text="")
provider = SelectelProvider(123, 'test_token')
zone = Zone('unit.tests.', [])
for record in self.expected:
zone.add_record(record)
plan = provider.plan(zone)
self.assertEquals(8, len(plan.changes))
self.assertEquals(8, provider.apply(plan))
@requests_mock.Mocker()
def test_include_change_returns_false(self, fake_http):
fake_http.get('{}/'.format(self.API_URL), json=self.domain)
fake_http.head('{}/'.format(self.API_URL),
headers={'X-Total-Count': str(len(self.domain))})
provider = SelectelProvider(123, 'test_token')
zone = Zone('unit.tests.', [])
exist_record = Record.new(zone, '', {
'ttl': 60,
'type': 'A',
'values': ['1.1.1.1', '2.2.2.2']
})
new = Record.new(zone, '', {
'ttl': 10,
'type': 'A',
'values': ['1.1.1.1', '2.2.2.2']
})
change = Update(exist_record, new)
include_change = provider._include_change(change)
self.assertFalse(include_change)

+ 275
- 0
tests/test_octodns_provider_transip.py View File

@ -0,0 +1,275 @@
#
#
#
from __future__ import absolute_import, division, print_function, \
unicode_literals
# from mock import Mock, call
from os.path import dirname, join
from suds import WebFault
from unittest import TestCase
from octodns.provider.transip import TransipProvider
from octodns.provider.yaml import YamlProvider
from octodns.zone import Zone
from transip.service.domain import DomainService
from transip.service.objects import DnsEntry
class MockFault(object):
faultstring = ""
faultcode = ""
def __init__(self, code, string, *args, **kwargs):
self.faultstring = string
self.faultcode = code
class MockResponse(object):
dnsEntries = []
class MockDomainService(DomainService):
def __init__(self, *args, **kwargs):
super(MockDomainService, self).__init__('MockDomainService', *args,
**kwargs)
self.mockupEntries = []
def mockup(self, records):
provider = TransipProvider('', '', '')
_dns_entries = []
for record in records:
if record._type in provider.SUPPORTS:
entries_for = getattr(provider,
'_entries_for_{}'.format(record._type))
# Root records have '@' as name
name = record.name
if name == '':
name = provider.ROOT_RECORD
_dns_entries.extend(entries_for(name, record))
# NS is not supported as a DNS Entry,
# so it should cover the if statement
_dns_entries.append(
DnsEntry('@', '3600', 'NS', 'ns01.transip.nl.'))
self.mockupEntries = _dns_entries
# Skips authentication layer and returns the entries loaded by "Mockup"
def get_info(self, domain_name):
# Special 'domain' to trigger error
if str(domain_name) == str('notfound.unit.tests'):
self.raiseZoneNotFound()
result = MockResponse()
result.dnsEntries = self.mockupEntries
return result
def set_dns_entries(self, domain_name, dns_entries):
# Special 'domain' to trigger error
if str(domain_name) == str('failsetdns.unit.tests'):
self.raiseSaveError()
return True
def raiseZoneNotFound(self):
fault = MockFault(str('102'), '102 is zone not found')
document = {}
raise WebFault(fault, document)
def raiseInvalidAuth(self):
fault = MockFault(str('200'), '200 is invalid auth')
document = {}
raise WebFault(fault, document)
def raiseSaveError(self):
fault = MockFault(str('200'), '202 random error')
document = {}
raise WebFault(fault, document)
class TestTransipProvider(TestCase):
bogus_key = str("""-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA0U5HGCkLrz423IyUf3u4cKN2WrNz1x5KNr6PvH2M/zxas+zB
elbxkdT3AQ+wmfcIvOuTmFRTHv35q2um1aBrPxVw+2s+lWo28VwIRttwIB1vIeWu
lSBnkEZQRLyPI2tH0i5QoMX4CVPf9rvij3Uslimi84jdzDfPFIh6jZ6C8nLipOTG
0IMhge1ofVfB0oSy5H+7PYS2858QLAf5ruYbzbAxZRivS402wGmQ0d0Lc1KxraAj
kiMM5yj/CkH/Vm2w9I6+tLFeASE4ub5HCP5G/ig4dbYtqZMQMpqyAbGxd5SOVtyn
UHagAJUxf8DT3I8PyjEHjxdOPUsxNyRtepO/7QIDAQABAoIBAQC7fiZ7gxE/ezjD
2n6PsHFpHVTBLS2gzzZl0dCKZeFvJk6ODJDImaeuHhrh7X8ifMNsEI9XjnojMhl8
MGPzy88mZHugDNK0H8B19x5G8v1/Fz7dG5WHas660/HFkS+b59cfdXOugYiOOn9O
08HBBpLZNRUOmVUuQfQTjapSwGLG8PocgpyRD4zx0LnldnJcqYCxwCdev+AAsPnq
ibNtOd/MYD37w9MEGcaxLE8wGgkv8yd97aTjkgE+tp4zsM4QE4Rag133tsLLNznT
4Qr/of15M3NW/DXq/fgctyRcJjZpU66eCXLCz2iRTnLyyxxDC2nwlxKbubV+lcS0
S4hbfd/BAoGBAO8jXxEaiybR0aIhhSR5esEc3ymo8R8vBN3ZMJ+vr5jEPXr/ZuFj
/R4cZ2XV3VoQJG0pvIOYVPZ5DpJM7W+zSXtJ/7bLXy4Bnmh/rc+YYgC+AXQoLSil
iD2OuB2xAzRAK71DVSO0kv8gEEXCersPT2i6+vC2GIlJvLcYbOdRKWGxAoGBAOAQ
aJbRLtKujH+kMdoMI7tRlL8XwI+SZf0FcieEu//nFyerTePUhVgEtcE+7eQ7hyhG
fIXUFx/wALySoqFzdJDLc8U8pTLhbUaoLOTjkwnCTKQVprhnISqQqqh/0U5u47IE
RWzWKN6OHb0CezNTq80Dr6HoxmPCnJHBHn5LinT9AoGAQSpvZpbIIqz8pmTiBl2A
QQ2gFpcuFeRXPClKYcmbXVLkuhbNL1BzEniFCLAt4LQTaRf9ghLJ3FyCxwVlkpHV
zV4N6/8hkcTpKOraL38D/dXJSaEFJVVuee/hZl3tVJjEEpA9rDwx7ooLRSdJEJ6M
ciq55UyKBSdt4KssSiDI2RECgYBL3mJ7xuLy5bWfNsrGiVvD/rC+L928/5ZXIXPw
26oI0Yfun7ulDH4GOroMcDF/GYT/Zzac3h7iapLlR0WYI47xxGI0A//wBZLJ3QIu
krxkDo2C9e3Y/NqnHgsbOQR3aWbiDT4wxydZjIeXS3LKA2fl6Hyc90PN3cTEOb8I
hq2gRQKBgEt0SxhhtyB93SjgTzmUZZ7PiEf0YJatfM6cevmjWHexrZH+x31PB72s
fH2BQyTKKzoCLB1k/6HRaMnZdrWyWSZ7JKz3AHJ8+58d0Hr8LTrzDM1L6BbjeDct
N4OiVz1I3rbZGYa396lpxO6ku8yCglisL1yrSP6DdEUp66ntpKVd
-----END RSA PRIVATE KEY-----""")
def make_expected(self):
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected)
return expected
def test_init(self):
with self.assertRaises(Exception) as ctx:
TransipProvider('test', 'unittest')
self.assertEquals(
str('Missing `key` of `key_file` parameter in config'),
str(ctx.exception))
TransipProvider('test', 'unittest', key=self.bogus_key)
# Existence and content of the key is tested in the SDK on client call
TransipProvider('test', 'unittest', key_file='/fake/path')
def test_populate(self):
_expected = self.make_expected()
# Unhappy Plan - Not authenticated
# Live test against API, will fail in an unauthorized error
with self.assertRaises(WebFault) as ctx:
provider = TransipProvider('test', 'unittest', self.bogus_key)
zone = Zone('unit.tests.', [])
provider.populate(zone, True)
self.assertEquals(str('WebFault'),
str(ctx.exception.__class__.__name__))
self.assertEquals(str('200'), ctx.exception.fault.faultcode)
# Unhappy Plan - Zone does not exists
# Will trigger an exception if provider is used as a target for a
# non-existing zone
with self.assertRaises(Exception) as ctx:
provider = TransipProvider('test', 'unittest', self.bogus_key)
provider._client = MockDomainService('unittest', self.bogus_key)
zone = Zone('notfound.unit.tests.', [])
provider.populate(zone, True)
self.assertEquals(str('TransipNewZoneException'),
str(ctx.exception.__class__.__name__))
self.assertEquals(
'populate: (102) Transip used as target' +
' for non-existing zone: notfound.unit.tests.',
ctx.exception.message)
# Happy Plan - Zone does not exists
# Won't trigger an exception if provider is NOT used as a target for a
# non-existing zone.
provider = TransipProvider('test', 'unittest', self.bogus_key)
provider._client = MockDomainService('unittest', self.bogus_key)
zone = Zone('notfound.unit.tests.', [])
provider.populate(zone, False)
# Happy Plan - Populate with mockup records
provider = TransipProvider('test', 'unittest', self.bogus_key)
provider._client = MockDomainService('unittest', self.bogus_key)
provider._client.mockup(_expected.records)
zone = Zone('unit.tests.', [])
provider.populate(zone, False)
# Transip allows relative values for types like cname, mx.
# Test is these are correctly appended with the domain
provider._currentZone = zone
self.assertEquals("www.unit.tests.", provider._parse_to_fqdn("www"))
self.assertEquals("www.unit.tests.",
provider._parse_to_fqdn("www.unit.tests."))
self.assertEquals("www.sub.sub.sub.unit.tests.",
provider._parse_to_fqdn("www.sub.sub.sub"))
self.assertEquals("unit.tests.",
provider._parse_to_fqdn("@"))
# Happy Plan - Even if the zone has no records the zone should exist
provider = TransipProvider('test', 'unittest', self.bogus_key)
provider._client = MockDomainService('unittest', self.bogus_key)
zone = Zone('unit.tests.', [])
exists = provider.populate(zone, True)
self.assertTrue(exists, 'populate should return true')
return
def test_plan(self):
_expected = self.make_expected()
# Test Happy plan, only create
provider = TransipProvider('test', 'unittest', self.bogus_key)
provider._client = MockDomainService('unittest', self.bogus_key)
plan = provider.plan(_expected)
self.assertEqual(12, plan.change_counts['Create'])
self.assertEqual(0, plan.change_counts['Update'])
self.assertEqual(0, plan.change_counts['Delete'])
return
def test_apply(self):
_expected = self.make_expected()
# Test happy flow. Create all supoorted records
provider = TransipProvider('test', 'unittest', self.bogus_key)
provider._client = MockDomainService('unittest', self.bogus_key)
plan = provider.plan(_expected)
self.assertEqual(12, len(plan.changes))
changes = provider.apply(plan)
self.assertEqual(changes, len(plan.changes))
# Test unhappy flow. Trigger 'not found error' in apply stage
# This should normally not happen as populate will capture it first
# but just in case.
changes = [] # reset changes
with self.assertRaises(Exception) as ctx:
provider = TransipProvider('test', 'unittest', self.bogus_key)
provider._client = MockDomainService('unittest', self.bogus_key)
plan = provider.plan(_expected)
plan.desired.name = 'notfound.unit.tests.'
changes = provider.apply(plan)
# Changes should not be set due to an Exception
self.assertEqual([], changes)
self.assertEquals(str('WebFault'),
str(ctx.exception.__class__.__name__))
self.assertEquals(str('102'), ctx.exception.fault.faultcode)
# Test unhappy flow. Trigger a unrecoverable error while saving
_expected = self.make_expected() # reset expected
changes = [] # reset changes
with self.assertRaises(Exception) as ctx:
provider = TransipProvider('test', 'unittest', self.bogus_key)
provider._client = MockDomainService('unittest', self.bogus_key)
plan = provider.plan(_expected)
plan.desired.name = 'failsetdns.unit.tests.'
changes = provider.apply(plan)
# Changes should not be set due to an Exception
self.assertEqual([], changes)
self.assertEquals(str('TransipException'),
str(ctx.exception.__class__.__name__))

+ 3
- 3
tests/test_octodns_record.py View File

@ -3166,7 +3166,7 @@ class TestDynamicRecords(TestCase):
self.assertEquals(['rule 1 invalid pool "[]"'],
ctx.exception.reasons)
# rule references non-existant pool
# rule references non-existent pool
a_data = {
'dynamic': {
'pools': {
@ -3185,7 +3185,7 @@ class TestDynamicRecords(TestCase):
},
'rules': [{
'geos': ['NA-US-CA'],
'pool': 'non-existant',
'pool': 'non-existent',
}, {
'pool': 'one',
}],
@ -3199,7 +3199,7 @@ class TestDynamicRecords(TestCase):
}
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'bad', a_data)
self.assertEquals(["rule 1 undefined pool \"non-existant\""],
self.assertEquals(["rule 1 undefined pool \"non-existent\""],
ctx.exception.reasons)
# rule with invalid geos


Loading…
Cancel
Save