1
0
mirror of https://github.com/github/octodns.git synced 2024-05-11 05:55:00 +00:00
Files
github-octodns/octodns/record/base.py
2023-01-02 10:12:59 -05:00

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}>'