1
0
mirror of https://github.com/github/octodns.git synced 2024-05-11 05:55:00 +00:00

Merge branch 'easydns_provider' of https://github.com/actazen/octodns into easydns_provider

This commit is contained in:
John Dale
2020-07-20 23:01:30 +00:00
10 changed files with 1497 additions and 1 deletions

View File

@@ -188,6 +188,7 @@ The above command pulled the existing data out of Route53 and placed the results
| [DynProvider](/octodns/provider/dyn.py) | dyn | All | Both | | | [DynProvider](/octodns/provider/dyn.py) | dyn | All | Both | |
| [EasyDNSProvider](/octodns/provider/easydns.py) | | A, AAAA, CAA, CNAME, MX, NAPTR, NS, SRV, TXT | No | | | [EasyDNSProvider](/octodns/provider/easydns.py) | | A, AAAA, CAA, CNAME, MX, NAPTR, NS, SRV, TXT | No | |
| [EtcHostsProvider](/octodns/provider/etc_hosts.py) | | A, AAAA, ALIAS, CNAME | No | | | [EtcHostsProvider](/octodns/provider/etc_hosts.py) | | A, AAAA, ALIAS, CNAME | No | |
| [EnvVarSource](/octodns/source/envvar.py) | | TXT | No | read-only environment variable injection |
| [GoogleCloudProvider](/octodns/provider/googlecloud.py) | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | | | [GoogleCloudProvider](/octodns/provider/googlecloud.py) | google-cloud-dns | A, AAAA, CAA, CNAME, MX, NAPTR, NS, PTR, SPF, SRV, TXT | No | |
| [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | | | [MythicBeastsProvider](/octodns/provider/mythicbeasts.py) | Mythic Beasts | A, AAAA, ALIAS, CNAME, MX, NS, SRV, SSHFP, CAA, TXT | No | |
| [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | No CNAME support, missing `NA` geo target | | [Ns1Provider](/octodns/provider/ns1.py) | ns1-python | All | Yes | No CNAME support, missing `NA` geo target |
@@ -197,6 +198,7 @@ The above command pulled the existing data out of Route53 and placed the results
| [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 | | [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 | | | [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 | | | [Transip](/octodns/provider/transip.py) | transip | A, AAAA, CNAME, MX, SRV, SPF, TXT, SSHFP, CAA | No | |
| [UltraDns](/octodns/provider/ultra.py) | | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | |
| [AxfrSource](/octodns/source/axfr.py) | | A, AAAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only | | [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 | | [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 | | [TinyDnsFileSource](/octodns/source/tinydns.py) | | A, CNAME, MX, NS, PTR | No | read-only |

View File

@@ -15,6 +15,11 @@ from ..record import Record, Update
from .base import BaseProvider from .base import BaseProvider
def escape_semicolon(s):
assert s
return s.replace(';', '\\;')
class SelectelAuthenticationRequired(Exception): class SelectelAuthenticationRequired(Exception):
def __init__(self, msg): def __init__(self, msg):
message = 'Authorization failed. Invalid or empty token.' message = 'Authorization failed. Invalid or empty token.'
@@ -200,7 +205,7 @@ class SelectelProvider(BaseProvider):
return { return {
'ttl': records[0]['ttl'], 'ttl': records[0]['ttl'],
'type': _type, 'type': _type,
'values': [r['content'] for r in records], 'values': [escape_semicolon(r['content']) for r in records]
} }
def _data_for_SRV(self, _type, records): def _data_for_SRV(self, _type, records):

444
octodns/provider/ultra.py Normal file
View File

@@ -0,0 +1,444 @@
from collections import defaultdict
from ipaddress import ip_address
from logging import getLogger
from requests import Session
from ..record import Record
from .base import BaseProvider
class UltraClientException(Exception):
'''
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 requires a login:
https://portal.ultradns.com/static/docs/REST-API_User_Guide.pdf
Implemented to the May 20, 2020 version of the document (dated on page ii)
Also described as Version 2.83.0 (title page)
Tested against 3.0.0-20200627220036.81047f5
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',
'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
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 = '{}{}'.format(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': 'Bearer {}'.format(resp['access_token']),
})
def __init__(self, id, account, username, password, timeout=TIMEOUT,
*args, **kwargs):
self.log = getLogger('UltraProvider[{}]'.format(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 = 100
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):
for i, v in enumerate(records['rdata']):
records['rdata'][i] = str(ip_address(v))
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],
}
_data_for_PTR = _data_for_single
_data_for_CNAME = _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 = '/v2/zones/{}/rrsets'.format(zone.name)
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, '_data_for_{}'.format(_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
_type = self.RECORDS_TO_TYPE[record['rrtype']]
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, '_apply_{}'.format(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
def _contents_for_SRV(self, record):
return {
'ttl': record.ttl,
'rdata': ['{} {} {} {}'.format(x.priority,
x.weight,
x.port,
x.target) for x in record.values]
}
def _contents_for_CAA(self, record):
return {
'ttl': record.ttl,
'rdata': ['{} {} {}'.format(x.flags,
x.tag,
x.value) for x in record.values]
}
def _contents_for_MX(self, record):
return {
'ttl': record.ttl,
'rdata': ['{} {}'.format(x.preference,
x.exchange) for x in record.values]
}
def _gen_data(self, record):
zone_name = self._remove_prefix(record.fqdn, record.name + '.')
path = '/v2/zones/{}/rrsets/{}/{}'.format(zone_name,
record._type,
record.fqdn)
contents_for = getattr(self, '_contents_for_{}'.format(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 + '.')
path = '/v2/zones/{}/rrsets/{}/{}'.format(zone_name,
existing._type,
existing.fqdn)
self._delete(path, json_response=False)

100
octodns/source/envvar.py Normal file
View File

@@ -0,0 +1,100 @@
import logging
import os
from ..record import Record
from .base import BaseSource
class EnvVarSourceException(Exception):
pass
class EnvironmentVariableNotFoundException(EnvVarSourceException):
def __init__(self, data):
super(EnvironmentVariableNotFoundException, self).__init__(
'Unknown environment variable {}'.format(data))
class EnvVarSource(BaseSource):
'''
This source allows for environment variables to be embedded at octodns
execution time into zones. Intended to capture artifacts of deployment to
facilitate operational objectives.
The TXT record generated will only have a single value.
The record name cannot conflict with any other co-existing sources. If
this occurs, an exception will be thrown.
Possible use cases include:
- Embedding a version number into a TXT record to monitor update
propagation across authoritative providers.
- Capturing identifying information about the deployment process to
record where and when the zone was updated.
version:
class: octodns.source.envvar.EnvVarSource
# The environment variable in question, in this example the username
# currently executing octodns
variable: USER
# The TXT record name to embed the value found at the above
# environment variable
name: deployuser
# The TTL of the TXT record (optional, default 60)
ttl: 3600
This source is then combined with other sources in the octodns config
file:
zones:
netflix.com.:
sources:
- yaml
- version
targets:
- ultra
- ns1
'''
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS = set(('TXT'))
DEFAULT_TTL = 60
def __init__(self, id, variable, name, ttl=DEFAULT_TTL):
self.log = logging.getLogger('{}[{}]'.format(
self.__class__.__name__, id))
self.log.debug('__init__: id=%s, variable=%s, name=%s, '
'ttl=%d', id, variable, name, ttl)
super(EnvVarSource, self).__init__(id)
self.envvar = variable
self.name = name
self.ttl = ttl
def _read_variable(self):
value = os.environ.get(self.envvar)
if value is None:
raise EnvironmentVariableNotFoundException(self.envvar)
self.log.debug('_read_variable: successfully loaded var=%s val=%s',
self.envvar, value)
return value
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)
value = self._read_variable()
# We don't need to worry about conflicting records here because the
# manager will deconflict sources on our behalf.
payload = {'ttl': self.ttl, 'type': 'TXT', 'values': [value]}
record = Record.new(zone, self.name, payload, source=self,
lenient=lenient)
zone.add_record(record, lenient=lenient)
self.log.info('populate: found %s records, exists=False',
len(zone.records) - before)

View File

@@ -0,0 +1,94 @@
{
"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": 12,
"offset": 0,
"returnedCount": 10
}
}

View File

@@ -0,0 +1,34 @@
{
"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."
}
}
],
"resultInfo": {
"totalCount": 12,
"offset": 10,
"returnedCount": 2
}
}

135
tests/fixtures/ultra-zones-page-1.json vendored Normal file
View File

@@ -0,0 +1,135 @@
{
"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": 5,
"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"
}
}
]
}

135
tests/fixtures/ultra-zones-page-2.json vendored Normal file
View File

@@ -0,0 +1,135 @@
{
"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"
}
}
]
}

View File

@@ -0,0 +1,506 @@
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 six import text_type
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('{}{}'.format(self.host, path), status_code=401,
text='{"errorCode": 60001}')
with self.assertRaises(Exception) as ctx:
UltraProvider('test', 'account', 'user', 'wrongpass')
self.assertEquals('Unauthorized', text_type(ctx.exception))
# Good Auth
with requests_mock() as mock:
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
mock.post('{}{}'.format(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.assertEquals(1, mock.call_count)
expected_payload = "grant_type=password&username=user&"\
"password=rightpass"
self.assertEquals(mock.last_request.text, expected_payload)
def test_get_zones(self):
provider = _get_provider()
path = "/v2/zones"
# Test authorization issue
with requests_mock() as mock:
mock.get('{}{}'.format(self.host, path), status_code=400,
json={"errorCode": 60004,
"errorMessage": "Authorization Header required"})
with self.assertRaises(HTTPError) as ctx:
zones = provider.zones
self.assertEquals(400, ctx.exception.response.status_code)
# Test no zones exist error
with requests_mock() as mock:
mock.get('{}{}'.format(self.host, path), status_code=404,
headers={'Authorization': 'Bearer 123'},
json=self.empty_body)
zones = provider.zones
self.assertEquals(1, mock.call_count)
self.assertEquals(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('{}{}'.format(self.host, path), status_code=200,
headers={'Authorization': 'Bearer 123'},
json=payload)
zones = provider.zones
self.assertEquals(1, mock.call_count)
self.assertEquals(1, len(zones))
self.assertEquals('testzone123.com.', zones[0])
# Test different paging behavior
provider._zones = None
with requests_mock() as mock:
mock.get('{}{}?limit=100&q=zone_type%3APRIMARY&offset=0'
.format(self.host, path), status_code=200,
json={"resultInfo": {"totalCount": 15,
"offset": 0,
"returnedCount": 10},
"zones": []})
mock.get('{}{}?limit=100&q=zone_type%3APRIMARY&offset=10'
.format(self.host, path), status_code=200,
json={"resultInfo": {"totalCount": 15,
"offset": 10,
"returnedCount": 5},
"zones": []})
zones = provider.zones
self.assertEquals(2, mock.call_count)
def test_request(self):
provider = _get_provider()
path = '/foo'
payload = {'a': 1}
with requests_mock() as mock:
mock.get('{}{}'.format(self.host, path), status_code=401,
headers={'Authorization': 'Bearer 123'}, json={})
with self.assertRaises(Exception) as ctx:
provider._get(path)
self.assertEquals('Unauthorized', text_type(ctx.exception))
# Test all GET patterns
with requests_mock() as mock:
mock.get('{}{}'.format(self.host, path), status_code=200,
headers={'Authorization': 'Bearer 123'},
json=payload)
provider._get(path, json=payload)
mock.get('{}{}?a=1'.format(self.host, path), 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('{}{}'.format(self.host, path), status_code=200,
headers={'Authorization': 'Bearer 123'},
json=payload)
provider._post(path, json=payload)
mock.post('{}{}'.format(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('{}{}'.format(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('{}{}'.format(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('{}{}?limit=100&q=zone_type%3APRIMARY&offset=0'
.format(self.host, zone_path),
status_code=200, json=zone_payload)
mock.get('{}{}?offset=0&limit=100'.format(self.host, rec_path),
status_code=200, json=records_payload)
zone = Zone('octodns1.test.', [])
self.assertTrue(provider.zone_records(zone))
self.assertEquals(mock.call_count, 2)
# Populate the same zone again and confirm cache is hit
self.assertTrue(provider.zone_records(zone))
self.assertEquals(mock.call_count, 2)
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.assertEquals(set(), zone.records)
# re-populating the same non-existent zone uses cache and makes no
# calls
again = Zone('unit.tests.', [])
provider.populate(again)
self.assertEquals(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('{}{}?limit=100&q=zone_type%3APRIMARY&offset=0'
.format(self.host, path),
status_code=200, text=fh.read())
with open('tests/fixtures/ultra-zones-page-2.json') as fh:
mock.get('{}{}?limit=100&q=zone_type%3APRIMARY&offset=10'
.format(self.host, path),
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('{}{}?offset=0&limit=100'.format(self.host, rec_path),
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('{}{}?offset=10&limit=100'
.format(self.host, rec_path),
status_code=200, text=fh.read())
zone = Zone('octodns1.test.', [])
self.assertTrue(provider.populate(zone))
self.assertEquals('octodns1.test.', zone.name)
self.assertEquals(11, len(zone.records))
self.assertEquals(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] * 13 # individual record creates
# non-existent zone, create everything
plan = provider.plan(self.expected)
self.assertEquals(13, len(plan.changes))
self.assertEquals(13, 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.assertEquals(15, 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.assertEquals(10, len(plan.changes))
self.assertEquals(10, 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']})),
):
# 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)
# 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)

View File

@@ -0,0 +1,41 @@
from six import text_type
from unittest import TestCase
from unittest.mock import patch
from octodns.source.envvar import EnvVarSource
from octodns.source.envvar import EnvironmentVariableNotFoundException
from octodns.zone import Zone
class TestEnvVarSource(TestCase):
def test_read_variable(self):
envvar = 'OCTODNS_TEST_ENVIRONMENT_VARIABLE'
source = EnvVarSource('testid', envvar, 'recordname', ttl=120)
with self.assertRaises(EnvironmentVariableNotFoundException) as ctx:
source._read_variable()
msg = 'Unknown environment variable {}'.format(envvar)
self.assertEquals(msg, text_type(ctx.exception))
with patch.dict('os.environ', {envvar: 'testvalue'}):
value = source._read_variable()
self.assertEquals(value, 'testvalue')
def test_populate(self):
envvar = 'TEST_VAR'
value = 'somevalue'
name = 'testrecord'
zone_name = 'unit.tests.'
source = EnvVarSource('testid', envvar, name)
zone = Zone(zone_name, [])
with patch.dict('os.environ', {envvar: value}):
source.populate(zone)
self.assertEquals(1, len(zone.records))
record = list(zone.records)[0]
self.assertEquals(name, record.name)
self.assertEquals('{}.{}'.format(name, zone_name), record.fqdn)
self.assertEquals('TXT', record._type)
self.assertEquals(1, len(record.values))
self.assertEquals(value, record.values[0])