Browse Source

Extract UltraProvider from octoDNS core

pull/859/head
Ross McFarland 4 years ago
parent
commit
aecbb61ea5
No known key found for this signature in database GPG Key ID: 943B179E15D3B22A
8 changed files with 31 additions and 1379 deletions
  1. +1
    -0
      CHANGELOG.md
  2. +1
    -1
      README.md
  3. +18
    -454
      octodns/provider/ultra.py
  4. +0
    -94
      tests/fixtures/ultra-records-page-1.json
  5. +0
    -51
      tests/fixtures/ultra-records-page-2.json
  6. +0
    -135
      tests/fixtures/ultra-zones-page-1.json
  7. +0
    -135
      tests/fixtures/ultra-zones-page-2.json
  8. +11
    -509
      tests/test_octodns_provider_ultra.py

+ 1
- 0
CHANGELOG.md View File

@ -29,6 +29,7 @@
AwsAcmMangingProcessor
* [SelectelProvider](https://github.com/octodns/octodns-selectel/)
* [TransipProvider](https://github.com/octodns/octodns-transip/)
* [UltraDnsProvider](https://github.com/octodns/octodns-ultradns/)
* NS1 provider has received improvements to the dynamic record implementation.
As a result, if octoDNS is downgraded from this version, any dynamic records
created or updated using this version will show an update.


+ 1
- 1
README.md View File

@ -215,7 +215,7 @@ The table below lists the providers octoDNS supports. We're currently in the pro
| [Rackspace](https://www.rackspace.com/library/what-is-dns) | [octodns_rackspace](https://github.com/octodns/octodns-rackspace/) | | | | |
| [Selectel](https://selectel.ru/en/services/additional/dns/) | [octodns_selectel](https://github.com/octodns/octodns-selectel/) | | | | |
| [Transip](https://www.transip.eu/knowledgebase/entry/155-dns-and-nameservers/) | [octodns_transip](https://github.com/octodns/octodns-transip/) | | | | |
| [UltraDns](/octodns/provider/ultra.py) | | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | |
| [Ultra Dns](https://www.home.neustar/dns-services) | [octodns_ultra](https://github.com/octodns/octodns-ultra/) | | | | |
| [AxfrSource](/octodns/source/axfr.py) | | | A, AAAA, CAA, CNAME, LOC, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [ZoneFileSource](/octodns/source/axfr.py) | | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [TinyDnsFileSource](/octodns/source/tinydns.py) | | | A, CNAME, MX, NS, PTR | No | read-only |


+ 18
- 454
octodns/provider/ultra.py View File

@ -1,457 +1,21 @@
from collections import defaultdict
from logging import getLogger
from requests import Session
from ..record import Record
from . import ProviderException
from .base import BaseProvider
class UltraClientException(ProviderException):
'''
Base Ultra exception type
'''
pass
class UltraNoZonesExistException(UltraClientException):
'''
Specially handling this condition where no zones exist in an account.
This is not an error exactly yet ultra treats this scenario as though a
failure has occurred.
'''
def __init__(self, data):
super(UltraNoZonesExistException, self).__init__('NoZonesExist')
class UltraClientUnauthorized(UltraClientException):
'''
Exception for invalid credentials.
'''
def __init__(self):
super(UltraClientUnauthorized, self).__init__('Unauthorized')
class UltraProvider(BaseProvider):
'''
Neustar UltraDNS provider
Documentation for Ultra REST API:
https://ultra-portalstatic.ultradns.com/static/docs/REST-API_User_Guide.pdf
Implemented to the May 26, 2021 version of the document (dated on page ii)
Also described as Version 3.18.0 (title page)
Tested against 3.20.1-20210521075351.36b9297
As determined by querying https://api.ultradns.com/version
ultra:
class: octodns.provider.ultra.UltraProvider
# Ultra Account Name (required)
account: acct
# Ultra username (required)
username: user
# Ultra password (required)
password: pass
'''
RECORDS_TO_TYPE = {
'A (1)': 'A',
'AAAA (28)': 'AAAA',
'APEXALIAS (65282)': 'ALIAS',
'CAA (257)': 'CAA',
'CNAME (5)': 'CNAME',
'MX (15)': 'MX',
'NS (2)': 'NS',
'PTR (12)': 'PTR',
'SPF (99)': 'SPF',
'SRV (33)': 'SRV',
'TXT (16)': 'TXT',
}
TYPE_TO_RECORDS = {v: k for k, v in RECORDS_TO_TYPE.items()}
SUPPORTS = set(TYPE_TO_RECORDS.keys())
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
TIMEOUT = 5
ZONE_REQUEST_LIMIT = 100
def _request(self, method, path, params=None,
data=None, json=None, json_response=True):
self.log.debug('_request: method=%s, path=%s', method, path)
url = f'{self._base_uri}{path}'
resp = self._sess.request(method,
url,
params=params,
data=data,
json=json,
timeout=self._timeout)
self.log.debug('_request: status=%d', resp.status_code)
if resp.status_code == 401:
raise UltraClientUnauthorized()
if json_response:
payload = resp.json()
# Expected return value when no zones exist in an account
if resp.status_code == 404 and len(payload) == 1 and \
payload[0]['errorCode'] == 70002:
raise UltraNoZonesExistException(resp)
else:
payload = resp.text
resp.raise_for_status()
return payload
def _get(self, path, **kwargs):
return self._request('GET', path, **kwargs)
def _post(self, path, **kwargs):
return self._request('POST', path, **kwargs)
def _delete(self, path, **kwargs):
return self._request('DELETE', path, **kwargs)
def _put(self, path, **kwargs):
return self._request('PUT', path, **kwargs)
def _login(self, username, password):
'''
Get an authorization token by logging in using the provided credentials
'''
path = '/v2/authorization/token'
data = {
'grant_type': 'password',
'username': username,
'password': password
}
resp = self._post(path, data=data)
self._sess.headers.update({
'Authorization': f'Bearer {resp["access_token"]}',
})
def __init__(self, id, account, username, password, timeout=TIMEOUT,
*args, **kwargs):
self.log = getLogger(f'UltraProvider[{id}]')
self.log.debug('__init__: id=%s, account=%s, username=%s, '
'password=***', id, account, username)
super(UltraProvider, self).__init__(id, *args, **kwargs)
self._base_uri = 'https://restapi.ultradns.com'
self._sess = Session()
self._account = account
self._timeout = timeout
self._login(username, password)
self._zones = None
self._zone_records = {}
@property
def zones(self):
if self._zones is None:
offset = 0
limit = self.ZONE_REQUEST_LIMIT
zones = []
paging = True
while paging:
data = {'limit': limit, 'q': 'zone_type:PRIMARY',
'offset': offset}
try:
resp = self._get('/v2/zones', params=data)
except UltraNoZonesExistException:
paging = False
continue
zones.extend(resp['zones'])
info = resp['resultInfo']
if info['offset'] + info['returnedCount'] < info['totalCount']:
offset += info['returnedCount']
else:
paging = False
self._zones = [z['properties']['name'] for z in zones]
return self._zones
def _data_for_multiple(self, _type, records):
return {
'ttl': records['ttl'],
'type': _type,
'values': records['rdata'],
}
_data_for_A = _data_for_multiple
_data_for_SPF = _data_for_multiple
_data_for_NS = _data_for_multiple
def _data_for_TXT(self, _type, records):
return {
'ttl': records['ttl'],
'type': _type,
'values': [r.replace(';', '\\;') for r in records['rdata']],
}
def _data_for_AAAA(self, _type, records):
return {
'ttl': records['ttl'],
'type': _type,
'values': records['rdata'],
}
#
#
#
def _data_for_single(self, _type, record):
return {
'type': _type,
'ttl': record['ttl'],
'value': record['rdata'][0],
}
from __future__ import absolute_import, division, print_function, \
unicode_literals
_data_for_PTR = _data_for_single
_data_for_CNAME = _data_for_single
_data_for_ALIAS = _data_for_single
def _data_for_CAA(self, _type, records):
return {
'type': _type,
'ttl': records['ttl'],
'values': [{'flags': x.split()[0],
'tag': x.split()[1],
'value': x.split()[2].strip('"')}
for x in records['rdata']]
}
def _data_for_MX(self, _type, records):
return {
'type': _type,
'ttl': records['ttl'],
'values': [{'preference': x.split()[0],
'exchange': x.split()[1]}
for x in records['rdata']]
}
def _data_for_SRV(self, _type, records):
return {
'type': _type,
'ttl': records['ttl'],
'values': [{
'priority': x.split()[0],
'weight': x.split()[1],
'port': x.split()[2],
'target': x.split()[3],
} for x in records['rdata']]
}
def zone_records(self, zone):
if zone.name not in self._zone_records:
if zone.name not in self.zones:
return []
records = []
path = f'/v2/zones/{zone.name}/rrsets'
offset = 0
limit = 100
paging = True
while paging:
resp = self._get(path,
params={'offset': offset, 'limit': limit})
records.extend(resp['rrSets'])
info = resp['resultInfo']
if info['offset'] + info['returnedCount'] < info['totalCount']:
offset += info['returnedCount']
else:
paging = False
self._zone_records[zone.name] = records
return self._zone_records[zone.name]
def _record_for(self, zone, name, _type, records, lenient):
data_for = getattr(self, f'_data_for_{_type}')
data = data_for(_type, records)
record = Record.new(zone, name, data, source=self, lenient=lenient)
return record
def populate(self, zone, target=False, lenient=False):
self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name,
target, lenient)
exists = False
before = len(zone.records)
records = self.zone_records(zone)
if records:
exists = True
values = defaultdict(lambda: defaultdict(None))
for record in records:
name = zone.hostname_from_fqdn(record['ownerName'])
if record['rrtype'] == 'SOA (6)':
continue
try:
_type = self.RECORDS_TO_TYPE[record['rrtype']]
except KeyError:
self.log.warning('populate: ignoring record with '
'unsupported rrtype, %s %s',
name, record['rrtype'])
continue
values[name][_type] = record
for name, types in values.items():
for _type, records in types.items():
record = self._record_for(zone, name, _type, records,
lenient)
zone.add_record(record, lenient=lenient)
self.log.info('populate: found %s records, exists=%s',
len(zone.records) - before, exists)
return exists
def _apply(self, plan):
desired = plan.desired
changes = plan.changes
self.log.debug('_apply: zone=%s, len(changes)=%d', desired.name,
len(changes))
name = desired.name
if name not in self.zones:
self.log.debug('_apply: no matching zone, creating')
data = {'properties': {'name': name,
'accountName': self._account,
'type': 'PRIMARY'},
'primaryCreateInfo': {
'createType': 'NEW'}}
self._post('/v2/zones', json=data)
self.zones.append(name)
self._zone_records[name] = {}
for change in changes:
class_name = change.__class__.__name__
getattr(self, f'_apply_{class_name}')(change)
# Clear the cache
self._zone_records.pop(name, None)
def _contents_for_multiple_resource_distribution(self, record):
if len(record.values) > 1:
return {
'ttl': record.ttl,
'rdata': record.values,
'profile': {
'@context':
'http://schemas.ultradns.com/RDPool.jsonschema',
'order': 'FIXED',
'description': record.fqdn
}
}
return {
'ttl': record.ttl,
'rdata': record.values
}
_contents_for_A = _contents_for_multiple_resource_distribution
_contents_for_AAAA = _contents_for_multiple_resource_distribution
def _contents_for_multiple(self, record):
return {
'ttl': record.ttl,
'rdata': record.values
}
_contents_for_NS = _contents_for_multiple
_contents_for_SPF = _contents_for_multiple
def _contents_for_TXT(self, record):
return {
'ttl': record.ttl,
'rdata': [v.replace('\\;', ';') for v in record.values]
}
def _contents_for_CNAME(self, record):
return {
'ttl': record.ttl,
'rdata': [record.value]
}
_contents_for_PTR = _contents_for_CNAME
_contents_for_ALIAS = _contents_for_CNAME
def _contents_for_SRV(self, record):
return {
'ttl': record.ttl,
'rdata': [f'{x.priority} {x.weight} {x.port} {x.target}'
for x in record.values]
}
def _contents_for_CAA(self, record):
return {
'ttl': record.ttl,
'rdata': [f'{x.flags} {x.tag} {x.value}' for x in record.values]
}
def _contents_for_MX(self, record):
return {
'ttl': record.ttl,
'rdata': [f'{x.preference} {x.exchange}' for x in record.values]
}
def _gen_data(self, record):
zone_name = self._remove_prefix(record.fqdn, record.name + '.')
# UltraDNS treats the `APEXALIAS` type as the octodns `ALIAS`.
if record._type == "ALIAS":
record_type = "APEXALIAS"
else:
record_type = record._type
path = f'/v2/zones/{zone_name}/rrsets/{record_type}/{record.fqdn}'
contents_for = getattr(self, f'_contents_for_{record._type}')
return path, contents_for(record)
def _apply_Create(self, change):
new = change.new
self.log.debug("_apply_Create: name=%s type=%s ttl=%s",
new.name,
new._type,
new.ttl)
path, content = self._gen_data(new)
self._post(path, json=content)
def _apply_Update(self, change):
new = change.new
self.log.debug("_apply_Update: name=%s type=%s ttl=%s",
new.name,
new._type,
new.ttl)
path, content = self._gen_data(new)
self.log.debug(path)
self.log.debug(content)
self._put(path, json=content)
def _remove_prefix(self, text, prefix):
if text.startswith(prefix):
return text[len(prefix):]
return text
def _apply_Delete(self, change):
existing = change.existing
for record in self.zone_records(existing.zone):
if record['rrtype'] == 'SOA (6)':
continue
if existing.fqdn == record['ownerName'] and \
existing._type == self.RECORDS_TO_TYPE[record['rrtype']]:
zone_name = self._remove_prefix(existing.fqdn,
existing.name + '.')
# UltraDNS treats the `APEXALIAS` type as the octodns `ALIAS`.
existing_type = existing._type
if existing_type == "ALIAS":
existing_type = "APEXALIAS"
from logging import getLogger
path = f'/v2/zones/{zone_name}/rrsets/{existing_type}/' + \
existing.fqdn
self._delete(path, json_response=False)
logger = getLogger('Ultra')
try:
logger.warning('octodns_ultra shimmed. Update your provider class to '
'octodns_ultra.UltraProvider. '
'Shim will be removed in 1.0')
from octodns_ultra import UltraProvider
UltraProvider # pragma: no cover
except ModuleNotFoundError:
logger.exception('UltraProvider has been moved into a seperate module, '
'octodns_ultra is now required. Provider class should '
'be updated to octodns_ultra.UltraProvider')
raise

+ 0
- 94
tests/fixtures/ultra-records-page-1.json View File

@ -1,94 +0,0 @@
{
"zoneName": "octodns1.test.",
"rrSets": [
{
"ownerName": "_srv._tcp.octodns1.test.",
"rrtype": "SRV (33)",
"ttl": 3600,
"rdata": [
"0 20 443 cname.octodns1.test."
]
},
{
"ownerName": "a.octodns1.test.",
"rrtype": "A (1)",
"ttl": 3600,
"rdata": [
"1.1.1.1"
]
},
{
"ownerName": "aaaa.octodns1.test.",
"rrtype": "AAAA (28)",
"ttl": 3600,
"rdata": [
"0:0:0:0:0:0:0:1"
]
},
{
"ownerName": "caa.octodns1.test.",
"rrtype": "CAA (257)",
"ttl": 3600,
"rdata": [
"0 issue \"symantec.com\""
]
},
{
"ownerName": "cname.octodns1.test.",
"rrtype": "CNAME (5)",
"ttl": 60,
"rdata": [
"a.octodns1.test."
]
},
{
"ownerName": "mail.octodns1.test.",
"rrtype": "MX (15)",
"ttl": 3600,
"rdata": [
"1 aspmx.l.google.com.",
"5 alt1.aspmx.l.google.com."
]
},
{
"ownerName": "octodns1.test.",
"rrtype": "NS (2)",
"ttl": 86400,
"rdata": [
"pdns1.ultradns.biz.",
"pdns1.ultradns.com.",
"pdns1.ultradns.net.",
"pdns1.ultradns.org."
]
},
{
"ownerName": "octodns1.test.",
"rrtype": "SOA (6)",
"ttl": 86400,
"rdata": [
"pdns1.ultradns.com. phelps.netflix.com. 2020062003 86400 86400 86400 86400"
]
},
{
"ownerName": "ptr.octodns1.test.",
"rrtype": "PTR (12)",
"ttl": 300,
"rdata": [
"foo.bar.com."
]
},
{
"ownerName": "spf.octodns1.test.",
"rrtype": "SPF (99)",
"ttl": 3600,
"rdata": [
"v=spf1 -all"
]
}
],
"resultInfo": {
"totalCount": 13,
"offset": 0,
"returnedCount": 10
}
}

+ 0
- 51
tests/fixtures/ultra-records-page-2.json View File

@ -1,51 +0,0 @@
{
"zoneName": "octodns1.test.",
"rrSets": [
{
"ownerName": "txt.octodns1.test.",
"rrtype": "TXT (16)",
"ttl": 3600,
"rdata": [
"foobar",
"v=spf1 -all"
]
},
{
"ownerName": "octodns1.test.",
"rrtype": "A (1)",
"ttl": 3600,
"rdata": [
"1.2.3.4",
"1.2.3.5",
"1.2.3.6"
],
"profile": {
"@context": "http://schemas.ultradns.com/RDPool.jsonschema",
"order": "FIXED",
"description": "octodns1.test."
}
},
{
"ownerName": "octodns1.test.",
"rrtype": "APEXALIAS (65282)",
"ttl": 3600,
"rdata": [
"www.octodns1.test."
]
},
{
"ownerName": "host1.octodns1.test.",
"rrtype": "RRSET (70)",
"ttl": 3600,
"rdata": [
"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
]
}
],
"resultInfo": {
"totalCount": 13,
"offset": 10,
"returnedCount": 3
}
}

+ 0
- 135
tests/fixtures/ultra-zones-page-1.json View File

@ -1,135 +0,0 @@
{
"queryInfo": {
"q": "zone_type:PRIMARY",
"sort": "NAME",
"reverse": false,
"limit": 10
},
"resultInfo": {
"totalCount": 20,
"offset": 0,
"returnedCount": 10
},
"zones": [
{
"properties": {
"name": "octodns1.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 6,
"lastModifiedDateTime": "2020-06-19T01:05Z"
}
},
{
"properties": {
"name": "octodns10.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
},
{
"properties": {
"name": "octodns11.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
},
{
"properties": {
"name": "octodns12.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
},
{
"properties": {
"name": "octodns13.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
},
{
"properties": {
"name": "octodns14.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
},
{
"properties": {
"name": "octodns15.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
},
{
"properties": {
"name": "octodns16.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
},
{
"properties": {
"name": "octodns17.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
},
{
"properties": {
"name": "octodns18.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:07Z"
}
}
]
}

+ 0
- 135
tests/fixtures/ultra-zones-page-2.json View File

@ -1,135 +0,0 @@
{
"queryInfo": {
"q": "zone_type:PRIMARY",
"sort": "NAME",
"reverse": false,
"limit": 10
},
"resultInfo": {
"totalCount": 20,
"offset": 10,
"returnedCount": 10
},
"zones": [
{
"properties": {
"name": "octodns19.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:07Z"
}
},
{
"properties": {
"name": "octodns2.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:05Z"
}
},
{
"properties": {
"name": "octodns20.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:07Z"
}
},
{
"properties": {
"name": "octodns3.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:05Z"
}
},
{
"properties": {
"name": "octodns4.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:05Z"
}
},
{
"properties": {
"name": "octodns5.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:05Z"
}
},
{
"properties": {
"name": "octodns6.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:05Z"
}
},
{
"properties": {
"name": "octodns7.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
},
{
"properties": {
"name": "octodns8.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
},
{
"properties": {
"name": "octodns9.test.",
"accountName": "Netflix - Automation",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "phelpstest",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T01:06Z"
}
}
]
}

+ 11
- 509
tests/test_octodns_provider_ultra.py View File

@ -1,514 +1,16 @@
from __future__ import 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 urllib.parse import parse_qs
from unittest import TestCase
from json import load as json_load
from octodns.record import Record
from octodns.provider.ultra import UltraProvider, UltraNoZonesExistException
from octodns.provider.yaml import YamlProvider
from octodns.zone import Zone
def _get_provider():
'''
Helper to return a provider after going through authentication sequence
'''
with requests_mock() as mock:
mock.post('https://restapi.ultradns.com/v2/authorization/token',
status_code=200,
text='{"token type": "Bearer", "refresh_token": "abc", '
'"access_token":"123", "expires_in": "3600"}')
return UltraProvider('test', 'testacct', 'user', 'pass')
class TestUltraProvider(TestCase):
expected = Zone('unit.tests.', [])
host = 'https://restapi.ultradns.com'
empty_body = [{"errorCode": 70002, "errorMessage": "Data not found."}]
expected = Zone('unit.tests.', [])
source = YamlProvider('test', join(dirname(__file__), 'config'))
source.populate(expected)
def test_login(self):
path = '/v2/authorization/token'
# Bad Auth
with requests_mock() as mock:
mock.post(f'{self.host}{path}', status_code=401,
text='{"errorCode": 60001}')
with self.assertRaises(Exception) as ctx:
UltraProvider('test', 'account', 'user', 'wrongpass')
self.assertEqual('Unauthorized', str(ctx.exception))
# Good Auth
with requests_mock() as mock:
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
mock.post(f'{self.host}{path}', status_code=200,
request_headers=headers,
text='{"token type": "Bearer", "refresh_token": "abc", '
'"access_token":"123", "expires_in": "3600"}')
UltraProvider('test', 'account', 'user', 'rightpass')
self.assertEqual(1, mock.call_count)
expected_payload = "grant_type=password&username=user&"\
"password=rightpass"
self.assertEqual(parse_qs(mock.last_request.text),
parse_qs(expected_payload))
def test_get_zones(self):
provider = _get_provider()
path = "/v2/zones"
# Test authorization issue
with requests_mock() as mock:
mock.get(f'{self.host}{path}', status_code=400,
json={"errorCode": 60004,
"errorMessage": "Authorization Header required"})
with self.assertRaises(HTTPError) as ctx:
zones = provider.zones
self.assertEqual(400, ctx.exception.response.status_code)
# Test no zones exist error
with requests_mock() as mock:
mock.get(f'{self.host}{path}', status_code=404,
headers={'Authorization': 'Bearer 123'},
json=self.empty_body)
zones = provider.zones
self.assertEqual(1, mock.call_count)
self.assertEqual(list(), zones)
# Reset zone cache so they are queried again
provider._zones = None
with requests_mock() as mock:
payload = {
"resultInfo": {
"totalCount": 1,
"offset": 0,
"returnedCount": 1
},
"zones": [
{
"properties": {
"name": "testzone123.com.",
"accountName": "testaccount",
"type": "PRIMARY",
"dnssecStatus": "UNSIGNED",
"status": "ACTIVE",
"owner": "user",
"resourceRecordCount": 5,
"lastModifiedDateTime": "2020-06-19T00:47Z"
}
}
]
}
mock.get(f'{self.host}{path}', status_code=200,
headers={'Authorization': 'Bearer 123'},
json=payload)
zones = provider.zones
self.assertEqual(1, mock.call_count)
self.assertEqual(1, len(zones))
self.assertEqual('testzone123.com.', zones[0])
# Test different paging behavior
provider._zones = None
with requests_mock() as mock:
mock.get(f'{self.host}{path}?limit=100&q=zone_type%3APRIMARY&'
'offset=0', status_code=200,
json={"resultInfo": {"totalCount": 15,
"offset": 0,
"returnedCount": 10},
"zones": []})
mock.get(f'{self.host}{path}?limit=100&q=zone_type%3APRIMARY'
'&offset=10', status_code=200,
json={"resultInfo": {"totalCount": 15,
"offset": 10,
"returnedCount": 5},
"zones": []})
zones = provider.zones
self.assertEqual(2, mock.call_count)
def test_request(self):
provider = _get_provider()
path = '/foo'
payload = {'a': 1}
with requests_mock() as mock:
mock.get(f'{self.host}{path}', status_code=401,
headers={'Authorization': 'Bearer 123'}, json={})
with self.assertRaises(Exception) as ctx:
provider._get(path)
self.assertEqual('Unauthorized', str(ctx.exception))
# Test all GET patterns
with requests_mock() as mock:
mock.get(f'{self.host}{path}', status_code=200,
headers={'Authorization': 'Bearer 123'},
json=payload)
provider._get(path, json=payload)
mock.get(f'{self.host}{path}?a=1', status_code=200,
headers={'Authorization': 'Bearer 123'})
provider._get(path, params=payload, json_response=False)
# Test all POST patterns
with requests_mock() as mock:
mock.post(f'{self.host}{path}', status_code=200,
headers={'Authorization': 'Bearer 123'},
json=payload)
provider._post(path, json=payload)
mock.post(f'{self.host}{path}', status_code=200,
headers={'Authorization': 'Bearer 123'},
text="{'a':1}")
provider._post(path, data=payload, json_response=False)
# Test all PUT patterns
with requests_mock() as mock:
mock.put(f'{self.host}{path}', status_code=200,
headers={'Authorization': 'Bearer 123'},
json=payload)
provider._put(path, json=payload)
# Test all DELETE patterns
with requests_mock() as mock:
mock.delete(f'{self.host}{path}', status_code=200,
headers={'Authorization': 'Bearer 123'})
provider._delete(path, json_response=False)
def test_zone_records(self):
provider = _get_provider()
zone_payload = {
"resultInfo": {"totalCount": 1,
"offset": 0,
"returnedCount": 1},
"zones": [{"properties": {"name": "octodns1.test."}}]}
records_payload = {
"zoneName": "octodns1.test.",
"rrSets": [
{
"ownerName": "octodns1.test.",
"rrtype": "NS (2)",
"ttl": 86400,
"rdata": [
"ns1.octodns1.test."
]
},
{
"ownerName": "octodns1.test.",
"rrtype": "SOA (6)",
"ttl": 86400,
"rdata": [
"pdns1.ultradns.com. phelps.netflix.com. 1 10 10 10 10"
]
},
],
"resultInfo": {
"totalCount": 2,
"offset": 0,
"returnedCount": 2
}
}
zone_path = '/v2/zones'
rec_path = '/v2/zones/octodns1.test./rrsets'
with requests_mock() as mock:
mock.get(f'{self.host}{zone_path}?limit=100&q=zone_type%3APRIMARY&'
'offset=0', status_code=200, json=zone_payload)
mock.get(f'{self.host}{rec_path}?offset=0&limit=100',
status_code=200, json=records_payload)
zone = Zone('octodns1.test.', [])
self.assertTrue(provider.zone_records(zone))
self.assertEqual(mock.call_count, 2)
# Populate the same zone again and confirm cache is hit
self.assertTrue(provider.zone_records(zone))
self.assertEqual(mock.call_count, 2)
from __future__ import absolute_import, division, print_function, \
unicode_literals
def test_populate(self):
provider = _get_provider()
# Non-existent zone doesn't populate anything
with requests_mock() as mock:
mock.get(ANY, status_code=404, json=self.empty_body)
zone = Zone('unit.tests.', [])
provider.populate(zone)
self.assertEqual(set(), zone.records)
# re-populating the same non-existent zone uses cache and makes no
# calls
again = Zone('unit.tests.', [])
provider.populate(again)
self.assertEqual(set(), again.records)
# Test zones with data
provider._zones = None
path = '/v2/zones'
with requests_mock() as mock:
with open('tests/fixtures/ultra-zones-page-1.json') as fh:
mock.get(f'{self.host}{path}?limit=100&q=zone_type%3APRIMARY&'
'offset=0', status_code=200, text=fh.read())
with open('tests/fixtures/ultra-zones-page-2.json') as fh:
mock.get(f'{self.host}{path}?limit=100&q=zone_type%3APRIMARY&'
'offset=10', status_code=200, text=fh.read())
with open('tests/fixtures/ultra-records-page-1.json') as fh:
rec_path = '/v2/zones/octodns1.test./rrsets'
mock.get(f'{self.host}{rec_path}?offset=0&limit=100',
status_code=200, text=fh.read())
with open('tests/fixtures/ultra-records-page-2.json') as fh:
rec_path = '/v2/zones/octodns1.test./rrsets'
mock.get(f'{self.host}{rec_path}?offset=10&limit=100',
status_code=200, text=fh.read())
zone = Zone('octodns1.test.', [])
self.assertTrue(provider.populate(zone))
self.assertEqual('octodns1.test.', zone.name)
self.assertEqual(12, len(zone.records))
self.assertEqual(4, mock.call_count)
def test_apply(self):
provider = _get_provider()
provider._request = Mock()
provider._request.side_effect = [
UltraNoZonesExistException('No Zones'),
None, # zone create
] + [None] * 15 # individual record creates
# non-existent zone, create everything
plan = provider.plan(self.expected)
self.assertEqual(15, len(plan.changes))
self.assertEqual(15, provider.apply(plan))
self.assertFalse(plan.exists)
provider._request.assert_has_calls([
# created the domain
call('POST', '/v2/zones', json={
'properties': {'name': 'unit.tests.',
'accountName': 'testacct',
'type': 'PRIMARY'},
'primaryCreateInfo': {'createType': 'NEW'}}),
# Validate multi-ip apex A record is correct
call('POST', '/v2/zones/unit.tests./rrsets/A/unit.tests.', json={
'ttl': 300,
'rdata': ['1.2.3.4', '1.2.3.5'],
'profile': {
'@context':
'http://schemas.ultradns.com/RDPool.jsonschema',
'order': 'FIXED',
'description': 'unit.tests.'
}
}),
# make sure semicolons are not escaped when sending data
call('POST', '/v2/zones/unit.tests./rrsets/TXT/txt.unit.tests.',
json={'ttl': 600,
'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']}),
], True)
# expected number of total calls
self.assertEqual(17, provider._request.call_count)
# Create sample rrset payload to attempt to alter
page1 = json_load(open('tests/fixtures/ultra-records-page-1.json'))
page2 = json_load(open('tests/fixtures/ultra-records-page-2.json'))
mock_rrsets = list()
mock_rrsets.extend(page1['rrSets'])
mock_rrsets.extend(page2['rrSets'])
# Seed a bunch of records into a zone and verify update / delete ops
provider._request.reset_mock()
provider._zones = ['octodns1.test.']
provider.zone_records = Mock(return_value=mock_rrsets)
provider._request.side_effect = [None] * 13
wanted = Zone('octodns1.test.', [])
wanted.add_record(Record.new(wanted, '', {
'ttl': 60, # Change TTL
'type': 'A',
'value': '5.6.7.8' # Change number of IPs (3 -> 1)
}))
wanted.add_record(Record.new(wanted, 'txt', {
'ttl': 3600,
'type': 'TXT',
'values': [ # Alter TXT value
"foobar",
"v=spf1 include:mail.server.net ?all"
]
}))
plan = provider.plan(wanted)
self.assertEqual(11, len(plan.changes))
self.assertEqual(11, provider.apply(plan))
self.assertTrue(plan.exists)
provider._request.assert_has_calls([
# Validate multi-ip apex A record replaced with standard A
call('PUT', '/v2/zones/octodns1.test./rrsets/A/octodns1.test.',
json={'ttl': 60,
'rdata': ['5.6.7.8']}),
# Make sure TXT value is properly updated
call('PUT',
'/v2/zones/octodns1.test./rrsets/TXT/txt.octodns1.test.',
json={'ttl': 3600,
'rdata': ["foobar",
"v=spf1 include:mail.server.net ?all"]}),
# Confirm a few of the DELETE operations properly occur
call('DELETE',
'/v2/zones/octodns1.test./rrsets/A/a.octodns1.test.',
json_response=False),
call('DELETE',
'/v2/zones/octodns1.test./rrsets/AAAA/aaaa.octodns1.test.',
json_response=False),
call('DELETE',
'/v2/zones/octodns1.test./rrsets/CAA/caa.octodns1.test.',
json_response=False),
call('DELETE',
'/v2/zones/octodns1.test./rrsets/CNAME/cname.octodns1.test.',
json_response=False),
], True)
def test_gen_data(self):
provider = _get_provider()
zone = Zone('unit.tests.', [])
for name, _type, expected_path, expected_payload, expected_record in (
# A
('a', 'A',
'/v2/zones/unit.tests./rrsets/A/a.unit.tests.',
{'ttl': 60, 'rdata': ['1.2.3.4']},
Record.new(zone, 'a',
{'ttl': 60, 'type': 'A', 'values': ['1.2.3.4']})),
('a', 'A',
'/v2/zones/unit.tests./rrsets/A/a.unit.tests.',
{'ttl': 60, 'rdata': ['1.2.3.4', '5.6.7.8'],
'profile': {'@context':
'http://schemas.ultradns.com/RDPool.jsonschema',
'order': 'FIXED',
'description': 'a.unit.tests.'}},
Record.new(zone, 'a',
{'ttl': 60, 'type': 'A',
'values': ['1.2.3.4', '5.6.7.8']})),
# AAAA
('aaaa', 'AAAA',
'/v2/zones/unit.tests./rrsets/AAAA/aaaa.unit.tests.',
{'ttl': 60, 'rdata': ['::1']},
Record.new(zone, 'aaaa',
{'ttl': 60, 'type': 'AAAA', 'values': ['::1']})),
('aaaa', 'AAAA',
'/v2/zones/unit.tests./rrsets/AAAA/aaaa.unit.tests.',
{'ttl': 60, 'rdata': ['::1', '::2'],
'profile': {'@context':
'http://schemas.ultradns.com/RDPool.jsonschema',
'order': 'FIXED',
'description': 'aaaa.unit.tests.'}},
Record.new(zone, 'aaaa',
{'ttl': 60, 'type': 'AAAA',
'values': ['::1', '::2']})),
# CAA
('caa', 'CAA',
'/v2/zones/unit.tests./rrsets/CAA/caa.unit.tests.',
{'ttl': 60, 'rdata': ['0 issue foo.com']},
Record.new(zone, 'caa',
{'ttl': 60, 'type': 'CAA',
'values':
[{'flags': 0, 'tag': 'issue', 'value': 'foo.com'}]})),
# CNAME
('cname', 'CNAME',
'/v2/zones/unit.tests./rrsets/CNAME/cname.unit.tests.',
{'ttl': 60, 'rdata': ['netflix.com.']},
Record.new(zone, 'cname',
{'ttl': 60, 'type': 'CNAME',
'value': 'netflix.com.'})),
# MX
('mx', 'MX',
'/v2/zones/unit.tests./rrsets/MX/mx.unit.tests.',
{'ttl': 60, 'rdata': ['1 mx1.unit.tests.', '1 mx2.unit.tests.']},
Record.new(zone, 'mx',
{'ttl': 60, 'type': 'MX',
'values': [{'preference': 1,
'exchange': 'mx1.unit.tests.'},
{'preference': 1,
'exchange': 'mx2.unit.tests.'}]})),
# NS
('ns', 'NS',
'/v2/zones/unit.tests./rrsets/NS/ns.unit.tests.',
{'ttl': 60, 'rdata': ['ns1.unit.tests.', 'ns2.unit.tests.']},
Record.new(zone, 'ns',
{'ttl': 60, 'type': 'NS',
'values': ['ns1.unit.tests.', 'ns2.unit.tests.']})),
# PTR
('ptr', 'PTR',
'/v2/zones/unit.tests./rrsets/PTR/ptr.unit.tests.',
{'ttl': 60, 'rdata': ['a.unit.tests.']},
Record.new(zone, 'ptr',
{'ttl': 60, 'type': 'PTR',
'value': 'a.unit.tests.'})),
# SPF
('spf', 'SPF',
'/v2/zones/unit.tests./rrsets/SPF/spf.unit.tests.',
{'ttl': 60, 'rdata': ['v=spf1 -all']},
Record.new(zone, 'spf',
{'ttl': 60, 'type': 'SPF',
'values': ['v=spf1 -all']})),
# SRV
('_srv._tcp', 'SRV',
'/v2/zones/unit.tests./rrsets/SRV/_srv._tcp.unit.tests.',
{'ttl': 60, 'rdata': ['10 20 443 target.unit.tests.']},
Record.new(zone, '_srv._tcp',
{'ttl': 60, 'type': 'SRV',
'values': [{'priority': 10,
'weight': 20,
'port': 443,
'target': 'target.unit.tests.'}]})),
# TXT
('txt', 'TXT',
'/v2/zones/unit.tests./rrsets/TXT/txt.unit.tests.',
{'ttl': 60, 'rdata': ['abc', 'def']},
Record.new(zone, 'txt',
{'ttl': 60, 'type': 'TXT',
'values': ['abc', 'def']})),
from unittest import TestCase
# ALIAS
('', 'ALIAS',
'/v2/zones/unit.tests./rrsets/APEXALIAS/unit.tests.',
{'ttl': 60, 'rdata': ['target.unit.tests.']},
Record.new(zone, '',
{'ttl': 60, 'type': 'ALIAS',
'value': 'target.unit.tests.'})),
):
# Validate path and payload based on record meet expectations
path, payload = provider._gen_data(expected_record)
self.assertEqual(expected_path, path)
self.assertEqual(expected_payload, payload)
class TestUltraShim(TestCase):
# Use generator for record and confirm the output matches
rec = provider._record_for(zone, name, _type,
expected_payload, False)
path, payload = provider._gen_data(rec)
self.assertEqual(expected_path, path)
self.assertEqual(expected_payload, payload)
def test_missing(self):
with self.assertRaises(ModuleNotFoundError):
from octodns.provider.ultra import UltraProvider
UltraProvider

Loading…
Cancel
Save