mirror of
https://github.com/github/octodns.git
synced 2024-05-11 05:55:00 +00:00
341 lines
10 KiB
Python
341 lines
10 KiB
Python
#
|
|
#
|
|
#
|
|
|
|
from collections import defaultdict
|
|
from logging import getLogger
|
|
|
|
from ..equality import EqualityTupleMixin
|
|
from ..idna import IdnaError, idna_decode, idna_encode
|
|
from .change import Update
|
|
from .exception import RecordException, ValidationError
|
|
|
|
|
|
class Record(EqualityTupleMixin):
|
|
log = getLogger('Record')
|
|
|
|
_CLASSES = {}
|
|
|
|
@classmethod
|
|
def register_type(cls, _class, _type=None):
|
|
if _type is None:
|
|
_type = _class._type
|
|
existing = cls._CLASSES.get(_type)
|
|
if existing:
|
|
module = existing.__module__
|
|
name = existing.__name__
|
|
msg = f'Type "{_type}" already registered by {module}.{name}'
|
|
raise RecordException(msg)
|
|
cls._CLASSES[_type] = _class
|
|
|
|
@classmethod
|
|
def registered_types(cls):
|
|
return cls._CLASSES
|
|
|
|
@classmethod
|
|
def new(cls, zone, name, data, source=None, lenient=False):
|
|
reasons = []
|
|
try:
|
|
name = idna_encode(str(name))
|
|
except IdnaError as e:
|
|
# convert the error into a reason
|
|
reasons.append(str(e))
|
|
name = str(name)
|
|
fqdn = f'{name}.{zone.name}' if name else zone.name
|
|
try:
|
|
_type = data['type']
|
|
except KeyError:
|
|
raise Exception(f'Invalid record {idna_decode(fqdn)}, missing type')
|
|
try:
|
|
_class = cls._CLASSES[_type]
|
|
except KeyError:
|
|
raise Exception(f'Unknown record type: "{_type}"')
|
|
reasons.extend(_class.validate(name, fqdn, data))
|
|
try:
|
|
lenient |= data['octodns']['lenient']
|
|
except KeyError:
|
|
pass
|
|
if reasons:
|
|
if lenient:
|
|
cls.log.warning(ValidationError.build_message(fqdn, reasons))
|
|
else:
|
|
raise ValidationError(fqdn, reasons)
|
|
return _class(zone, name, data, source=source)
|
|
|
|
@classmethod
|
|
def validate(cls, name, fqdn, data):
|
|
reasons = []
|
|
if name == '@':
|
|
reasons.append('invalid name "@", use "" instead')
|
|
n = len(fqdn)
|
|
if n > 253:
|
|
reasons.append(
|
|
f'invalid fqdn, "{idna_decode(fqdn)}" is too long at {n} '
|
|
'chars, max is 253'
|
|
)
|
|
for label in name.split('.'):
|
|
n = len(label)
|
|
if n > 63:
|
|
reasons.append(
|
|
f'invalid label, "{label}" is too long at {n}'
|
|
' chars, max is 63'
|
|
)
|
|
# TODO: look at the idna lib for a lot more potential validations...
|
|
try:
|
|
ttl = int(data['ttl'])
|
|
if ttl < 0:
|
|
reasons.append('invalid ttl')
|
|
except KeyError:
|
|
reasons.append('missing ttl')
|
|
try:
|
|
if data['octodns']['healthcheck']['protocol'] not in (
|
|
'HTTP',
|
|
'HTTPS',
|
|
'TCP',
|
|
):
|
|
reasons.append('invalid healthcheck protocol')
|
|
except KeyError:
|
|
pass
|
|
return reasons
|
|
|
|
@classmethod
|
|
def from_rrs(cls, zone, rrs, lenient=False):
|
|
# group records by name & type so that multiple rdatas can be combined
|
|
# into a single record when needed
|
|
grouped = defaultdict(list)
|
|
for rr in rrs:
|
|
grouped[(rr.name, rr._type)].append(rr)
|
|
|
|
records = []
|
|
# walk the grouped rrs converting each one to data and then create a
|
|
# record with that data
|
|
for _, rrs in sorted(grouped.items()):
|
|
rr = rrs[0]
|
|
name = zone.hostname_from_fqdn(rr.name)
|
|
_class = cls._CLASSES[rr._type]
|
|
data = _class.data_from_rrs(rrs)
|
|
record = Record.new(zone, name, data, lenient=lenient)
|
|
records.append(record)
|
|
|
|
return records
|
|
|
|
def __init__(self, zone, name, data, source=None):
|
|
self.zone = zone
|
|
if name:
|
|
# internally everything is idna
|
|
self.name = idna_encode(str(name))
|
|
# we'll keep a decoded version around for logs and errors
|
|
self.decoded_name = idna_decode(self.name)
|
|
else:
|
|
self.name = self.decoded_name = name
|
|
self.log.debug(
|
|
'__init__: zone.name=%s, type=%11s, name=%s',
|
|
zone.decoded_name,
|
|
self.__class__.__name__,
|
|
self.decoded_name,
|
|
)
|
|
self.source = source
|
|
self.ttl = int(data['ttl'])
|
|
|
|
self._octodns = data.get('octodns', {})
|
|
|
|
def _data(self):
|
|
return {'ttl': self.ttl}
|
|
|
|
@property
|
|
def data(self):
|
|
return self._data()
|
|
|
|
@property
|
|
def fqdn(self):
|
|
# TODO: these should be calculated and set in __init__ rather than on
|
|
# each use
|
|
if self.name:
|
|
return f'{self.name}.{self.zone.name}'
|
|
return self.zone.name
|
|
|
|
@property
|
|
def decoded_fqdn(self):
|
|
if self.decoded_name:
|
|
return f'{self.decoded_name}.{self.zone.decoded_name}'
|
|
return self.zone.decoded_name
|
|
|
|
@property
|
|
def ignored(self):
|
|
return self._octodns.get('ignored', False)
|
|
|
|
@property
|
|
def excluded(self):
|
|
return self._octodns.get('excluded', [])
|
|
|
|
@property
|
|
def included(self):
|
|
return self._octodns.get('included', [])
|
|
|
|
def healthcheck_host(self, value=None):
|
|
healthcheck = self._octodns.get('healthcheck', {})
|
|
if healthcheck.get('protocol', None) == 'TCP':
|
|
return None
|
|
return healthcheck.get('host', self.fqdn[:-1]) or value
|
|
|
|
@property
|
|
def healthcheck_path(self):
|
|
healthcheck = self._octodns.get('healthcheck', {})
|
|
if healthcheck.get('protocol', None) == 'TCP':
|
|
return None
|
|
try:
|
|
return healthcheck['path']
|
|
except KeyError:
|
|
return '/_dns'
|
|
|
|
@property
|
|
def healthcheck_protocol(self):
|
|
try:
|
|
return self._octodns['healthcheck']['protocol']
|
|
except KeyError:
|
|
return 'HTTPS'
|
|
|
|
@property
|
|
def healthcheck_port(self):
|
|
try:
|
|
return int(self._octodns['healthcheck']['port'])
|
|
except KeyError:
|
|
return 443
|
|
|
|
def changes(self, other, target):
|
|
# We're assuming we have the same name and type if we're being compared
|
|
if self.ttl != other.ttl:
|
|
return Update(self, other)
|
|
|
|
def copy(self, zone=None):
|
|
data = self.data
|
|
data['type'] = self._type
|
|
data['octodns'] = self._octodns
|
|
|
|
return Record.new(
|
|
zone if zone else self.zone,
|
|
self.name,
|
|
data,
|
|
self.source,
|
|
lenient=True,
|
|
)
|
|
|
|
# NOTE: we're using __hash__ and ordering methods that consider Records
|
|
# equivalent if they have the same name & _type. Values are ignored. This
|
|
# is useful when computing diffs/changes.
|
|
|
|
def __hash__(self):
|
|
return f'{self.name}:{self._type}'.__hash__()
|
|
|
|
def _equality_tuple(self):
|
|
return (self.name, self._type)
|
|
|
|
def __repr__(self):
|
|
# Make sure this is always overridden
|
|
raise NotImplementedError('Abstract base class, __repr__ required')
|
|
|
|
|
|
class ValuesMixin(object):
|
|
@classmethod
|
|
def validate(cls, name, fqdn, data):
|
|
reasons = super().validate(name, fqdn, data)
|
|
|
|
values = data.get('values', data.get('value', []))
|
|
|
|
reasons.extend(cls._value_type.validate(values, cls._type))
|
|
|
|
return reasons
|
|
|
|
@classmethod
|
|
def data_from_rrs(cls, rrs):
|
|
# type and TTL come from the first rr
|
|
rr = rrs[0]
|
|
# values come from parsing the rdata portion of all rrs
|
|
values = [cls._value_type.parse_rdata_text(rr.rdata) for rr in rrs]
|
|
return {'ttl': rr.ttl, 'type': rr._type, 'values': values}
|
|
|
|
def __init__(self, zone, name, data, source=None):
|
|
super().__init__(zone, name, data, source=source)
|
|
try:
|
|
values = data['values']
|
|
except KeyError:
|
|
values = [data['value']]
|
|
self.values = sorted(self._value_type.process(values))
|
|
|
|
def changes(self, other, target):
|
|
if self.values != other.values:
|
|
return Update(self, other)
|
|
return super().changes(other, target)
|
|
|
|
def _data(self):
|
|
ret = super()._data()
|
|
if len(self.values) > 1:
|
|
values = [getattr(v, 'data', v) for v in self.values if v]
|
|
if len(values) > 1:
|
|
ret['values'] = values
|
|
elif len(values) == 1:
|
|
ret['value'] = values[0]
|
|
elif len(self.values) == 1:
|
|
v = self.values[0]
|
|
if v:
|
|
ret['value'] = getattr(v, 'data', v)
|
|
|
|
return ret
|
|
|
|
@property
|
|
def rrs(self):
|
|
return (
|
|
self.fqdn,
|
|
self.ttl,
|
|
self._type,
|
|
[v.rdata_text for v in self.values],
|
|
)
|
|
|
|
def __repr__(self):
|
|
values = "', '".join([str(v) for v in self.values])
|
|
klass = self.__class__.__name__
|
|
return f"<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, ['{values}']>"
|
|
|
|
|
|
class ValueMixin(object):
|
|
@classmethod
|
|
def validate(cls, name, fqdn, data):
|
|
reasons = super().validate(name, fqdn, data)
|
|
reasons.extend(
|
|
cls._value_type.validate(data.get('value', None), cls._type)
|
|
)
|
|
return reasons
|
|
|
|
@classmethod
|
|
def data_from_rrs(cls, rrs):
|
|
# single value, so single rr only...
|
|
rr = rrs[0]
|
|
return {
|
|
'ttl': rr.ttl,
|
|
'type': rr._type,
|
|
'value': cls._value_type.parse_rdata_text(rr.rdata),
|
|
}
|
|
|
|
def __init__(self, zone, name, data, source=None):
|
|
super().__init__(zone, name, data, source=source)
|
|
self.value = self._value_type.process(data['value'])
|
|
|
|
def changes(self, other, target):
|
|
if self.value != other.value:
|
|
return Update(self, other)
|
|
return super().changes(other, target)
|
|
|
|
def _data(self):
|
|
ret = super()._data()
|
|
if self.value:
|
|
ret['value'] = getattr(self.value, 'data', self.value)
|
|
return ret
|
|
|
|
@property
|
|
def rrs(self):
|
|
return self.fqdn, self.ttl, self._type, [self.value.rdata_text]
|
|
|
|
def __repr__(self):
|
|
klass = self.__class__.__name__
|
|
return f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, {self.value}>'
|