Browse Source

Merge branch 'master' into constellix_healthcheck

pull/803/head
Artem Patserkovskyi 4 years ago
committed by GitHub
parent
commit
c8e1116461
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 354 additions and 130 deletions
  1. +21
    -1
      CHANGELOG.md
  2. +2
    -2
      README.md
  3. +1
    -1
      octodns/__init__.py
  4. +18
    -24
      octodns/provider/azuredns.py
  5. +117
    -80
      octodns/provider/ns1.py
  6. +3
    -4
      octodns/provider/route53.py
  7. +1
    -1
      octodns/record/__init__.py
  8. +7
    -4
      script/bootstrap
  9. +1
    -3
      script/cibuild
  10. +1
    -0
      setup.py
  11. +6
    -6
      tests/test_octodns_provider_azuredns.py
  12. +40
    -1
      tests/test_octodns_provider_ns1.py
  13. +134
    -1
      tests/test_octodns_provider_route53.py
  14. +2
    -2
      tests/test_octodns_record.py

+ 21
- 1
CHANGELOG.md View File

@ -1,4 +1,4 @@
## v0.9.14 - 2021-??-?? - A new supports system
## v0.9.14 - 2021-10-10 - A new supports system
#### Noteworthy changes
@ -25,6 +25,26 @@
new python versions and hasn't seen a release since 2010. May return with
https://github.com/octodns/octodns/pull/762
#### Stuff
* Fully remove python 2.7 support & sims
* Dynamic record pool status flag: up/down/obey added w/provider support as
possible.
* Support for multi-value PTRs where providers allow them
* Normalize IPv6 addresses to avoid false changes and simplify providers
* Include pure-python wheel distirubtions in release builds
* Improvements and updates to AzureProvider, especially w/respect to dynamic
records.
* NS1Provider support for IPv6 monitors and general caching/performance
improvements
* Route53Provider.get_zones_by_name option to avoid paging through huge lists
and hitting rate limits
* Misc Route53Provider
* Ensure no network access during testing (helps with runtime)
* Sped up the long pole unit tests
* Misc. ConstellixProvider, DigitalOceanProvider, GCoreProvider, and
Route53Provider fixes & improvements
## v0.9.13 - 2021-07-18 - Processors Alpha
#### Noteworthy changes


+ 2
- 2
README.md View File

