mirror of
https://github.com/github/octodns.git
synced 2024-05-11 05:55:00 +00:00
383 lines
12 KiB
Python
383 lines
12 KiB
Python
#
|
|
#
|
|
#
|
|
|
|
import re
|
|
from logging import getLogger
|
|
|
|
from .change import Update
|
|
from .geo import GeoCodes
|
|
|
|
|
|
class _DynamicPool(object):
|
|
log = getLogger('_DynamicPool')
|
|
|
|
def __init__(self, _id, data, value_type):
|
|
self._id = _id
|
|
|
|
values = [
|
|
{
|
|
'value': value_type(d['value']),
|
|
'weight': d.get('weight', 1),
|
|
'status': d.get('status', 'obey'),
|
|
}
|
|
for d in data['values']
|
|
]
|
|
values.sort(key=lambda d: d['value'])
|
|
|
|
# normalize weight of a single-value pool
|
|
if len(values) == 1:
|
|
weight = data['values'][0].get('weight', 1)
|
|
if weight != 1:
|
|
self.log.warning(
|
|
'Using weight=1 instead of %s for single-value pool %s',
|
|
weight,
|
|
_id,
|
|
)
|
|
values[0]['weight'] = 1
|
|
|
|
fallback = data.get('fallback', None)
|
|
self.data = {
|
|
'fallback': fallback if fallback != 'default' else None,
|
|
'values': values,
|
|
}
|
|
|
|
def _data(self):
|
|
return self.data
|
|
|
|
def __eq__(self, other):
|
|
if not isinstance(other, _DynamicPool):
|
|
return False
|
|
return self.data == other.data
|
|
|
|
def __ne__(self, other):
|
|
return not self.__eq__(other)
|
|
|
|
def __repr__(self):
|
|
return f'{self.data}'
|
|
|
|
|
|
class _DynamicRule(object):
|
|
def __init__(self, i, data):
|
|
self.i = i
|
|
|
|
self.data = {}
|
|
try:
|
|
self.data['pool'] = data['pool']
|
|
except KeyError:
|
|
pass
|
|
try:
|
|
self.data['geos'] = sorted(data['geos'])
|
|
except KeyError:
|
|
pass
|
|
|
|
def _data(self):
|
|
return self.data
|
|
|
|
def __eq__(self, other):
|
|
if not isinstance(other, _DynamicRule):
|
|
return False
|
|
return self.data == other.data
|
|
|
|
def __ne__(self, other):
|
|
return not self.__eq__(other)
|
|
|
|
def __repr__(self):
|
|
return f'{self.data}'
|
|
|
|
|
|
class _Dynamic(object):
|
|
def __init__(self, pools, rules):
|
|
self.pools = pools
|
|
self.rules = rules
|
|
|
|
def _data(self):
|
|
pools = {}
|
|
for _id, pool in self.pools.items():
|
|
pools[_id] = pool._data()
|
|
rules = []
|
|
for rule in self.rules:
|
|
rules.append(rule._data())
|
|
return {'pools': pools, 'rules': rules}
|
|
|
|
def __eq__(self, other):
|
|
if not isinstance(other, _Dynamic):
|
|
return False
|
|
ret = self.pools == other.pools and self.rules == other.rules
|
|
return ret
|
|
|
|
def __ne__(self, other):
|
|
return not self.__eq__(other)
|
|
|
|
def __repr__(self):
|
|
return f'{self.pools}, {self.rules}'
|
|
|
|
|
|
class _DynamicMixin(object):
|
|
geo_re = re.compile(
|
|
r'^(?P<continent_code>\w\w)(-(?P<country_code>\w\w)'
|
|
r'(-(?P<subdivision_code>\w\w))?)?$'
|
|
)
|
|
|
|
@classmethod
|
|
def _validate_pools(cls, pools):
|
|
reasons = []
|
|
pools_exist = set()
|
|
pools_seen_as_fallback = set()
|
|
if not isinstance(pools, dict):
|
|
reasons.append('pools must be a dict')
|
|
elif not pools:
|
|
reasons.append('missing pools')
|
|
else:
|
|
for _id, pool in sorted(pools.items()):
|
|
if not isinstance(pool, dict):
|
|
reasons.append(f'pool "{_id}" must be a dict')
|
|
continue
|
|
try:
|
|
values = pool['values']
|
|
except KeyError:
|
|
reasons.append(f'pool "{_id}" is missing values')
|
|
continue
|
|
|
|
pools_exist.add(_id)
|
|
|
|
for i, value in enumerate(values):
|
|
value_num = i + 1
|
|
try:
|
|
weight = value['weight']
|
|
weight = int(weight)
|
|
if weight < 1 or weight > 100:
|
|
reasons.append(
|
|
f'invalid weight "{weight}" in '
|
|
f'pool "{_id}" value {value_num}'
|
|
)
|
|
except KeyError:
|
|
pass
|
|
except ValueError:
|
|
reasons.append(
|
|
f'invalid weight "{weight}" in '
|
|
f'pool "{_id}" value {value_num}'
|
|
)
|
|
|
|
try:
|
|
status = value['status']
|
|
if status not in ['up', 'down', 'obey']:
|
|
reasons.append(
|
|
f'invalid status "{status}" in '
|
|
f'pool "{_id}" value {value_num}'
|
|
)
|
|
except KeyError:
|
|
pass
|
|
|
|
try:
|
|
value = value['value']
|
|
reasons.extend(
|
|
cls._value_type.validate(value, cls._type)
|
|
)
|
|
except KeyError:
|
|
reasons.append(
|
|
f'missing value in pool "{_id}" '
|
|
f'value {value_num}'
|
|
)
|
|
|
|
if len(values) == 1 and values[0].get('weight', 1) != 1:
|
|
reasons.append(
|
|
f'pool "{_id}" has single value with weight!=1'
|
|
)
|
|
|
|
fallback = pool.get('fallback', None)
|
|
if fallback is not None:
|
|
if fallback in pools:
|
|
pools_seen_as_fallback.add(fallback)
|
|
else:
|
|
reasons.append(
|
|
f'undefined fallback "{fallback}" '
|
|
f'for pool "{_id}"'
|
|
)
|
|
|
|
# Check for loops
|
|
fallback = pools[_id].get('fallback', None)
|
|
seen = [_id, fallback]
|
|
while fallback is not None:
|
|
# See if there's a next fallback
|
|
fallback = pools.get(fallback, {}).get('fallback', None)
|
|
if fallback in seen:
|
|
loop = ' -> '.join(seen)
|
|
reasons.append(f'loop in pool fallbacks: {loop}')
|
|
# exit the loop
|
|
break
|
|
seen.append(fallback)
|
|
|
|
return reasons, pools_exist, pools_seen_as_fallback
|
|
|
|
@classmethod
|
|
def _validate_rules(cls, pools, rules):
|
|
reasons = []
|
|
pools_seen = set()
|
|
|
|
geos_seen = {}
|
|
|
|
if not isinstance(rules, (list, tuple)):
|
|
reasons.append('rules must be a list')
|
|
elif not rules:
|
|
reasons.append('missing rules')
|
|
else:
|
|
seen_default = False
|
|
|
|
for i, rule in enumerate(rules):
|
|
rule_num = i + 1
|
|
try:
|
|
pool = rule['pool']
|
|
except KeyError:
|
|
reasons.append(f'rule {rule_num} missing pool')
|
|
continue
|
|
|
|
try:
|
|
geos = rule['geos']
|
|
except KeyError:
|
|
geos = []
|
|
|
|
if not isinstance(pool, str):
|
|
reasons.append(f'rule {rule_num} invalid pool "{pool}"')
|
|
else:
|
|
if pool not in pools:
|
|
reasons.append(
|
|
f'rule {rule_num} undefined pool ' f'"{pool}"'
|
|
)
|
|
elif pool in pools_seen and geos:
|
|
reasons.append(
|
|
f'rule {rule_num} invalid, target '
|
|
f'pool "{pool}" reused'
|
|
)
|
|
pools_seen.add(pool)
|
|
|
|
if not geos:
|
|
if seen_default:
|
|
reasons.append(f'rule {rule_num} duplicate default')
|
|
seen_default = True
|
|
|
|
if not isinstance(geos, (list, tuple)):
|
|
reasons.append(f'rule {rule_num} geos must be a list')
|
|
else:
|
|
# sorted so that NA would come before NA-US so that the code
|
|
# below can detect rules that have needlessly targeted a
|
|
# more specific location along with it's parent/ancestor
|
|
for geo in sorted(geos):
|
|
reasons.extend(
|
|
GeoCodes.validate(geo, f'rule {rule_num} ')
|
|
)
|
|
|
|
# have we ever seen a broader version of the geo we're
|
|
# currently looking at, e.g. geo=NA-US and there was a
|
|
# previous rule with NA
|
|
for seen, where in geos_seen.items():
|
|
if geo == seen:
|
|
reasons.append(
|
|
f'rule {rule_num} targets geo {geo} which has previously been seen in rule {where}'
|
|
)
|
|
elif geo.startswith(seen):
|
|
reasons.append(
|
|
f'rule {rule_num} targets geo {geo} which is more specific than the previously seen {seen} in rule {where}'
|
|
)
|
|
|
|
geos_seen[geo] = rule_num
|
|
|
|
if 'geos' in rules[-1]:
|
|
reasons.append('final rule has "geos" and is not catchall')
|
|
|
|
return reasons, pools_seen
|
|
|
|
@classmethod
|
|
def validate(cls, name, fqdn, data):
|
|
reasons = super().validate(name, fqdn, data)
|
|
|
|
if 'dynamic' not in data:
|
|
return reasons
|
|
elif 'geo' in data:
|
|
reasons.append('"dynamic" record with "geo" content')
|
|
|
|
try:
|
|
pools = data['dynamic']['pools']
|
|
except KeyError:
|
|
pools = {}
|
|
|
|
pool_reasons, pools_exist, pools_seen_as_fallback = cls._validate_pools(
|
|
pools
|
|
)
|
|
reasons.extend(pool_reasons)
|
|
|
|
try:
|
|
rules = data['dynamic']['rules']
|
|
except KeyError:
|
|
rules = []
|
|
|
|
rule_reasons, pools_seen = cls._validate_rules(pools, rules)
|
|
reasons.extend(rule_reasons)
|
|
|
|
unused = pools_exist - pools_seen - pools_seen_as_fallback
|
|
if unused:
|
|
unused = '", "'.join(sorted(unused))
|
|
reasons.append(f'unused pools: "{unused}"')
|
|
|
|
return reasons
|
|
|
|
def __init__(self, zone, name, data, *args, **kwargs):
|
|
super().__init__(zone, name, data, *args, **kwargs)
|
|
|
|
self.dynamic = {}
|
|
|
|
if 'dynamic' not in data:
|
|
return
|
|
|
|
# pools
|
|
try:
|
|
pools = dict(data['dynamic']['pools'])
|
|
except:
|
|
pools = {}
|
|
|
|
for _id, pool in sorted(pools.items()):
|
|
pools[_id] = _DynamicPool(_id, pool, self._value_type)
|
|
|
|
# rules
|
|
try:
|
|
rules = list(data['dynamic']['rules'])
|
|
except:
|
|
rules = []
|
|
|
|
parsed = []
|
|
for i, rule in enumerate(rules):
|
|
parsed.append(_DynamicRule(i, rule))
|
|
|
|
# dynamic
|
|
self.dynamic = _Dynamic(pools, parsed)
|
|
|
|
def _data(self):
|
|
ret = super()._data()
|
|
if self.dynamic:
|
|
ret['dynamic'] = self.dynamic._data()
|
|
return ret
|
|
|
|
def changes(self, other, target):
|
|
if target.SUPPORTS_DYNAMIC:
|
|
if self.dynamic != other.dynamic:
|
|
return Update(self, other)
|
|
return super().changes(other, target)
|
|
|
|
def __repr__(self):
|
|
# TODO: improve this whole thing, we need multi-line...
|
|
if self.dynamic:
|
|
# TODO: this hack can't going to cut it, as part of said
|
|
# improvements the value types should deal with serializing their
|
|
# value
|
|
try:
|
|
values = self.values
|
|
except AttributeError:
|
|
values = self.value
|
|
|
|
klass = self.__class__.__name__
|
|
return (
|
|
f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, '
|
|
f'{values}, {self.dynamic}>'
|
|
)
|
|
return super().__repr__()
|