| @ -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) | |||
| @ -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) | |||
| @ -1,25 +1,25 @@ | |||
| PyYaml==5.3.1 | |||
| azure-common==1.1.25 | |||
| azure-mgmt-dns==3.0.0 | |||
| boto3==1.13.19 | |||
| botocore==1.16.19 | |||
| boto3==1.14.14 | |||
| botocore==1.17.14 | |||
| dnspython==1.16.0 | |||
| docutils==0.16 | |||
| dyn==1.8.1 | |||
| edgegrid-python==1.1.1 | |||
| futures==3.2.0; python_version < '3.0' | |||
| futures==3.2.0; python_version < '3.2' | |||
| google-cloud-core==1.3.0 | |||
| google-cloud-dns==0.32.0 | |||
| ipaddress==1.0.23 | |||
| ipaddress==1.0.23; python_version < '3.3' | |||
| jmespath==0.10.0 | |||
| msrestazure==0.6.3 | |||
| msrestazure==0.6.4 | |||
| natsort==6.2.1 | |||
| ns1-python==0.16.0 | |||
| ovh==0.5.0 | |||
| pycountry-convert==0.7.2 | |||
| pycountry==19.8.18 | |||
| python-dateutil==2.8.1 | |||
| requests==2.23.0 | |||
| requests==2.24.0 | |||
| s3transfer==0.3.3 | |||
| setuptools==44.1.1 | |||
| six==1.15.0 | |||
| @ -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 | |||
| } | |||
| } | |||
| @ -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 | |||
| } | |||
| } | |||
| @ -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" | |||
| } | |||
| } | |||
| ] | |||
| } | |||
| @ -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" | |||
| } | |||
| } | |||
| ] | |||
| } | |||
| @ -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) | |||
| @ -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]) | |||
| @ -0,0 +1,43 @@ | |||
| $ORIGIN invalid.records. | |||
| @ 3600 IN SOA ns1.invalid.records. root.invalid.records. ( | |||
| 2018071501 ; Serial | |||
| 3600 ; Refresh (1 hour) | |||
| 600 ; Retry (10 minutes) | |||
| 604800 ; Expire (1 week) | |||
| 3600 ; NXDOMAIN ttl (1 hour) | |||
| ) | |||
| ; NS Records | |||
| @ 3600 IN NS ns1.invalid.records. | |||
| @ 3600 IN NS ns2.invalid.records. | |||
| under 3600 IN NS ns1.invalid.records. | |||
| under 3600 IN NS ns2.invalid.records. | |||
| ; SRV Records | |||
| _srv._tcp 600 IN SRV 10 20 30 foo-1.invalid.records. | |||
| _srv._tcp 600 IN SRV 10 20 30 foo-2.invalid.records. | |||
| _invalid 600 IN SRV 10 20 30 foo-3.invalid.records. | |||
| ; TXT Records | |||
| txt 600 IN TXT "Bah bah black sheep" | |||
| txt 600 IN TXT "have you any wool." | |||
| txt 600 IN TXT "v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs" | |||
| ; MX Records | |||
| mx 300 IN MX 10 smtp-4.invalid.records. | |||
| mx 300 IN MX 20 smtp-2.invalid.records. | |||
| mx 300 IN MX 30 smtp-3.invalid.records. | |||
| mx 300 IN MX 40 smtp-1.invalid.records. | |||
| ; A Records | |||
| @ 300 IN A 1.2.3.4 | |||
| @ 300 IN A 1.2.3.5 | |||
| www 300 IN A 2.2.3.6 | |||
| wwww.sub 300 IN A 2.2.3.6 | |||
| ; AAAA Records | |||
| aaaa 600 IN AAAA 2601:644:500:e210:62f8:1dff:feb8:947a | |||
| ; CNAME Records | |||
| cname 300 IN CNAME invalid.records. | |||
| included 300 IN CNAME invalid.records. | |||