diff --git a/CHANGELOG.md b/CHANGELOG.md index 480d2c4..0e3612c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 8064122..5aadb7c 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/octodns/provider/ultra.py b/octodns/provider/ultra.py index bb8403d..103a41b 100644 --- a/octodns/provider/ultra.py +++ b/octodns/provider/ultra.py @@ -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 diff --git a/tests/fixtures/ultra-records-page-1.json b/tests/fixtures/ultra-records-page-1.json deleted file mode 100644 index 8614427..0000000 --- a/tests/fixtures/ultra-records-page-1.json +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/tests/fixtures/ultra-records-page-2.json b/tests/fixtures/ultra-records-page-2.json deleted file mode 100644 index 274d95e..0000000 --- a/tests/fixtures/ultra-records-page-2.json +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/tests/fixtures/ultra-zones-page-1.json b/tests/fixtures/ultra-zones-page-1.json deleted file mode 100644 index f748d08..0000000 --- a/tests/fixtures/ultra-zones-page-1.json +++ /dev/null @@ -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" - } - } - ] -} \ No newline at end of file diff --git a/tests/fixtures/ultra-zones-page-2.json b/tests/fixtures/ultra-zones-page-2.json deleted file mode 100644 index f720b03..0000000 --- a/tests/fixtures/ultra-zones-page-2.json +++ /dev/null @@ -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" - } - } - ] -} \ No newline at end of file diff --git a/tests/test_octodns_provider_ultra.py b/tests/test_octodns_provider_ultra.py index 736ddc5..acf2805 100644 --- a/tests/test_octodns_provider_ultra.py +++ b/tests/test_octodns_provider_ultra.py @@ -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