diff --git a/CHANGELOG.md b/CHANGELOG.md index e6dcb48..55a88c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ -## v0.9.13 - 2021-..-.. - +## v0.9.14 - 2021-??-?? - ... + +#### Noteworthy changes + +* NS1 NA target now includes `SX` and `UM`. If `NA` continent is in use in + dynamic records care must be taken to upgrade/downgrade to v0.9.13. + +## v0.9.13 - 2021-07-18 - Processors Alpha #### Noteworthy changes @@ -14,6 +21,16 @@ America list for backwards compatibility reasons. They will be added in the next releaser. +#### Stuff + +* Lots of progress on the partial/beta support for dynamic records in Azure, + still not production ready. +* NS1 fix for when a pool only exists as a fallback +* Zone level lenient flag +* Validate weight makes sense for pools with a single record +* UltraDNS support for aliases and general fixes/improvements +* Misc doc fixes and improvements + ## v0.9.12 - 2021-04-30 - Enough time has passed #### Noteworthy changes diff --git a/README.md b/README.md index 9b4031e..1f0e9e3 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ $ mkdir config If you'd like to install a version that has not yet been released in a repetable/safe manner you can do the following. In general octoDNS is fairly stable inbetween releases thanks to the plan and apply process, but care should be taken regardless. ```shell -$ pip install -e git+https://git@github.com/github/octodns.git@#egg=octodns +$ pip install -e git+https://git@github.com/octodns/octodns.git@#egg=octodns ``` ### Config @@ -89,7 +89,7 @@ zones: - dyn - route53 - example.net: + example.net.: alias: example.com. ``` diff --git a/docs/records.md b/docs/records.md index c6b2a77..f210846 100644 --- a/docs/records.md +++ b/docs/records.md @@ -19,6 +19,7 @@ OctoDNS supports the following record types: * `SRV` * `SSHFP` * `TXT` +* `URLFWD` Underlying provider support for each of these varies and some providers have extra requirements or limitations. In cases where a record type is not supported by a provider OctoDNS will ignore it there and continue to manage the record elsewhere. For example `SSHFP` is supported by Dyn, but not Route53. If your source data includes an SSHFP record OctoDNS will keep it in sync on Dyn, but not consider it when evaluating the state of Route53. The best way to find out what types are supported by a provider is to look for its `supports` method. If that method exists the logic will drive which records are supported and which are ignored. If the provider does not implement the method it will fall back to `BaseProvider.supports` which indicates full support. diff --git a/octodns/__init__.py b/octodns/__init__.py index 1885d42..16ec066 100644 --- a/octodns/__init__.py +++ b/octodns/__init__.py @@ -3,4 +3,4 @@ from __future__ import absolute_import, division, print_function, \ unicode_literals -__VERSION__ = '0.9.12' +__VERSION__ = '0.9.13' diff --git a/octodns/provider/cloudflare.py b/octodns/provider/cloudflare.py index 4f9ba64..ad057eb 100644 --- a/octodns/provider/cloudflare.py +++ b/octodns/provider/cloudflare.py @@ -10,6 +10,7 @@ from copy import deepcopy from logging import getLogger from requests import Session from time import sleep +from urllib.parse import urlsplit from ..record import Record, Update from .base import BaseProvider @@ -76,7 +77,7 @@ class CloudflareProvider(BaseProvider): SUPPORTS_GEO = False SUPPORTS_DYNAMIC = False SUPPORTS = set(('ALIAS', 'A', 'AAAA', 'CAA', 'CNAME', 'LOC', 'MX', 'NS', - 'PTR', 'SRV', 'SPF', 'TXT')) + 'PTR', 'SRV', 'SPF', 'TXT', 'URLFWD')) MIN_TTL = 120 TIMEOUT = 15 @@ -170,6 +171,9 @@ class CloudflareProvider(BaseProvider): return self._zones + def _ttl_data(self, ttl): + return 300 if ttl == 1 else ttl + def _data_for_cdn(self, name, _type, records): self.log.info('CDN rewrite for %s', records[0]['name']) _type = "CNAME" @@ -177,14 +181,14 @@ class CloudflareProvider(BaseProvider): _type = "ALIAS" return { - 'ttl': records[0]['ttl'], + 'ttl': self._ttl_data(records[0]['ttl']), 'type': _type, 'value': '{}.cdn.cloudflare.net.'.format(records[0]['name']), } def _data_for_multiple(self, _type, records): return { - 'ttl': records[0]['ttl'], + 'ttl': self._ttl_data(records[0]['ttl']), 'type': _type, 'values': [r['content'] for r in records], } @@ -195,7 +199,7 @@ class CloudflareProvider(BaseProvider): def _data_for_TXT(self, _type, records): return { - 'ttl': records[0]['ttl'], + 'ttl': self._ttl_data(records[0]['ttl']), 'type': _type, 'values': [r['content'].replace(';', '\\;') for r in records], } @@ -206,7 +210,7 @@ class CloudflareProvider(BaseProvider): data = r['data'] values.append(data) return { - 'ttl': records[0]['ttl'], + 'ttl': self._ttl_data(records[0]['ttl']), 'type': _type, 'values': values, } @@ -214,7 +218,7 @@ class CloudflareProvider(BaseProvider): def _data_for_CNAME(self, _type, records): only = records[0] return { - 'ttl': only['ttl'], + 'ttl': self._ttl_data(only['ttl']), 'type': _type, 'value': '{}.'.format(only['content']) } @@ -241,7 +245,7 @@ class CloudflareProvider(BaseProvider): 'precision_vert': float(r['precision_vert']), }) return { - 'ttl': records[0]['ttl'], + 'ttl': self._ttl_data(records[0]['ttl']), 'type': _type, 'values': values } @@ -254,14 +258,14 @@ class CloudflareProvider(BaseProvider): 'exchange': '{}.'.format(r['content']), }) return { - 'ttl': records[0]['ttl'], + 'ttl': self._ttl_data(records[0]['ttl']), 'type': _type, 'values': values, } def _data_for_NS(self, _type, records): return { - 'ttl': records[0]['ttl'], + 'ttl': self._ttl_data(records[0]['ttl']), 'type': _type, 'values': ['{}.'.format(r['content']) for r in records], } @@ -279,7 +283,23 @@ class CloudflareProvider(BaseProvider): }) return { 'type': _type, - 'ttl': records[0]['ttl'], + 'ttl': self._ttl_data(records[0]['ttl']), + 'values': values + } + + def _data_for_URLFWD(self, _type, records): + values = [] + for r in records: + values.append({ + 'path': r['path'], + 'target': r['url'], + 'code': r['status_code'], + 'masking': 2, + 'query': 0, + }) + return { + 'type': _type, + 'ttl': 300, # ttl does not exist for this type, forcing a setting 'values': values } @@ -302,6 +322,13 @@ class CloudflareProvider(BaseProvider): else: page = None + path = '/zones/{}/pagerules'.format(zone_id) + resp = self._try_request('GET', path, params={'status': 'active'}) + for r in resp['result']: + # assumption, base on API guide, will only contain 1 action + if r['actions'][0]['id'] == 'forwarding_url': + records += [r] + self._zone_records[zone.name] = records return self._zone_records[zone.name] @@ -338,10 +365,29 @@ class CloudflareProvider(BaseProvider): exists = True values = defaultdict(lambda: defaultdict(list)) for record in records: - name = zone.hostname_from_fqdn(record['name']) - _type = record['type'] - if _type in self.SUPPORTS: - values[name][record['type']].append(record) + if 'targets' in record: + # assumption, targets will always contain 1 target + # API documentation only indicates 'url' as the only target + # if record['targets'][0]['target'] == 'url': + uri = record['targets'][0]['constraint']['value'] + uri = '//' + uri if not uri.startswith('http') else uri + parsed_uri = urlsplit(uri) + name = zone.hostname_from_fqdn(parsed_uri.netloc) + path = parsed_uri.path + _type = 'URLFWD' + # assumption, actions will always contain 1 action + _values = record['actions'][0]['value'] + _values['path'] = path + # no ttl set by pagerule, creating one + _values['ttl'] = 300 + values[name][_type].append(_values) + # the dns_records branch + # elif 'name' in record: + else: + name = zone.hostname_from_fqdn(record['name']) + _type = record['type'] + if _type in self.SUPPORTS: + values[name][record['type']].append(record) for name, types in values.items(): for _type, records in types.items(): @@ -373,6 +419,11 @@ class CloudflareProvider(BaseProvider): existing.update({ 'ttl': new['ttl'] }) + elif change.new._type == 'URLFWD': + existing = deepcopy(change.existing.data) + existing.update({ + 'ttl': new['ttl'] + }) else: existing = change.existing.data @@ -470,6 +521,31 @@ class CloudflareProvider(BaseProvider): } } + def _contents_for_URLFWD(self, record): + name = record.fqdn[:-1] + for value in record.values: + yield { + 'targets': [ + { + 'target': 'url', + 'constraint': { + 'operator': 'matches', + 'value': name + value.path + } + } + ], + 'actions': [ + { + 'id': 'forwarding_url', + 'value': { + 'url': value.target, + 'status_code': value.code, + } + } + ], + 'status': 'active', + } + def _record_is_proxied(self, record): return ( not self.cdn and @@ -485,20 +561,25 @@ class CloudflareProvider(BaseProvider): if _type == 'ALIAS': _type = 'CNAME' - contents_for = getattr(self, '_contents_for_{}'.format(_type)) - for content in contents_for(record): - content.update({ - 'name': name, - 'type': _type, - 'ttl': ttl, - }) - - if _type in _PROXIABLE_RECORD_TYPES: + if _type == 'URLFWD': + contents_for = getattr(self, '_contents_for_{}'.format(_type)) + for content in contents_for(record): + yield content + else: + contents_for = getattr(self, '_contents_for_{}'.format(_type)) + for content in contents_for(record): content.update({ - 'proxied': self._record_is_proxied(record) + 'name': name, + 'type': _type, + 'ttl': ttl, }) - yield content + if _type in _PROXIABLE_RECORD_TYPES: + content.update({ + 'proxied': self._record_is_proxied(record) + }) + + yield content def _gen_key(self, data): # Note that most CF record data has a `content` field the value of @@ -512,7 +593,8 @@ class CloudflareProvider(BaseProvider): # BUT... there are exceptions. MX, CAA, LOC and SRV don't have a simple # content as things are currently implemented so we need to handle # those explicitly and create unique/hashable strings for them. - _type = data['type'] + # AND... for URLFWD/Redirects additional adventures are created. + _type = data.get('type', 'URLFWD') if _type == 'MX': return '{priority} {content}'.format(**data) elif _type == 'CAA': @@ -537,12 +619,23 @@ class CloudflareProvider(BaseProvider): '{precision_horz}', '{precision_vert}') return ' '.join(loc).format(**data) + elif _type == 'URLFWD': + uri = data['targets'][0]['constraint']['value'] + uri = '//' + uri if not uri.startswith('http') else uri + parsed_uri = urlsplit(uri) + return '{name} {path} {url} {status_code}' \ + .format(name=parsed_uri.netloc, + path=parsed_uri.path, + **data['actions'][0]['value']) return data['content'] def _apply_Create(self, change): new = change.new zone_id = self.zones[new.zone.name] - path = '/zones/{}/dns_records'.format(zone_id) + if new._type == 'URLFWD': + path = '/zones/{}/pagerules'.format(zone_id) + else: + path = '/zones/{}/dns_records'.format(zone_id) for content in self._gen_data(new): self._try_request('POST', path, data=content) @@ -555,14 +648,27 @@ class CloudflareProvider(BaseProvider): existing = {} # Find all of the existing CF records for this name & type for record in self.zone_records(zone): - name = zone.hostname_from_fqdn(record['name']) + if 'targets' in record: + uri = record['targets'][0]['constraint']['value'] + uri = '//' + uri if not uri.startswith('http') else uri + parsed_uri = urlsplit(uri) + name = zone.hostname_from_fqdn(parsed_uri.netloc) + path = parsed_uri.path + # assumption, actions will always contain 1 action + _values = record['actions'][0]['value'] + _values['path'] = path + _values['ttl'] = 300 + _values['type'] = 'URLFWD' + record.update(_values) + else: + name = zone.hostname_from_fqdn(record['name']) # Use the _record_for so that we include all of standard # conversion logic r = self._record_for(zone, name, record['type'], [record], True) if hostname == r.name and _type == r._type: - # Round trip the single value through a record to contents flow - # to get a consistent _gen_data result that matches what - # went in to new_contents + # Round trip the single value through a record to contents + # flow to get a consistent _gen_data result that matches + # what went in to new_contents data = next(self._gen_data(r)) # Record the record_id and data for this existing record @@ -630,7 +736,10 @@ class CloudflareProvider(BaseProvider): # otherwise required, just makes things deterministic # Creates - path = '/zones/{}/dns_records'.format(zone_id) + if _type == 'URLFWD': + path = '/zones/{}/pagerules'.format(zone_id) + else: + path = '/zones/{}/dns_records'.format(zone_id) for _, data in sorted(creates.items()): self.log.debug('_apply_Update: creating %s', data) self._try_request('POST', path, data=data) @@ -640,7 +749,10 @@ class CloudflareProvider(BaseProvider): record_id = info['record_id'] data = info['data'] old_data = info['old_data'] - path = '/zones/{}/dns_records/{}'.format(zone_id, record_id) + if _type == 'URLFWD': + path = '/zones/{}/pagerules/{}'.format(zone_id, record_id) + else: + path = '/zones/{}/dns_records/{}'.format(zone_id, record_id) self.log.debug('_apply_Update: updating %s, %s -> %s', record_id, data, old_data) self._try_request('PUT', path, data=data) @@ -649,7 +761,10 @@ class CloudflareProvider(BaseProvider): for _, info in sorted(deletes.items()): record_id = info['record_id'] old_data = info['data'] - path = '/zones/{}/dns_records/{}'.format(zone_id, record_id) + if _type == 'URLFWD': + path = '/zones/{}/pagerules/{}'.format(zone_id, record_id) + else: + path = '/zones/{}/dns_records/{}'.format(zone_id, record_id) self.log.debug('_apply_Update: removing %s, %s', record_id, old_data) self._try_request('DELETE', path) @@ -661,11 +776,24 @@ class CloudflareProvider(BaseProvider): existing_type = 'CNAME' if existing._type == 'ALIAS' \ else existing._type for record in self.zone_records(existing.zone): - if existing_name == record['name'] and \ - existing_type == record['type']: - path = '/zones/{}/dns_records/{}'.format(record['zone_id'], - record['id']) - self._try_request('DELETE', path) + if 'targets' in record: + uri = record['targets'][0]['constraint']['value'] + uri = '//' + uri if not uri.startswith('http') else uri + parsed_uri = urlsplit(uri) + record_name = parsed_uri.netloc + record_type = 'URLFWD' + zone_id = self.zones.get(existing.zone.name, False) + if existing_name == record_name and \ + existing_type == record_type: + path = '/zones/{}/pagerules/{}' \ + .format(zone_id, record['id']) + self._try_request('DELETE', path) + else: + if existing_name == record['name'] and \ + existing_type == record['type']: + path = '/zones/{}/dns_records/{}' \ + .format(record['zone_id'], record['id']) + self._try_request('DELETE', path) def _apply(self, plan): desired = plan.desired diff --git a/octodns/provider/ns1.py b/octodns/provider/ns1.py index 510b099..6d4f84d 100644 --- a/octodns/provider/ns1.py +++ b/octodns/provider/ns1.py @@ -234,7 +234,7 @@ class Ns1Provider(BaseProvider): SUPPORTS_GEO = True SUPPORTS_DYNAMIC = True SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'NAPTR', - 'NS', 'PTR', 'SPF', 'SRV', 'TXT')) + 'NS', 'PTR', 'SPF', 'SRV', 'TXT', 'URLFWD')) ZONE_NOT_FOUND_MESSAGE = 'server error: zone not found' @@ -363,7 +363,8 @@ class Ns1Provider(BaseProvider): 'NA': {'DO', 'DM', 'BB', 'BL', 'BM', 'HT', 'KN', 'JM', 'VC', 'HN', 'BS', 'BZ', 'PR', 'NI', 'LC', 'TT', 'VG', 'PA', 'TC', 'PM', 'GT', 'AG', 'GP', 'AI', 'VI', 'CA', 'GD', 'AW', 'CR', 'GL', - 'CU', 'MF', 'SV', 'US', 'MQ', 'MS', 'KY', 'MX', 'CW', 'BQ'} + 'CU', 'MF', 'SV', 'US', 'MQ', 'MS', 'KY', 'MX', 'CW', 'BQ', + 'SX', 'UM'} } def __init__(self, id, api_key, retry_count=4, monitor_regions=None, @@ -748,6 +749,23 @@ class Ns1Provider(BaseProvider): 'values': values, } + def _data_for_URLFWD(self, _type, record): + values = [] + for answer in record['short_answers']: + path, target, code, masking, query = answer.split(' ', 4) + values.append({ + 'path': path, + 'target': target, + 'code': code, + 'masking': masking, + 'query': query, + }) + return { + 'ttl': record['ttl'], + 'type': _type, + 'values': values, + } + def populate(self, zone, target=False, lenient=False): self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, @@ -1243,6 +1261,11 @@ class Ns1Provider(BaseProvider): for v in record.values] return {'answers': values, 'ttl': record.ttl}, None + def _params_for_URLFWD(self, record): + values = [(v.path, v.target, v.code, v.masking, v.query) + for v in record.values] + return {'answers': values, 'ttl': record.ttl}, None + def _get_ns1_filters(self, ns1_zone_name): ns1_filters = {} ns1_zone = {} diff --git a/octodns/provider/yaml.py b/octodns/provider/yaml.py index 8314f38..b3dd2d9 100644 --- a/octodns/provider/yaml.py +++ b/octodns/provider/yaml.py @@ -105,7 +105,8 @@ class YamlProvider(BaseProvider): SUPPORTS_GEO = True SUPPORTS_DYNAMIC = True SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'DNAME', 'LOC', 'MX', - 'NAPTR', 'NS', 'PTR', 'SSHFP', 'SPF', 'SRV', 'TXT')) + 'NAPTR', 'NS', 'PTR', 'SSHFP', 'SPF', 'SRV', 'TXT', + 'URLFWD')) def __init__(self, id, directory, default_ttl=3600, enforce_order=True, populate_should_replace=False, *args, **kwargs): diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 3ab8263..7714b27 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -106,6 +106,7 @@ class Record(EqualityTupleMixin): 'SRV': SrvRecord, 'SSHFP': SshfpRecord, 'TXT': TxtRecord, + 'URLFWD': UrlfwdRecord, }[_type] except KeyError: raise Exception('Unknown record type: "{}"'.format(_type)) @@ -617,7 +618,6 @@ class _DynamicMixin(object): else: seen_default = False - # TODO: don't allow 'default' as a pool name, reserved for i, rule in enumerate(rules): rule_num = i + 1 try: @@ -1467,3 +1467,86 @@ class _TxtValue(_ChunkedValue): class TxtRecord(_ChunkedValuesMixin, Record): _type = 'TXT' _value_type = _TxtValue + + +class UrlfwdValue(EqualityTupleMixin): + VALID_CODES = (301, 302) + VALID_MASKS = (0, 1, 2) + VALID_QUERY = (0, 1) + + @classmethod + def validate(cls, data, _type): + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + try: + code = int(value['code']) + if code not in cls.VALID_CODES: + reasons.append('unrecognized return code "{}"' + .format(code)) + except KeyError: + reasons.append('missing code') + except ValueError: + reasons.append('invalid return code "{}"' + .format(value['code'])) + try: + masking = int(value['masking']) + if masking not in cls.VALID_MASKS: + reasons.append('unrecognized masking setting "{}"' + .format(masking)) + except KeyError: + reasons.append('missing masking') + except ValueError: + reasons.append('invalid masking setting "{}"' + .format(value['masking'])) + try: + query = int(value['query']) + if query not in cls.VALID_QUERY: + reasons.append('unrecognized query setting "{}"' + .format(query)) + except KeyError: + reasons.append('missing query') + except ValueError: + reasons.append('invalid query setting "{}"' + .format(value['query'])) + for k in ('path', 'target'): + if k not in value: + reasons.append('missing {}'.format(k)) + return reasons + + @classmethod + def process(cls, values): + return [UrlfwdValue(v) for v in values] + + def __init__(self, value): + self.path = value['path'] + self.target = value['target'] + self.code = int(value['code']) + self.masking = int(value['masking']) + self.query = int(value['query']) + + @property + def data(self): + return { + 'path': self.path, + 'target': self.target, + 'code': self.code, + 'masking': self.masking, + 'query': self.query, + } + + def __hash__(self): + return hash(self.__repr__()) + + def _equality_tuple(self): + return (self.path, self.target, self.code, self.masking, self.query) + + def __repr__(self): + return '"{}" "{}" {} {} {}'.format(self.path, self.target, self.code, + self.masking, self.query) + + +class UrlfwdRecord(_ValuesMixin, Record): + _type = 'URLFWD' + _value_type = UrlfwdValue diff --git a/tests/config/split/unit.tests.tst/urlfwd.yaml b/tests/config/split/unit.tests.tst/urlfwd.yaml new file mode 100644 index 0000000..778b9b5 --- /dev/null +++ b/tests/config/split/unit.tests.tst/urlfwd.yaml @@ -0,0 +1,15 @@ +--- +urlfwd: + ttl: 300 + type: URLFWD + values: + - code: 302 + masking: 2 + path: '/' + query: 0 + target: 'http://www.unit.tests' + - code: 301 + masking: 2 + path: '/target' + query: 0 + target: 'http://target.unit.tests' diff --git a/tests/config/unit.tests.yaml b/tests/config/unit.tests.yaml index 39e5326..c70b20c 100644 --- a/tests/config/unit.tests.yaml +++ b/tests/config/unit.tests.yaml @@ -169,6 +169,20 @@ txt: - Bah bah black sheep - have you any wool. - 'v=DKIM1\;k=rsa\;s=email\;h=sha256\;p=A/kinda+of/long/string+with+numb3rs' +urlfwd: + ttl: 300 + type: URLFWD + values: + - code: 302 + masking: 2 + path: '/' + query: 0 + target: 'http://www.unit.tests' + - code: 301 + masking: 2 + path: '/target' + query: 0 + target: 'http://target.unit.tests' www: ttl: 300 type: A diff --git a/tests/fixtures/cloudflare-pagerules.json b/tests/fixtures/cloudflare-pagerules.json new file mode 100644 index 0000000..7efa018 --- /dev/null +++ b/tests/fixtures/cloudflare-pagerules.json @@ -0,0 +1,103 @@ +{ + "result": [ + { + "id": "2b1ec1793185213139f22059a165376e", + "targets": [ + { + "target": "url", + "constraint": { + "operator": "matches", + "value": "urlfwd0.unit.tests/" + } + } + ], + "actions": [ + { + "id": "always_use_https" + } + ], + "priority": 4, + "status": "active", + "created_on": "2021-06-29T17:14:28.000000Z", + "modified_on": "2021-06-29T17:15:33.000000Z" + }, + { + "id": "2b1ec1793185213139f22059a165376f", + "targets": [ + { + "target": "url", + "constraint": { + "operator": "matches", + "value": "urlfwd0.unit.tests/*" + } + } + ], + "actions": [ + { + "id": "forwarding_url", + "value": { + "url": "https://www.unit.tests/", + "status_code": 301 + } + } + ], + "priority": 3, + "status": "active", + "created_on": "2021-06-29T17:07:12.000000Z", + "modified_on": "2021-06-29T17:15:12.000000Z" + }, + { + "id": "2b1ec1793185213139f22059a165377e", + "targets": [ + { + "target": "url", + "constraint": { + "operator": "matches", + "value": "urlfwd1.unit.tests/*" + } + } + ], + "actions": [ + { + "id": "forwarding_url", + "value": { + "url": "https://www.unit.tests/", + "status_code": 302 + } + } + ], + "priority": 2, + "status": "active", + "created_on": "2021-06-28T22:42:27.000000Z", + "modified_on": "2021-06-28T22:43:13.000000Z" + }, + { + "id": "2a9140b17ffb0e6aed826049eec970b8", + "targets": [ + { + "target": "url", + "constraint": { + "operator": "matches", + "value": "urlfwd2.unit.tests/*" + } + } + ], + "actions": [ + { + "id": "forwarding_url", + "value": { + "url": "https://www.unit.tests/", + "status_code": 301 + } + } + ], + "priority": 1, + "status": "active", + "created_on": "2021-06-25T20:10:50.000000Z", + "modified_on": "2021-06-28T22:38:10.000000Z" + } + ], + "success": true, + "errors": [], + "messages": [] +} diff --git a/tests/test_octodns_manager.py b/tests/test_octodns_manager.py index 8bada06..96f67fd 100644 --- a/tests/test_octodns_manager.py +++ b/tests/test_octodns_manager.py @@ -121,12 +121,12 @@ class TestManager(TestCase): environ['YAML_TMP_DIR'] = tmpdir.dirname tc = Manager(get_config_filename('simple.yaml')) \ .sync(dry_run=False) - self.assertEquals(25, tc) + self.assertEquals(26, tc) # try with just one of the zones tc = Manager(get_config_filename('simple.yaml')) \ .sync(dry_run=False, eligible_zones=['unit.tests.']) - self.assertEquals(19, tc) + self.assertEquals(20, tc) # the subzone, with 2 targets tc = Manager(get_config_filename('simple.yaml')) \ @@ -141,18 +141,18 @@ class TestManager(TestCase): # Again with force tc = Manager(get_config_filename('simple.yaml')) \ .sync(dry_run=False, force=True) - self.assertEquals(25, tc) + self.assertEquals(26, tc) # Again with max_workers = 1 tc = Manager(get_config_filename('simple.yaml'), max_workers=1) \ .sync(dry_run=False, force=True) - self.assertEquals(25, tc) + self.assertEquals(26, tc) # Include meta tc = Manager(get_config_filename('simple.yaml'), max_workers=1, include_meta=True) \ .sync(dry_run=False, force=True) - self.assertEquals(29, tc) + self.assertEquals(30, tc) def test_eligible_sources(self): with TemporaryDirectory() as tmpdir: @@ -218,13 +218,13 @@ class TestManager(TestCase): fh.write('---\n{}') changes = manager.compare(['in'], ['dump'], 'unit.tests.') - self.assertEquals(19, len(changes)) + self.assertEquals(20, len(changes)) # Compound sources with varying support changes = manager.compare(['in', 'nosshfp'], ['dump'], 'unit.tests.') - self.assertEquals(18, len(changes)) + self.assertEquals(19, len(changes)) with self.assertRaises(ManagerException) as ctx: manager.compare(['nope'], ['dump'], 'unit.tests.') diff --git a/tests/test_octodns_provider_cloudflare.py b/tests/test_octodns_provider_cloudflare.py index 8843843..2cc11cb 100644 --- a/tests/test_octodns_provider_cloudflare.py +++ b/tests/test_octodns_provider_cloudflare.py @@ -166,9 +166,15 @@ class TestCloudflareProvider(TestCase): json={'result': [], 'result_info': {'count': 0, 'per_page': 0}}) + base = '{}/234234243423aaabb334342aaa343435'.format(base) + + # pagerules/URLFWD + with open('tests/fixtures/cloudflare-pagerules.json') as fh: + mock.get('{}/pagerules?status=active'.format(base), + status_code=200, text=fh.read()) + # records - base = '{}/234234243423aaabb334342aaa343435/dns_records' \ - .format(base) + base = '{}/dns_records'.format(base) with open('tests/fixtures/cloudflare-dns_records-' 'page-1.json') as fh: mock.get('{}?page=1'.format(base), status_code=200, @@ -184,16 +190,16 @@ class TestCloudflareProvider(TestCase): zone = Zone('unit.tests.', []) provider.populate(zone) - self.assertEquals(16, len(zone.records)) + self.assertEquals(19, len(zone.records)) changes = self.expected.changes(zone, provider) - self.assertEquals(0, len(changes)) + self.assertEquals(4, len(changes)) # re-populating the same zone/records comes out of cache, no calls again = Zone('unit.tests.', []) provider.populate(again) - self.assertEquals(16, len(again.records)) + self.assertEquals(19, len(again.records)) def test_apply(self): provider = CloudflareProvider('test', 'email', 'token', retry_period=0) @@ -207,12 +213,12 @@ class TestCloudflareProvider(TestCase): 'id': 42, } }, # zone create - ] + [None] * 25 # individual record creates + ] + [None] * 27 # individual record creates # non-existent zone, create everything plan = provider.plan(self.expected) - self.assertEquals(16, len(plan.changes)) - self.assertEquals(16, provider.apply(plan)) + self.assertEquals(17, len(plan.changes)) + self.assertEquals(17, provider.apply(plan)) self.assertFalse(plan.exists) provider._request.assert_has_calls([ @@ -236,9 +242,31 @@ class TestCloudflareProvider(TestCase): 'name': 'txt.unit.tests', 'ttl': 600 }), + # create at least one pagerules + call('POST', '/zones/42/pagerules', data={ + 'targets': [ + { + 'target': 'url', + 'constraint': { + 'operator': 'matches', + 'value': 'urlfwd.unit.tests/' + } + } + ], + 'actions': [ + { + 'id': 'forwarding_url', + 'value': { + 'url': 'http://www.unit.tests', + 'status_code': 302 + } + } + ], + 'status': 'active' + }), ], True) # expected number of total calls - self.assertEquals(27, provider._request.call_count) + self.assertEquals(29, provider._request.call_count) provider._request.reset_mock() @@ -311,6 +339,56 @@ class TestCloudflareProvider(TestCase): "auto_added": False } }, + { + "id": "2a9140b17ffb0e6aed826049eec970b7", + "targets": [ + { + "target": "url", + "constraint": { + "operator": "matches", + "value": "urlfwd.unit.tests/" + } + } + ], + "actions": [ + { + "id": "forwarding_url", + "value": { + "url": "https://www.unit.tests", + "status_code": 302 + } + } + ], + "priority": 1, + "status": "active", + "created_on": "2021-06-25T20:10:50.000000Z", + "modified_on": "2021-06-28T22:38:10.000000Z" + }, + { + "id": "2a9141b18ffb0e6aed826050eec970b8", + "targets": [ + { + "target": "url", + "constraint": { + "operator": "matches", + "value": "urlfwdother.unit.tests/target" + } + } + ], + "actions": [ + { + "id": "forwarding_url", + "value": { + "url": "https://target.unit.tests", + "status_code": 301 + } + } + ], + "priority": 2, + "status": "active", + "created_on": "2021-06-25T20:10:50.000000Z", + "modified_on": "2021-06-28T22:38:10.000000Z" + }, ]) # we don't care about the POST/create return values @@ -319,7 +397,7 @@ class TestCloudflareProvider(TestCase): # Test out the create rate-limit handling, then 9 successes provider._request.side_effect = [ CloudflareRateLimitError('{}'), - ] + ([None] * 3) + ] + ([None] * 5) wanted = Zone('unit.tests.', []) wanted.add_record(Record.new(wanted, 'nc', { @@ -332,14 +410,27 @@ class TestCloudflareProvider(TestCase): 'type': 'A', 'value': '3.2.3.4' })) + wanted.add_record(Record.new(wanted, 'urlfwd', { + 'ttl': 300, + 'type': 'URLFWD', + 'value': { + 'path': '/*', # path change + 'target': 'https://www.unit.tests/', # target change + 'code': 301, # status_code change + 'masking': '2', + 'query': 0, + } + })) plan = provider.plan(wanted) # only see the delete & ttl update, below min-ttl is filtered out - self.assertEquals(2, len(plan.changes)) - self.assertEquals(2, provider.apply(plan)) + self.assertEquals(4, len(plan.changes)) + self.assertEquals(4, provider.apply(plan)) self.assertTrue(plan.exists) # creates a the new value and then deletes all the old provider._request.assert_has_calls([ + call('DELETE', '/zones/42/' + 'pagerules/2a9141b18ffb0e6aed826050eec970b8'), call('DELETE', '/zones/ff12ab34cd5611334422ab3322997650/' 'dns_records/fc12ab34cd5611334422ab3322997653'), call('DELETE', '/zones/ff12ab34cd5611334422ab3322997650/' @@ -351,7 +442,29 @@ class TestCloudflareProvider(TestCase): 'name': 'ttl.unit.tests', 'proxied': False, 'ttl': 300 - }) + }), + call('PUT', '/zones/42/pagerules/' + '2a9140b17ffb0e6aed826049eec970b7', data={ + 'targets': [ + { + 'target': 'url', + 'constraint': { + 'operator': 'matches', + 'value': 'urlfwd.unit.tests/*' + } + } + ], + 'actions': [ + { + 'id': 'forwarding_url', + 'value': { + 'url': 'https://www.unit.tests/', + 'status_code': 301 + } + } + ], + 'status': 'active', + }), ]) def test_update_add_swap(self): @@ -500,6 +613,56 @@ class TestCloudflareProvider(TestCase): "auto_added": False } }, + { + "id": "2a9140b17ffb0e6aed826049eec974b7", + "targets": [ + { + "target": "url", + "constraint": { + "operator": "matches", + "value": "urlfwd1.unit.tests/" + } + } + ], + "actions": [ + { + "id": "forwarding_url", + "value": { + "url": "https://www.unit.tests", + "status_code": 302 + } + } + ], + "priority": 1, + "status": "active", + "created_on": "2021-06-25T20:10:50.000000Z", + "modified_on": "2021-06-28T22:38:10.000000Z" + }, + { + "id": "2a9141b18ffb0e6aed826054eec970b8", + "targets": [ + { + "target": "url", + "constraint": { + "operator": "matches", + "value": "urlfwd1.unit.tests/target" + } + } + ], + "actions": [ + { + "id": "forwarding_url", + "value": { + "url": "https://target.unit.tests", + "status_code": 301 + } + } + ], + "priority": 2, + "status": "active", + "created_on": "2021-06-25T20:10:50.000000Z", + "modified_on": "2021-06-28T22:38:10.000000Z" + }, ]) provider._request = Mock() @@ -513,6 +676,8 @@ class TestCloudflareProvider(TestCase): }, # zone create None, None, + None, + None, ] # Add something and delete something @@ -523,14 +688,46 @@ class TestCloudflareProvider(TestCase): # This matches the zone data above, one to delete, one to leave 'values': ['ns1.foo.bar.', 'ns2.foo.bar.'], }) + exstingurlfwd = Record.new(zone, 'urlfwd1', { + 'ttl': 300, + 'type': 'URLFWD', + 'values': [ + { + 'path': '/', + 'target': 'https://www.unit.tests', + 'code': 302, + 'masking': '2', + 'query': 0, + }, + { + 'path': '/target', + 'target': 'https://target.unit.tests', + 'code': 301, + 'masking': '2', + 'query': 0, + } + ] + }) new = Record.new(zone, '', { 'ttl': 300, 'type': 'NS', # This leaves one and deletes one 'value': 'ns2.foo.bar.', }) + newurlfwd = Record.new(zone, 'urlfwd1', { + 'ttl': 300, + 'type': 'URLFWD', + 'value': { + 'path': '/', + 'target': 'https://www.unit.tests', + 'code': 302, + 'masking': '2', + 'query': 0, + } + }) change = Update(existing, new) - plan = Plan(zone, zone, [change], True) + changeurlfwd = Update(exstingurlfwd, newurlfwd) + plan = Plan(zone, zone, [change, changeurlfwd], True) provider._apply(plan) # Get zones, create zone, create a record, delete a record @@ -548,7 +745,31 @@ class TestCloudflareProvider(TestCase): 'ttl': 300 }), call('DELETE', '/zones/42/dns_records/' - 'fc12ab34cd5611334422ab3322997653') + 'fc12ab34cd5611334422ab3322997653'), + call('PUT', '/zones/42/pagerules/' + '2a9140b17ffb0e6aed826049eec974b7', data={ + 'targets': [ + { + 'target': 'url', + 'constraint': { + 'operator': 'matches', + 'value': 'urlfwd1.unit.tests/' + } + } + ], + 'actions': [ + { + 'id': 'forwarding_url', + 'value': { + 'url': 'https://www.unit.tests', + 'status_code': 302 + } + } + ], + 'status': 'active' + }), + call('DELETE', '/zones/42/pagerules/' + '2a9141b18ffb0e6aed826054eec970b8'), ]) def test_ptr(self): @@ -1410,3 +1631,11 @@ class TestCloudflareProvider(TestCase): with self.assertRaises(CloudflareRateLimitError) as ctx: provider.zone_records(zone) self.assertEquals('last', text_type(ctx.exception)) + + def test_ttl_mapping(self): + provider = CloudflareProvider('test', 'email', 'token') + + self.assertEquals(120, provider._ttl_data(120)) + self.assertEquals(120, provider._ttl_data(120)) + self.assertEquals(3600, provider._ttl_data(3600)) + self.assertEquals(300, provider._ttl_data(1)) diff --git a/tests/test_octodns_provider_constellix.py b/tests/test_octodns_provider_constellix.py index e9ece0e..38b7ab9 100644 --- a/tests/test_octodns_provider_constellix.py +++ b/tests/test_octodns_provider_constellix.py @@ -132,7 +132,7 @@ class TestConstellixProvider(TestCase): plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported - n = len(self.expected.records) - 7 + n = len(self.expected.records) - 8 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) diff --git a/tests/test_octodns_provider_digitalocean.py b/tests/test_octodns_provider_digitalocean.py index affd140..9ed54bf 100644 --- a/tests/test_octodns_provider_digitalocean.py +++ b/tests/test_octodns_provider_digitalocean.py @@ -163,7 +163,7 @@ class TestDigitalOceanProvider(TestCase): plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported - n = len(self.expected.records) - 9 + n = len(self.expected.records) - 10 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) self.assertFalse(plan.exists) diff --git a/tests/test_octodns_provider_dnsimple.py b/tests/test_octodns_provider_dnsimple.py index be881e4..0b8d209 100644 --- a/tests/test_octodns_provider_dnsimple.py +++ b/tests/test_octodns_provider_dnsimple.py @@ -137,7 +137,7 @@ class TestDnsimpleProvider(TestCase): plan = provider.plan(self.expected) # No root NS, no ignored, no excluded - n = len(self.expected.records) - 7 + n = len(self.expected.records) - 8 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) self.assertFalse(plan.exists) diff --git a/tests/test_octodns_provider_dnsmadeeasy.py b/tests/test_octodns_provider_dnsmadeeasy.py index dc104b7..9efc81d 100644 --- a/tests/test_octodns_provider_dnsmadeeasy.py +++ b/tests/test_octodns_provider_dnsmadeeasy.py @@ -134,7 +134,7 @@ class TestDnsMadeEasyProvider(TestCase): plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported - n = len(self.expected.records) - 9 + n = len(self.expected.records) - 10 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) diff --git a/tests/test_octodns_provider_easydns.py b/tests/test_octodns_provider_easydns.py index a6de8a9..85492eb 100644 --- a/tests/test_octodns_provider_easydns.py +++ b/tests/test_octodns_provider_easydns.py @@ -374,7 +374,7 @@ class TestEasyDNSProvider(TestCase): plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported - n = len(self.expected.records) - 8 + n = len(self.expected.records) - 9 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) self.assertFalse(plan.exists) diff --git a/tests/test_octodns_provider_gandi.py b/tests/test_octodns_provider_gandi.py index 26ffeee..f2e3028 100644 --- a/tests/test_octodns_provider_gandi.py +++ b/tests/test_octodns_provider_gandi.py @@ -193,7 +193,7 @@ class TestGandiProvider(TestCase): plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no LOC - n = len(self.expected.records) - 5 + n = len(self.expected.records) - 6 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) self.assertFalse(plan.exists) diff --git a/tests/test_octodns_provider_hetzner.py b/tests/test_octodns_provider_hetzner.py index 4167944..218a6b2 100644 --- a/tests/test_octodns_provider_hetzner.py +++ b/tests/test_octodns_provider_hetzner.py @@ -108,7 +108,7 @@ class TestHetznerProvider(TestCase): plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported - n = len(self.expected.records) - 9 + n = len(self.expected.records) - 10 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) self.assertFalse(plan.exists) diff --git a/tests/test_octodns_provider_ns1.py b/tests/test_octodns_provider_ns1.py index e21fe0d..875ebbf 100644 --- a/tests/test_octodns_provider_ns1.py +++ b/tests/test_octodns_provider_ns1.py @@ -109,6 +109,17 @@ class TestNs1Provider(TestCase): 'value': 'ca.unit.tests', }, })) + expected.add(Record.new(zone, 'urlfwd', { + 'ttl': 41, + 'type': 'URLFWD', + 'value': { + 'path': '/', + 'target': 'http://foo.unit.tests', + 'code': 301, + 'masking': 2, + 'query': 0, + }, + })) ns1_records = [{ 'type': 'A', @@ -164,6 +175,11 @@ class TestNs1Provider(TestCase): 'ttl': 40, 'short_answers': ['0 issue ca.unit.tests'], 'domain': 'unit.tests.', + }, { + 'type': 'URLFWD', + 'ttl': 41, + 'short_answers': ['/ http://foo.unit.tests 301 2 0'], + 'domain': 'urlfwd.unit.tests.', }] @patch('ns1.rest.records.Records.retrieve') @@ -345,7 +361,7 @@ class TestNs1Provider(TestCase): # Test out the create rate-limit handling, then 9 successes record_create_mock.side_effect = [ RateLimitException('boo', period=0), - ] + ([None] * 9) + ] + ([None] * 10) got_n = provider.apply(plan) self.assertEquals(expected_n, got_n) diff --git a/tests/test_octodns_provider_powerdns.py b/tests/test_octodns_provider_powerdns.py index 5775f41..92211d1 100644 --- a/tests/test_octodns_provider_powerdns.py +++ b/tests/test_octodns_provider_powerdns.py @@ -185,7 +185,7 @@ class TestPowerDnsProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) - expected_n = len(expected.records) - 3 + expected_n = len(expected.records) - 4 self.assertEquals(19, expected_n) # No diffs == no changes @@ -291,7 +291,7 @@ class TestPowerDnsProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) - self.assertEquals(22, len(expected.records)) + self.assertEquals(23, len(expected.records)) # A small change to a single record with requests_mock() as mock: diff --git a/tests/test_octodns_provider_yaml.py b/tests/test_octodns_provider_yaml.py index 872fcca..7e4f6f7 100644 --- a/tests/test_octodns_provider_yaml.py +++ b/tests/test_octodns_provider_yaml.py @@ -35,7 +35,7 @@ class TestYamlProvider(TestCase): # without it we see everything source.populate(zone) - self.assertEquals(22, len(zone.records)) + self.assertEquals(23, len(zone.records)) source.populate(dynamic_zone) self.assertEquals(6, len(dynamic_zone.records)) @@ -58,12 +58,12 @@ class TestYamlProvider(TestCase): # We add everything plan = target.plan(zone) - self.assertEquals(19, len([c for c in plan.changes + self.assertEquals(20, len([c for c in plan.changes if isinstance(c, Create)])) self.assertFalse(isfile(yaml_file)) # Now actually do it - self.assertEquals(19, target.apply(plan)) + self.assertEquals(20, target.apply(plan)) self.assertTrue(isfile(yaml_file)) # Dynamic plan @@ -87,7 +87,7 @@ class TestYamlProvider(TestCase): # A 2nd sync should still create everything plan = target.plan(zone) - self.assertEquals(19, len([c for c in plan.changes + self.assertEquals(20, len([c for c in plan.changes if isinstance(c, Create)])) with open(yaml_file) as fh: @@ -107,6 +107,7 @@ class TestYamlProvider(TestCase): self.assertTrue('values' in data.pop('sub')) self.assertTrue('values' in data.pop('txt')) self.assertTrue('values' in data.pop('loc')) + self.assertTrue('values' in data.pop('urlfwd')) # these are stored as singular 'value' self.assertTrue('value' in data.pop('_imap._tcp')) self.assertTrue('value' in data.pop('_pop3._tcp')) @@ -248,7 +249,7 @@ class TestSplitYamlProvider(TestCase): # without it we see everything source.populate(zone) - self.assertEquals(19, len(zone.records)) + self.assertEquals(20, len(zone.records)) source.populate(dynamic_zone) self.assertEquals(5, len(dynamic_zone.records)) @@ -263,12 +264,12 @@ class TestSplitYamlProvider(TestCase): # We add everything plan = target.plan(zone) - self.assertEquals(16, len([c for c in plan.changes + self.assertEquals(17, len([c for c in plan.changes if isinstance(c, Create)])) self.assertFalse(isdir(zone_dir)) # Now actually do it - self.assertEquals(16, target.apply(plan)) + self.assertEquals(17, target.apply(plan)) # Dynamic plan plan = target.plan(dynamic_zone) @@ -291,7 +292,7 @@ class TestSplitYamlProvider(TestCase): # A 2nd sync should still create everything plan = target.plan(zone) - self.assertEquals(16, len([c for c in plan.changes + self.assertEquals(17, len([c for c in plan.changes if isinstance(c, Create)])) yaml_file = join(zone_dir, '$unit.tests.yaml') @@ -306,7 +307,8 @@ class TestSplitYamlProvider(TestCase): # These records are stored as plural "values." Check each file to # ensure correctness. - for record_name in ('_srv._tcp', 'mx', 'naptr', 'sub', 'txt'): + for record_name in ('_srv._tcp', 'mx', 'naptr', 'sub', 'txt', + 'urlfwd'): yaml_file = join(zone_dir, '{}.yaml'.format(record_name)) self.assertTrue(isfile(yaml_file)) with open(yaml_file) as fh: diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 315670e..3bd48e5 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -12,8 +12,8 @@ from octodns.record import ARecord, AaaaRecord, AliasRecord, CaaRecord, \ CaaValue, CnameRecord, DnameRecord, Create, Delete, GeoValue, LocRecord, \ LocValue, MxRecord, MxValue, NaptrRecord, NaptrValue, NsRecord, \ PtrRecord, Record, SshfpRecord, SshfpValue, SpfRecord, SrvRecord, \ - SrvValue, TxtRecord, Update, ValidationError, _Dynamic, _DynamicPool, \ - _DynamicRule + SrvValue, TxtRecord, Update, UrlfwdRecord, UrlfwdValue, ValidationError, \ + _Dynamic, _DynamicPool, _DynamicRule from octodns.zone import Zone from helpers import DynamicProvider, GeoProvider, SimpleProvider @@ -884,6 +884,112 @@ class TestRecord(TestCase): b_value = 'b other' self.assertMultipleValues(TxtRecord, a_values, b_value) + def test_urlfwd(self): + a_values = [{ + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 0, + }, { + 'path': '/target', + 'target': 'http://target', + 'code': 302, + 'masking': 2, + 'query': 0, + }] + a_data = {'ttl': 30, 'values': a_values} + a = UrlfwdRecord(self.zone, 'a', a_data) + self.assertEquals('a', a.name) + self.assertEquals('a.unit.tests.', a.fqdn) + self.assertEquals(30, a.ttl) + self.assertEquals(a_values[0]['path'], a.values[0].path) + self.assertEquals(a_values[0]['target'], a.values[0].target) + self.assertEquals(a_values[0]['code'], a.values[0].code) + self.assertEquals(a_values[0]['masking'], a.values[0].masking) + self.assertEquals(a_values[0]['query'], a.values[0].query) + self.assertEquals(a_values[1]['path'], a.values[1].path) + self.assertEquals(a_values[1]['target'], a.values[1].target) + self.assertEquals(a_values[1]['code'], a.values[1].code) + self.assertEquals(a_values[1]['masking'], a.values[1].masking) + self.assertEquals(a_values[1]['query'], a.values[1].query) + self.assertEquals(a_data, a.data) + + b_value = { + 'path': '/', + 'target': 'http://location', + 'code': 301, + 'masking': 2, + 'query': 0, + } + b_data = {'ttl': 30, 'value': b_value} + b = UrlfwdRecord(self.zone, 'b', b_data) + self.assertEquals(b_value['path'], b.values[0].path) + self.assertEquals(b_value['target'], b.values[0].target) + self.assertEquals(b_value['code'], b.values[0].code) + self.assertEquals(b_value['masking'], b.values[0].masking) + self.assertEquals(b_value['query'], b.values[0].query) + self.assertEquals(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in path causes change + other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].path = '/change' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in target causes change + other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].target = 'http://target' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in code causes change + other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].code = 302 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in masking causes change + other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].masking = 0 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in query causes change + other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].query = 1 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # hash + v = UrlfwdValue({ + 'path': '/', + 'target': 'http://place', + 'code': 301, + 'masking': 2, + 'query': 0, + }) + o = UrlfwdValue({ + 'path': '/location', + 'target': 'http://redirect', + 'code': 302, + 'masking': 2, + 'query': 0, + }) + values = set() + values.add(v) + self.assertTrue(v in values) + self.assertFalse(o in values) + values.add(o) + self.assertTrue(o in values) + + # __repr__ doesn't blow up + a.__repr__() + def test_record_new(self): txt = Record.new(self.zone, 'txt', { 'ttl': 44, @@ -3019,6 +3125,203 @@ class TestRecordValidation(TestCase): # should be chunked values, with quoting self.assertEquals(single.chunked_values, chunked.chunked_values) + def test_URLFWD(self): + # doesn't blow up + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 0, + } + }) + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'values': [{ + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 0, + }, { + 'path': '/target', + 'target': 'http://target', + 'code': 302, + 'masking': 2, + 'query': 0, + }] + }) + + # missing path + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 0, + } + }) + self.assertEquals(['missing path'], ctx.exception.reasons) + + # missing target + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'code': 301, + 'masking': 2, + 'query': 0, + } + }) + self.assertEquals(['missing target'], ctx.exception.reasons) + + # missing code + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'masking': 2, + 'query': 0, + } + }) + self.assertEquals(['missing code'], ctx.exception.reasons) + + # invalid code + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 'nope', + 'masking': 2, + 'query': 0, + } + }) + self.assertEquals(['invalid return code "nope"'], + ctx.exception.reasons) + + # unrecognized code + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 3, + 'masking': 2, + 'query': 0, + } + }) + self.assertEquals(['unrecognized return code "3"'], + ctx.exception.reasons) + + # missing masking + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'query': 0, + } + }) + self.assertEquals(['missing masking'], ctx.exception.reasons) + + # invalid masking + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 'nope', + 'query': 0, + } + }) + self.assertEquals(['invalid masking setting "nope"'], + ctx.exception.reasons) + + # unrecognized masking + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 3, + 'query': 0, + } + }) + self.assertEquals(['unrecognized masking setting "3"'], + ctx.exception.reasons) + + # missing query + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + } + }) + self.assertEquals(['missing query'], ctx.exception.reasons) + + # invalid query + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 'nope', + } + }) + self.assertEquals(['invalid query setting "nope"'], + ctx.exception.reasons) + + # unrecognized query + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 3, + } + }) + self.assertEquals(['unrecognized query setting "3"'], + ctx.exception.reasons) + class TestDynamicRecords(TestCase): zone = Zone('unit.tests.', [])