@ -41,7 +41,7 @@ Running through the following commands will install the latest release of OctoDN
```shell
$ mkdir dns
$ cd dns
$ virtualenv env
$ python -m venv env
...
$ source env/bin/activate
$ pip install octodns <provider-specific-requirements>
@ -357,4 +357,4 @@ GitHub® and its stylized versions and the Invertocat mark are GitHub's Trademar
## Authors
OctoDNS was designed and authored by [Ross McFarland](https://github.com/ross) and [Joe Williams](https://github.com/joewilliams). It is now maintained, reviewed, and tested by Traffic Engineering team at GitHub.
OctoDNS was designed and authored by [Ross McFarland](https://github.com/ross) and [Joe Williams](https://github.com/joewilliams). See https://github.com/octodns/octodns/graphs/contributors for a complete list of people who've contributed.

+ 1
- 1
octodns/__init__.py View File

@ -3,4 +3,4 @@
from __future__ import absolute_import, division, print_function, \
unicode_literals
__VERSION__ = '0.9.13'
__VERSION__ = '0.9.14'

+ 18
- 24
octodns/provider/azuredns.py View File

@ -650,14 +650,12 @@ class AzureProvider(BaseProvider):
if azrecord.a_records is None:
if azrecord.target_resource.id:
return self._data_for_dynamic(azrecord)
else:
# dynamic record alias is broken, return dummy value and apply
# will likely overwrite/fix it
self.log.warn('_data_for_A: Missing Traffic Manager '
'alias for dynamic A record %s, forcing '
're-link by setting an invalid value',
azrecord.fqdn)
return {'values': ['255.255.255.255']}
# dynamic record alias is broken, return dummy value and apply
# will likely overwrite/fix it
self.log.warn('_data_for_A: Missing Traffic Manager alias for '
'dynamic record %s', azrecord.fqdn)
return {'values': []}
return {'values': [ar.ipv4_address for ar in azrecord.a_records]}
@ -665,14 +663,12 @@ class AzureProvider(BaseProvider):
if azrecord.aaaa_records is None:
if azrecord.target_resource.id:
return self._data_for_dynamic(azrecord)
else:
# dynamic record alias is broken, return dummy value and apply
# will likely overwrite/fix it
self.log.warn('_data_for_AAAA: Missing Traffic Manager '
'alias for dynamic AAAA record %s, forcing '
're-link by setting an invalid value',
azrecord.fqdn)
return {'values': ['::1']}
# dynamic record alias is broken, return dummy value and apply
# will likely overwrite/fix it
self.log.warn('_data_for_AAAA: Missing Traffic Manager alias for '
'dynamic record %s', azrecord.fqdn)
return {'values': []}
return {'values': [ar.ipv6_address for ar in azrecord.aaaa_records]}
@ -692,14 +688,12 @@ class AzureProvider(BaseProvider):
if azrecord.cname_record is None:
if azrecord.target_resource.id:
return self._data_for_dynamic(azrecord)
else:
# dynamic record alias is broken, return dummy value and apply
# will likely overwrite/fix it
self.log.warn('_data_for_CNAME: Missing Traffic Manager '
'alias for dynamic CNAME record %s, forcing '
're-link by setting an invalid value',
azrecord.fqdn)
return {'value': 'iam.invalid.'}
# dynamic record alias is broken, return dummy value and apply
# will likely overwrite/fix it
self.log.warn('_data_for_CNAME: Missing Traffic Manager alias for '
'dynamic record %s', azrecord.fqdn)
return {'value': None}
return {'value': _check_endswith_dot(azrecord.cname_record.cname)}


+ 117
- 80
octodns/provider/ns1.py View File

@ -545,21 +545,16 @@ class Ns1Provider(BaseProvider):
pass
return pool_name
def _data_for_dynamic(self, _type, record):
# First make sure we have the expected filters config
if not self._valid_filter_config(record['filters'], record['domain']):
self.log.error('_data_for_dynamic: %s %s has unsupported '
'filters', record['domain'], _type)
raise Ns1Exception('Unrecognized advanced record')
def _parse_pools(self, answers):
# All regions (pools) will include the list of default values
# (eventually) at higher priorities, we'll just add them to this set to
# we'll have the complete collection.
default = set()
# Fill out the pools by walking the answers and looking at their
# region.
# region (< v0.9.11) or notes (> v0.9.11).
pools = defaultdict(lambda: {'fallback': None, 'values': []})
for answer in record['answers']:
for answer in answers:
meta = answer['meta']
notes = self._parse_notes(meta.get('note', ''))
@ -579,7 +574,7 @@ class Ns1Provider(BaseProvider):
if meta['priority'] != 1:
# Ignore all but priority 1
continue
# And use region's pool name as the pool name
# And use region's name as the pool name
pool_name = self._parse_dynamic_pool_name(answer['region'])
else:
# > v0.9.11, use the notes-based name and consider all values
@ -603,18 +598,83 @@ class Ns1Provider(BaseProvider):
if fallback is not None:
pool['fallback'] = fallback
# Order and convert to a list
default = sorted(default)
return default, pools
def _parse_rule_geos(self, meta):
geos = set()
for georegion in meta.get('georegion', []):
geos.add(self._REGION_TO_CONTINENT[georegion])
# Countries are easy enough to map, we just have to find their
# continent
#
# NOTE: Some continents need special handling since NS1
# does not supprt them as regions. These are defined under
# _CONTINENT_TO_LIST_OF_COUNTRIES. So the countries for these
# regions will be present in meta['country']. If all the countries
# in _CONTINENT_TO_LIST_OF_COUNTRIES[<region>] list are found,
# set the continent as the region and remove individual countries
special_continents = dict()
for country in meta.get('country', []):
# country_alpha2_to_continent_code fails for Pitcairn ('PN'),
# United States Minor Outlying Islands ('UM') and
# Sint Maarten ('SX')
if country == 'PN':
con = 'OC'
elif country in ['SX', 'UM']:
con = 'NA'
else:
con = country_alpha2_to_continent_code(country)
if con in self._CONTINENT_TO_LIST_OF_COUNTRIES:
special_continents.setdefault(con, set()).add(country)
else:
geos.add(f'{con}-{country}')
for continent, countries in special_continents.items():
if countries == self._CONTINENT_TO_LIST_OF_COUNTRIES[
continent]:
# All countries found, so add it to geos
geos.add(continent)
else:
# Partial countries found, so just add them as-is to geos
for c in countries:
geos.add(f'{continent}-{c}')
# States and provinces are easy too,
# just assume NA-US or NA-CA
for state in meta.get('us_state', []):
geos.add(f'NA-US-{state}')
for province in meta.get('ca_province', []):
geos.add(f'NA-CA-{province}')
return geos
def _parse_rules(self, pools, regions):
# The regions objects map to rules, but it's a bit fuzzy since they're
# tied to pools on the NS1 side, e.g. we can only have 1 rule per pool,
# that may eventually run into problems, but I don't have any use-cases
# examples currently where it would
rules = {}
for pool_name, region in sorted(record['regions'].items()):
for pool_name, region in sorted(regions.items()):
# Get the actual pool name by removing the type
pool_name = self._parse_dynamic_pool_name(pool_name)
meta = region['meta']
notes = self._parse_notes(meta.get('note', ''))
# The group notes field in the UI is a `note` on the region here,
# that's where we can find our pool's fallback in < v0.9.11 anyway
if 'fallback' in notes:
# set the fallback pool name
pools[pool_name]['fallback'] = notes['fallback']
rule_order = notes['rule-order']
try:
rule = rules[rule_order]
@ -625,72 +685,26 @@ class Ns1Provider(BaseProvider):
}
rules[rule_order] = rule
# The group notes field in the UI is a `note` on the region here,
# that's where we can find our pool's fallback in < v0.9.11 anyway
if 'fallback' in notes:
# set the fallback pool name
pools[pool_name]['fallback'] = notes['fallback']
geos = set()
for georegion in meta.get('georegion', []):
geos.add(self._REGION_TO_CONTINENT[georegion])
# Countries are easy enough to map, we just have to find their
# continent
#
# NOTE: Some continents need special handling since NS1
# does not supprt them as regions. These are defined under
# _CONTINENT_TO_LIST_OF_COUNTRIES. So the countries for these
# regions will be present in meta['country']. If all the countries
# in _CONTINENT_TO_LIST_OF_COUNTRIES[<region>] list are found,
# set the continent as the region and remove individual countries
special_continents = dict()
for country in meta.get('country', []):
# country_alpha2_to_continent_code fails for Pitcairn ('PN'),
# United States Minor Outlying Islands ('UM') and
# Sint Maarten ('SX')
if country == 'PN':
con = 'OC'
elif country in ['SX', 'UM']:
con = 'NA'
else:
con = country_alpha2_to_continent_code(country)
if con in self._CONTINENT_TO_LIST_OF_COUNTRIES:
special_continents.setdefault(con, set()).add(country)
else:
geos.add(f'{con}-{country}')
for continent, countries in special_continents.items():
if countries == self._CONTINENT_TO_LIST_OF_COUNTRIES[
continent]:
# All countries found, so add it to geos
geos.add(continent)
else:
# Partial countries found, so just add them as-is to geos
for c in countries:
geos.add(f'{continent}-{c}')
# States and provinces are easy too,
# just assume NA-US or NA-CA
for state in meta.get('us_state', []):
geos.add(f'NA-US-{state}')
for province in meta.get('ca_province', []):
geos.add(f'NA-CA-{province}')
geos = self._parse_rule_geos(meta)
if geos:
# There are geos, combine them with any existing geos for this
# pool and recorded the sorted unique set of them
rule['geos'] = sorted(set(rule.get('geos', [])) | geos)
# Order and convert to a list
default = sorted(default)
# Convert to list and order
rules = list(rules.values())
rules.sort(key=lambda r: (r['_order'], r['pool']))
rules = sorted(rules.values(), key=lambda r: (r['_order'], r['pool']))
return rules
def _data_for_dynamic(self, _type, record):
# First make sure we have the expected filters config
if not self._valid_filter_config(record['filters'], record['domain']):
self.log.error('_data_for_dynamic: %s %s has unsupported '
'filters', record['domain'], _type)
raise Ns1Exception('Unrecognized advanced record')
default, pools = self._parse_pools(record['answers'])
rules = self._parse_rules(pools, record['regions'])
data = {
'dynamic': {
@ -757,13 +771,25 @@ class Ns1Provider(BaseProvider):
def _data_for_CNAME(self, _type, record):
if record.get('tier', 1) > 1:
# Advanced dynamic record
return self._data_for_dynamic(_type, record)
try:
value = record['short_answers'][0]
except IndexError:
# Advanced record, see if it's first answer has a note
try:
first_answer_note = record['answers'][0]['meta']['note']
except (IndexError, KeyError):
first_answer_note = ''
# If that note includes a `pool` it's a valid dynamic record
if 'pool:' in first_answer_note:
return self._data_for_dynamic(_type, record)
# If not, it can't be parsed. Let it be an empty record
self.log.warn('Cannot parse %s dynamic record due to missing '
'pool name in first answer note, treating it as '
'an empty record', record['domain'])
value = None
else:
try:
value = record['short_answers'][0]
except IndexError:
value = None
return {
'ttl': record['ttl'],
'type': _type,
@ -1182,10 +1208,8 @@ class Ns1Provider(BaseProvider):
}
answers.append(answer)
def _params_for_dynamic(self, record):
def _generate_regions(self, record):
pools = record.dynamic.pools
# Convert rules to regions
has_country = False
has_region = False
regions = {}
@ -1267,6 +1291,10 @@ class Ns1Provider(BaseProvider):
'meta': meta,
}
return has_country, has_region, regions
def _generate_answers(self, record, regions):
pools = record.dynamic.pools
existing_monitors = self._monitors_for(record)
active_monitors = set()
@ -1324,6 +1352,15 @@ class Ns1Provider(BaseProvider):
pool_label, pool_answers, pools,
priority)
return active_monitors, answers
def _params_for_dynamic(self, record):
# Convert rules to regions
has_country, has_region, regions = self._generate_regions(record)
# Convert pools to answers
active_monitors, answers = self._generate_answers(record, regions)
# Update filters as necessary
filters = self._get_updated_filter_chain(has_region, has_country)


+ 3
- 4
octodns/provider/route53.py View File

@ -682,11 +682,11 @@ class Route53Provider(BaseProvider):
def _get_zone_id(self, name, create=False):
self.log.debug('_get_zone_id: name=%s', name)
self.update_r53_zones(name)
id = None
if name in self._r53_zones:
id = self._r53_zones[name]
self.log.debug('_get_zone_id: id=%s', id)
return id
if create:
if create and not id:
ref = uuid4().hex
del_set = self.delegation_set_id
self.log.debug('_get_zone_id: no matching zone, creating, '
@ -699,8 +699,7 @@ class Route53Provider(BaseProvider):
resp = self._conn.create_hosted_zone(Name=name,
CallerReference=ref)
self._r53_zones[name] = id = resp['HostedZone']['Id']
return id
return None
return id
def _parse_geo(self, rrset):
try:


+ 1
- 1
octodns/record/__init__.py View File

@ -557,7 +557,7 @@ class _DynamicMixin(object):
try:
weight = value['weight']
weight = int(weight)
if weight < 1 or weight > 15:
if weight < 1 or weight > 100:
reasons.append(f'invalid weight "{weight}" in '
f'pool "{_id}" value {value_num}')
except KeyError:


+ 7
- 4
script/bootstrap View File

@ -15,15 +15,18 @@ if [ ! -d "$VENV_NAME" ]; then
if [ -z "$VENV_PYTHON" ]; then
VENV_PYTHON=$(command -v python3)
fi
virtualenv --python="$VENV_PYTHON" "$VENV_NAME"
"$VENV_PYTHON" -m venv "$VENV_NAME"
fi
. "$VENV_NAME/bin/activate"
pip install -U 'pip>=10.0.1'
pip install -r requirements.txt
# We're in the venv now, so use the first Python in $PATH. In particular, don't
# use $VENV_PYTHON - that's the Python that *created* the venv, not the python
# *inside* the venv
python -m pip install -U 'pip>=10.0.1'
python -m pip install -r requirements.txt
if [ "$ENV" != "production" ]; then
pip install -r requirements-dev.txt
python -m pip install -r requirements-dev.txt
fi
if [ ! -L ".git/hooks/pre-commit" ]; then


+ 1
- 3
script/cibuild View File

@ -8,9 +8,7 @@ script/bootstrap
echo "## environment & versions ######################################################"
python --version
pip --version
VVER=$(virtualenv --version)
echo "virtualenv $VVER"
python -m pip --version
if [ -z "$VENV_NAME" ]; then
VENV_NAME="env"


+ 1
- 0
setup.py View File

@ -80,6 +80,7 @@ setup(
long_description_content_type='text/markdown',
name='octodns',
packages=find_packages(),
python_requires='>=3.6',
url='https://github.com/octodns/octodns',
version=octodns.__VERSION__,
)

+ 6
- 6
tests/test_octodns_provider_azuredns.py View File

@ -1934,8 +1934,8 @@ class TestAzureDnsProvider(TestCase):
ttl=60, target_resource=SubResource(id=None))
azrecord.name = record.name or '@'
azrecord.type = f'Microsoft.Network/dnszones/{record._type}'
record2 = provider._populate_record(zone, azrecord)
self.assertEqual(record2.values, ['255.255.255.255'])
record2 = provider._populate_record(zone, azrecord, lenient=True)
self.assertEqual(record2.values, [])
# test that same record gets populated back from traffic managers
tm_list = provider._tm_client.profiles.list_by_resource_group
@ -2016,8 +2016,8 @@ class TestAzureDnsProvider(TestCase):
ttl=60, target_resource=SubResource(id=None))
azrecord.name = record.name or '@'
azrecord.type = f'Microsoft.Network/dnszones/{record._type}'
record2 = provider._populate_record(zone, azrecord)
self.assertEqual(record2.values, ['::1'])
record2 = provider._populate_record(zone, azrecord, lenient=True)
self.assertEqual(record2.values, [])
# test that same record gets populated back from traffic managers
tm_list = provider._tm_client.profiles.list_by_resource_group
@ -2259,8 +2259,8 @@ class TestAzureDnsProvider(TestCase):
azrecord.name = record1.name or '@'
azrecord.type = f'Microsoft.Network/dnszones/{record1._type}'
record2 = provider._populate_record(zone, azrecord)
self.assertEqual(record2.value, 'iam.invalid.')
record2 = provider._populate_record(zone, azrecord, lenient=True)
self.assertIsNone(record2.value)
change = Update(record2, record1)
provider._apply_Update(change)


+ 40
- 1
tests/test_octodns_provider_ns1.py View File

@ -1961,7 +1961,7 @@ class TestNs1ProviderDynamic(TestCase):
'meta': {
'priority': 1,
'weight': 12,
'note': f'from:{catchall_pool_name}',
'note': f'pool:iad from:{catchall_pool_name}',
'up': {},
},
'region': catchall_pool_name,
@ -2009,6 +2009,45 @@ class TestNs1ProviderDynamic(TestCase):
'value': 'value.unit.tests.',
}, data)
def test_data_for_invalid_dynamic_CNAME(self):
provider = Ns1Provider('test', 'api-key')
# Potential setup created outside of octoDNS, so it could be missing
# notes and region names can be arbitrary
filters = provider._get_updated_filter_chain(False, False)
ns1_record = {
'answers': [{
'answer': ['iad.unit.tests.'],
'meta': {
'priority': 1,
'weight': 12,
'up': {},
},
'region': 'global',
}, {
'answer': ['value.unit.tests.'],
'meta': {
'priority': 2,
'up': {},
},
'region': 'global',
}],
'domain': 'foo.unit.tests',
'filters': filters,
'regions': {
'global': {},
},
'tier': 3,
'ttl': 44,
'type': 'CNAME',
}
data = provider._data_for_CNAME('CNAME', ns1_record)
self.assertEquals({
'ttl': 44,
'type': 'CNAME',
'value': None,
}, data)
@patch('ns1.rest.records.Records.retrieve')
@patch('ns1.rest.zones.Zones.retrieve')
@patch('octodns.provider.ns1.Ns1Provider._monitors_for')


+ 134
- 1
tests/test_octodns_provider_route53.py View File

@ -1847,7 +1847,7 @@ class TestRoute53Provider(TestCase):
self.assertEquals([], extra)
stubber.assert_no_pending_responses()
def test_plan_with_get_zones_by_name(self):
def test_plan_apply_with_get_zones_by_name_zone_not_exists(self):
provider = Route53Provider(
'test', 'abc', '123', get_zones_by_name=True)
@ -1874,6 +1874,139 @@ class TestRoute53Provider(TestCase):
plan = provider.plan(self.expected)
self.assertEquals(9, len(plan.changes))
create_hosted_zone_resp = {
'HostedZone': {
'Name': 'unit.tests.',
'Id': 'z42',
'CallerReference': 'abc',
},
'ChangeInfo': {
'Id': 'a12',
'Status': 'PENDING',
'SubmittedAt': '2017-01-29T01:02:03Z',
'Comment': 'hrm',
},
'DelegationSet': {
'Id': 'b23',
'CallerReference': 'blip',
'NameServers': [
'n12.unit.tests.',
],
},
'Location': 'us-east-1',
}
stubber.add_response('create_hosted_zone',
create_hosted_zone_resp, {
'Name': 'unit.tests.',
'CallerReference': ANY,
})
list_resource_record_sets_resp = {
'ResourceRecordSets': [{
'Name': 'a.unit.tests.',
'Type': 'A',
'GeoLocation': {
'ContinentCode': 'NA',
},
'ResourceRecords': [{
'Value': '2.2.3.4',
}],
'TTL': 61,
}],
'IsTruncated': False,
'MaxItems': '100',
}
stubber.add_response('list_resource_record_sets',
list_resource_record_sets_resp,
{'HostedZoneId': 'z42'})
stubber.add_response('list_health_checks',
{
'HealthChecks': self.health_checks,
'IsTruncated': False,
'MaxItems': '100',
'Marker': '',
})
stubber.add_response('change_resource_record_sets',
{'ChangeInfo': {
'Id': 'id',
'Status': 'PENDING',
'SubmittedAt': '2017-01-29T01:02:03Z',
}}, {'HostedZoneId': 'z42', 'ChangeBatch': ANY})
self.assertEquals(9, provider.apply(plan))
stubber.assert_no_pending_responses()
def test_plan_apply_with_get_zones_by_name_zone_exists(self):
provider = Route53Provider(
'test', 'abc', '123', get_zones_by_name=True)
# Use the stubber
stubber = Stubber(provider._conn)
stubber.activate()
list_hosted_zones_by_name_resp = {
'HostedZones': [{
'Id': 'z42',
'Name': 'unit.tests.',
'CallerReference': 'abc',
'Config': {
'Comment': 'string',
'PrivateZone': False
},
'ResourceRecordSetCount': 123,
}, ],
'DNSName': 'unit.tests.',
'HostedZoneId': 'z42',
'IsTruncated': False,
'MaxItems': 'string'
}
list_resource_record_sets_resp = {
'ResourceRecordSets': [{
'Name': 'a.unit.tests.',
'Type': 'A',
'ResourceRecords': [{
'Value': '2.2.3.4',
}],
'TTL': 61,
}],
'IsTruncated': False,
'MaxItems': '100',
}
stubber.add_response(
'list_hosted_zones_by_name',
list_hosted_zones_by_name_resp,
{'DNSName': 'unit.tests.', 'MaxItems': '1'}
)
stubber.add_response('list_resource_record_sets',
list_resource_record_sets_resp,
{'HostedZoneId': 'z42'})
plan = provider.plan(self.expected)
self.assertEquals(10, len(plan.changes))
stubber.add_response('list_health_checks',
{
'HealthChecks': self.health_checks,
'IsTruncated': False,
'MaxItems': '100',
'Marker': '',
})
stubber.add_response('change_resource_record_sets',
{'ChangeInfo': {
'Id': 'id',
'Status': 'PENDING',
'SubmittedAt': '2017-01-29T01:02:03Z',
}}, {'HostedZoneId': 'z42', 'ChangeBatch': ANY})
self.assertEquals(10, provider.apply(plan))
stubber.assert_no_pending_responses()
def test_extra_change_no_health_check(self):
provider, stubber = self._get_stubbed_provider()


+ 2
- 2
tests/test_octodns_record.py View File

@ -3895,7 +3895,7 @@ class TestDynamicRecords(TestCase):
'weight': 1,
'value': '6.6.6.6',
}, {
'weight': 16,
'weight': 101,
'value': '7.7.7.7',
}],
},
@ -3919,7 +3919,7 @@ class TestDynamicRecords(TestCase):
}
with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, 'bad', a_data)
self.assertEquals(['invalid weight "16" in pool "three" value 2'],
self.assertEquals(['invalid weight "101" in pool "three" value 2'],
ctx.exception.reasons)
# invalid non-int weight


Loading…
Cancel
Save