From 52ed5fa0229ed7444b4072d220d04b3e9d8b3a83 Mon Sep 17 00:00:00 2001 From: Phelps Williams Date: Wed, 1 Jul 2020 17:41:45 -0700 Subject: [PATCH 1/5] Ultra interface with support for basic records --- README.md | 1 + octodns/provider/ultra.py | 443 +++++++++++++++++++++++ tests/fixtures/ultra-records-page-1.json | 94 +++++ tests/fixtures/ultra-records-page-2.json | 34 ++ tests/fixtures/ultra-zones-page-1.json | 135 +++++++ tests/fixtures/ultra-zones-page-2.json | 135 +++++++ tests/test_octodns_provider_ultra.py | 377 +++++++++++++++++++ 7 files changed, 1219 insertions(+) create mode 100644 octodns/provider/ultra.py create mode 100644 tests/fixtures/ultra-records-page-1.json create mode 100644 tests/fixtures/ultra-records-page-2.json create mode 100644 tests/fixtures/ultra-zones-page-1.json create mode 100644 tests/fixtures/ultra-zones-page-2.json create mode 100644 tests/test_octodns_provider_ultra.py diff --git a/README.md b/README.md index ce9be86..d4e7171 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,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 | | [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 | | +| [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 | | [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 | diff --git a/octodns/provider/ultra.py b/octodns/provider/ultra.py new file mode 100644 index 0000000..9a79656 --- /dev/null +++ b/octodns/provider/ultra.py @@ -0,0 +1,443 @@ +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): + pass + + +class UltraError(UltraClientException): + ''' + This exception is thrown for error messages returned from Ultra DNS + ''' + def __init__(self, data): + try: + message = data.json()[0]['errorMessage'] + except (IndexError, KeyError, TypeError, AttributeError): + message = 'Ultra error' + super(UltraError, self).__init__(message) + + +class UltraNoZonesExistException(UltraError): + def __init__(self, data): + UltraError.__init__(self, data) + + +class UltraClientUnauthorized(UltraClientException): + 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 July 18, 2017 version of the document + + ultra: + class: octodns.provider.ultra.UltraProvider + # Ultra Account Name (required) + account: acct + # Ultra username (required) + username: user + # Ultra password (required) + password: pass + # Whether to use the ultradns test endpoint + # (optional, default is false) + test_endpoint: false + ''' + + 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, + test_endpoint=False, *args, **kwargs): + self.log = getLogger('UltraProvider[{}]'.format(id)) + self.log.debug('__init__: id=%s, account=%s, username=%s, ' + 'password=***, test_endpoint=%s', id, + account, username, test_endpoint) + + super(UltraProvider, self).__init__(id, *args, **kwargs) + self.base_uri = 'https://restapi.ultradns.com' + if test_endpoint: + self.base_uri = 'https://test-restapi.ultradns.com' + self._sess = Session() + self._login(username, password) + self._account = account + 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) diff --git a/tests/fixtures/ultra-records-page-1.json b/tests/fixtures/ultra-records-page-1.json new file mode 100644 index 0000000..2f5f836 --- /dev/null +++ b/tests/fixtures/ultra-records-page-1.json @@ -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 + } +} \ No newline at end of file diff --git a/tests/fixtures/ultra-records-page-2.json b/tests/fixtures/ultra-records-page-2.json new file mode 100644 index 0000000..db51828 --- /dev/null +++ b/tests/fixtures/ultra-records-page-2.json @@ -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 + } +} \ No newline at end of file diff --git a/tests/fixtures/ultra-zones-page-1.json b/tests/fixtures/ultra-zones-page-1.json new file mode 100644 index 0000000..ad98d48 --- /dev/null +++ b/tests/fixtures/ultra-zones-page-1.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/ultra-zones-page-2.json b/tests/fixtures/ultra-zones-page-2.json new file mode 100644 index 0000000..f720b03 --- /dev/null +++ b/tests/fixtures/ultra-zones-page-2.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/tests/test_octodns_provider_ultra.py b/tests/test_octodns_provider_ultra.py new file mode 100644 index 0000000..b1dc85e --- /dev/null +++ b/tests/test_octodns_provider_ultra.py @@ -0,0 +1,377 @@ +from mock import Mock, call +from os.path import dirname, join +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) + + # 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_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: + mock.post('{}{}'.format(self.host, path), status_code=200, + text='{"token type": "Bearer", "refresh_token": "abc", ' + '"access_token":"123", "expires_in": "3600"}') + UltraProvider('test', 'account', 'user', 'pass', + test_endpoint=False) + + with requests_mock() as mock: + test_host = 'https://test-restapi.ultradns.com' + mock.post('{}{}'.format(test_host, path), status_code=200, + text='{"token type": "Bearer", "refresh_token": "abc", ' + '"access_token":"123", "expires_in": "3600"}') + UltraProvider('test', 'account', 'user', 'pass', + test_endpoint=True) + + def test_get_zones(self): + provider = _get_provider() + path = "/v2/zones" + + # 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(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, 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(mock.call_count, 2) + + 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(mock.call_count, 4) + + 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) + })) + # TODO: Figure out why this isn't happening + wanted.add_record(Record.new(wanted, '', { + 'ttl': 3600, # TTL change + 'type': 'NS', + 'values': [ # Add additional NS records + "pdns1.ultradns.biz.", + "pdns1.ultradns.com.", + "pdns1.ultradns.net.", + "pdns1.ultradns.org.", + "pdns2.ultradns.biz.", + "pdns2.ultradns.com.", + "pdns2.ultradns.net.", + "pdns2.ultradns.org.", + ] + })) + 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) + # TODO: 11 expected but NS isn't being respected + self.assertEquals(10, len(plan.changes)) + self.assertEquals(10, provider.apply(plan)) + self.assertTrue(plan.exists) From da9b3aed5bb5321cac911f57225a37ef77393937 Mon Sep 17 00:00:00 2001 From: Phelps Williams Date: Thu, 9 Jul 2020 15:27:23 -0700 Subject: [PATCH 2/5] Removing NS modification tests Root NS modification support is being developed on https://github.com/github/octodns/pull/434 Removing attempts to validate this functionality for now. --- tests/test_octodns_provider_ultra.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/test_octodns_provider_ultra.py b/tests/test_octodns_provider_ultra.py index b1dc85e..b5e5300 100644 --- a/tests/test_octodns_provider_ultra.py +++ b/tests/test_octodns_provider_ultra.py @@ -346,21 +346,6 @@ class TestUltraProvider(TestCase): 'type': 'A', 'value': '5.6.7.8' # Change number of IPs (3 -> 1) })) - # TODO: Figure out why this isn't happening - wanted.add_record(Record.new(wanted, '', { - 'ttl': 3600, # TTL change - 'type': 'NS', - 'values': [ # Add additional NS records - "pdns1.ultradns.biz.", - "pdns1.ultradns.com.", - "pdns1.ultradns.net.", - "pdns1.ultradns.org.", - "pdns2.ultradns.biz.", - "pdns2.ultradns.com.", - "pdns2.ultradns.net.", - "pdns2.ultradns.org.", - ] - })) wanted.add_record(Record.new(wanted, 'txt', { 'ttl': 3600, 'type': 'TXT', @@ -371,7 +356,6 @@ class TestUltraProvider(TestCase): })) plan = provider.plan(wanted) - # TODO: 11 expected but NS isn't being respected self.assertEquals(10, len(plan.changes)) self.assertEquals(10, provider.apply(plan)) self.assertTrue(plan.exists) From fb2d83e635f453dfa2228af47736c942214c4f25 Mon Sep 17 00:00:00 2001 From: Phelps Williams Date: Mon, 13 Jul 2020 16:35:32 -0700 Subject: [PATCH 3/5] UltraDNS: cleaning up exceptions and test endpoint Also some comment additions --- octodns/provider/ultra.py | 40 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/octodns/provider/ultra.py b/octodns/provider/ultra.py index 9a79656..a501425 100644 --- a/octodns/provider/ultra.py +++ b/octodns/provider/ultra.py @@ -8,27 +8,26 @@ from .base import BaseProvider class UltraClientException(Exception): + ''' + Base Ultra exception type + ''' pass -class UltraError(UltraClientException): +class UltraNoZonesExistException(UltraClientException): ''' - This exception is thrown for error messages returned from Ultra DNS + 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): - try: - message = data.json()[0]['errorMessage'] - except (IndexError, KeyError, TypeError, AttributeError): - message = 'Ultra error' - super(UltraError, self).__init__(message) - - -class UltraNoZonesExistException(UltraError): - def __init__(self, data): - UltraError.__init__(self, data) + super(UltraNoZonesExistException, self).__init__('NoZonesExist') class UltraClientUnauthorized(UltraClientException): + ''' + Exception for invalid credentials. + ''' def __init__(self): super(UltraClientUnauthorized, self).__init__('Unauthorized') @@ -39,7 +38,11 @@ class UltraProvider(BaseProvider): Documentation for Ultra REST API requires a login: https://portal.ultradns.com/static/docs/REST-API_User_Guide.pdf - Implemented to the July 18, 2017 version of the document + 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 @@ -49,9 +52,6 @@ class UltraProvider(BaseProvider): username: user # Ultra password (required) password: pass - # Whether to use the ultradns test endpoint - # (optional, default is false) - test_endpoint: false ''' RECORDS_TO_TYPE = { @@ -129,17 +129,13 @@ class UltraProvider(BaseProvider): 'Authorization': 'Bearer {}'.format(resp['access_token']), }) - def __init__(self, id, account, username, password, - test_endpoint=False, *args, **kwargs): + def __init__(self, id, account, username, password, *args, **kwargs): self.log = getLogger('UltraProvider[{}]'.format(id)) self.log.debug('__init__: id=%s, account=%s, username=%s, ' - 'password=***, test_endpoint=%s', id, - account, username, test_endpoint) + 'password=***', id, account, username) super(UltraProvider, self).__init__(id, *args, **kwargs) self.base_uri = 'https://restapi.ultradns.com' - if test_endpoint: - self.base_uri = 'https://test-restapi.ultradns.com' self._sess = Session() self._login(username, password) self._account = account From 5c0d5831ac728b2f6546aa7cb414e8e9a2348629 Mon Sep 17 00:00:00 2001 From: Phelps Williams Date: Mon, 13 Jul 2020 16:37:21 -0700 Subject: [PATCH 4/5] UltraDNS unit test improvements --- tests/test_octodns_provider_ultra.py | 197 +++++++++++++++++++++++---- 1 file changed, 171 insertions(+), 26 deletions(-) diff --git a/tests/test_octodns_provider_ultra.py b/tests/test_octodns_provider_ultra.py index b5e5300..43eac3c 100644 --- a/tests/test_octodns_provider_ultra.py +++ b/tests/test_octodns_provider_ultra.py @@ -1,5 +1,6 @@ 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 @@ -32,20 +33,6 @@ class TestUltraProvider(TestCase): 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_login(self): path = '/v2/authorization/token' @@ -59,30 +46,37 @@ class TestUltraProvider(TestCase): # 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', 'pass', - test_endpoint=False) - - with requests_mock() as mock: - test_host = 'https://test-restapi.ultradns.com' - mock.post('{}{}'.format(test_host, path), status_code=200, - text='{"token type": "Bearer", "refresh_token": "abc", ' - '"access_token":"123", "expires_in": "3600"}') - UltraProvider('test', 'account', 'user', 'pass', - test_endpoint=True) + 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 @@ -115,6 +109,7 @@ class TestUltraProvider(TestCase): 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]) @@ -134,7 +129,7 @@ class TestUltraProvider(TestCase): "returnedCount": 5}, "zones": []}) zones = provider.zones - self.assertEquals(mock.call_count, 2) + self.assertEquals(2, mock.call_count) def test_request(self): provider = _get_provider() @@ -276,10 +271,11 @@ class TestUltraProvider(TestCase): 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(mock.call_count, 4) + self.assertEquals(4, mock.call_count) def test_apply(self): provider = _get_provider() @@ -359,3 +355,152 @@ class TestUltraProvider(TestCase): 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) From d078392bbc7cf98b4a499bf2ff733680c3954a17 Mon Sep 17 00:00:00 2001 From: Phelps Williams Date: Mon, 13 Jul 2020 17:46:24 -0700 Subject: [PATCH 5/5] UltraDNS adding configurable HTTP request timeout --- octodns/provider/ultra.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/octodns/provider/ultra.py b/octodns/provider/ultra.py index a501425..eb10e0d 100644 --- a/octodns/provider/ultra.py +++ b/octodns/provider/ultra.py @@ -77,13 +77,13 @@ class UltraProvider(BaseProvider): data=None, json=None, json_response=True): self.log.debug('_request: method=%s, path=%s', method, path) - url = '{}{}'.format(self.base_uri, path) + url = '{}{}'.format(self._base_uri, path) resp = self._sess.request(method, url, params=params, data=data, json=json, - timeout=self.TIMEOUT) + timeout=self._timeout) self.log.debug('_request: status=%d', resp.status_code) if resp.status_code == 401: @@ -129,16 +129,21 @@ class UltraProvider(BaseProvider): 'Authorization': 'Bearer {}'.format(resp['access_token']), }) - def __init__(self, id, account, username, password, *args, **kwargs): + 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._base_uri = 'https://restapi.ultradns.com' self._sess = Session() - self._login(username, password) self._account = account + self._timeout = timeout + + self._login(username, password) + self._zones = None self._zone_records = {}