diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c082948..76decff 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,3 +24,15 @@ jobs: - name: CI Build run: | ./script/cibuild + setup-py: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: '3.10' + architecture: x64 + - name: CI setup.py + run: | + ./script/cibuild-setup-py diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a91ef..ba1aa96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ just standardizes what they are doing as many other providers appear to need to do so, but weren't. There was an ordering before, but it was essentially arbitrarily picked. +* Record.register_type added so that providers can register custom record + types, see [docs/records.md](docs/records.md) for more information #### Stuff diff --git a/docs/records.md b/docs/records.md index f210846..182a109 100644 --- a/docs/records.md +++ b/docs/records.md @@ -124,10 +124,10 @@ If you'd like to enable lenience for a whole zone you can do so with the followi #### Restrict Record manipulations -OctoDNS currently provides the ability to limit the number of updates/deletes on +OctoDNS currently provides the ability to limit the number of updates/deletes on DNS records by configuring a percentage of allowed operations as a threshold. -If left unconfigured, suitable defaults take over instead. In the below example, -the Dyn provider is configured with limits of 40% on both update and +If left unconfigured, suitable defaults take over instead. In the below example, +the Dyn provider is configured with limits of 40% on both update and delete operations over all the records present. ````yaml @@ -136,3 +136,42 @@ dyn: update_pcent_threshold: 0.4 delete_pcent_threshold: 0.4 ```` + +## Provider specific record types + +### Creating and registering + +octoDNS has support for provider specific record types through a dynamic type registration system. This functionality is powered by `Route.register_type` and can be used as follows. + +```python +class _SpecificValue(object): + ... + +class SomeProviderSpecificRecord(ValuesMixin, Record): + _type = 'SomeProvider/SPECIFIC' + _value_type = _SpecificValue + +Record.register_type(SomeProviderSpecificRecord) +``` + +Have a look in [octodns.record](/octodns/record/__init__.py) for examples of how records are implemented. `NsRecord` and `_NsValue` are fairly simple examples to start with. You can also take a look at [`Route53Provider`'s `Route53Provider/ALIAS` type](https://github.com/octodns/octodns-route53/blob/main/octodns_route53/record.py). + +In general this support is intended for record types that only make sense for a single provider. If multiple providers have a similar record it may make sense to implement it in octoDNS core. + +### Naming + +By convention the record type should be prefix with the provider class, e.g. `Route53Provider` followed by a `/` and an all-caps record type name `ALIAS`, e.g. `Route53Provider/ALIAS`. + +### YamlProvider support + +Once the type is registered `YamlProvider` will automatically gain support for it and they can be included in your zone yaml files. + +```yaml +alias: + type: Route53Provider/ALIAS + values: + - name: www + type: A + - name: www + type: AAAA +``` diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 61dfb04..6fe3f90 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -64,7 +64,11 @@ class Delete(Change): return f'Delete {self.existing}' -class ValidationError(Exception): +class RecordException(Exception): + pass + + +class ValidationError(RecordException): @classmethod def build_message(cls, fqdn, reasons): @@ -80,6 +84,20 @@ class ValidationError(Exception): 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 new(cls, zone, name, data, source=None, lenient=False): name = str(name) @@ -89,24 +107,7 @@ class Record(EqualityTupleMixin): except KeyError: raise Exception(f'Invalid record {fqdn}, missing type') try: - _class = { - 'A': ARecord, - 'AAAA': AaaaRecord, - 'ALIAS': AliasRecord, - 'CAA': CaaRecord, - 'CNAME': CnameRecord, - 'DNAME': DnameRecord, - 'LOC': LocRecord, - 'MX': MxRecord, - 'NAPTR': NaptrRecord, - 'NS': NsRecord, - 'PTR': PtrRecord, - 'SPF': SpfRecord, - 'SRV': SrvRecord, - 'SSHFP': SshfpRecord, - 'TXT': TxtRecord, - 'URLFWD': UrlfwdRecord, - }[_type] + _class = cls._CLASSES[_type] except KeyError: raise Exception(f'Unknown record type: "{_type}"') reasons = _class.validate(name, fqdn, data) @@ -284,11 +285,11 @@ class GeoValue(EqualityTupleMixin): "{self.subdivision_code} {self.values}'" -class _ValuesMixin(object): +class ValuesMixin(object): @classmethod def validate(cls, name, fqdn, data): - reasons = super(_ValuesMixin, cls).validate(name, fqdn, data) + reasons = super(ValuesMixin, cls).validate(name, fqdn, data) values = data.get('values', data.get('value', [])) @@ -297,7 +298,7 @@ class _ValuesMixin(object): return reasons def __init__(self, zone, name, data, source=None): - super(_ValuesMixin, self).__init__(zone, name, data, source=source) + super(ValuesMixin, self).__init__(zone, name, data, source=source) try: values = data['values'] except KeyError: @@ -307,10 +308,10 @@ class _ValuesMixin(object): def changes(self, other, target): if self.values != other.values: return Update(self, other) - return super(_ValuesMixin, self).changes(other, target) + return super(ValuesMixin, self).changes(other, target) def _data(self): - ret = super(_ValuesMixin, self)._data() + ret = super(ValuesMixin, self)._data() if len(self.values) > 1: values = [getattr(v, 'data', v) for v in self.values if v] if len(values) > 1: @@ -330,7 +331,7 @@ class _ValuesMixin(object): return f"<{klass} {self._type} {self.ttl}, {self.fqdn}, ['{values}']>" -class _GeoMixin(_ValuesMixin): +class _GeoMixin(ValuesMixin): ''' Adds GeoDNS support to a record. @@ -381,26 +382,26 @@ class _GeoMixin(_ValuesMixin): return super(_GeoMixin, self).__repr__() -class _ValueMixin(object): +class ValueMixin(object): @classmethod def validate(cls, name, fqdn, data): - reasons = super(_ValueMixin, cls).validate(name, fqdn, data) + reasons = super(ValueMixin, cls).validate(name, fqdn, data) reasons.extend(cls._value_type.validate(data.get('value', None), cls._type)) return reasons def __init__(self, zone, name, data, source=None): - super(_ValueMixin, self).__init__(zone, name, data, source=source) + super(ValueMixin, self).__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(_ValueMixin, self).changes(other, target) + return super(ValueMixin, self).changes(other, target) def _data(self): - ret = super(_ValueMixin, self)._data() + ret = super(ValueMixin, self)._data() if self.value: ret['value'] = getattr(self.value, 'data', self.value) return ret @@ -804,16 +805,22 @@ class ARecord(_DynamicMixin, _GeoMixin, Record): _value_type = Ipv4List +Record.register_type(ARecord) + + class AaaaRecord(_DynamicMixin, _GeoMixin, Record): _type = 'AAAA' _value_type = Ipv6List +Record.register_type(AaaaRecord) + + class AliasValue(_TargetValue): pass -class AliasRecord(_ValueMixin, Record): +class AliasRecord(ValueMixin, Record): _type = 'ALIAS' _value_type = AliasValue @@ -826,6 +833,9 @@ class AliasRecord(_ValueMixin, Record): return reasons +Record.register_type(AliasRecord) + + class CaaValue(EqualityTupleMixin): # https://tools.ietf.org/html/rfc6844#page-5 @@ -872,12 +882,15 @@ class CaaValue(EqualityTupleMixin): return f'{self.flags} {self.tag} "{self.value}"' -class CaaRecord(_ValuesMixin, Record): +class CaaRecord(ValuesMixin, Record): _type = 'CAA' _value_type = CaaValue -class CnameRecord(_DynamicMixin, _ValueMixin, Record): +Record.register_type(CaaRecord) + + +class CnameRecord(_DynamicMixin, ValueMixin, Record): _type = 'CNAME' _value_type = CnameValue @@ -890,11 +903,17 @@ class CnameRecord(_DynamicMixin, _ValueMixin, Record): return reasons -class DnameRecord(_DynamicMixin, _ValueMixin, Record): +Record.register_type(CnameRecord) + + +class DnameRecord(_DynamicMixin, ValueMixin, Record): _type = 'DNAME' _value_type = DnameValue +Record.register_type(DnameRecord) + + class LocValue(EqualityTupleMixin): # TODO: work out how to do defaults per RFC @@ -1066,11 +1085,14 @@ class LocValue(EqualityTupleMixin): f"{self.precision_horz:.2f}m {self.precision_vert:.2f}m'" -class LocRecord(_ValuesMixin, Record): +class LocRecord(ValuesMixin, Record): _type = 'LOC' _value_type = LocValue +Record.register_type(LocRecord) + + class MxValue(EqualityTupleMixin): @classmethod @@ -1136,11 +1158,14 @@ class MxValue(EqualityTupleMixin): return f"'{self.preference} {self.exchange}'" -class MxRecord(_ValuesMixin, Record): +class MxRecord(ValuesMixin, Record): _type = 'MX' _value_type = MxValue +Record.register_type(MxRecord) + + class NaptrValue(EqualityTupleMixin): VALID_FLAGS = ('S', 'A', 'U', 'P') @@ -1214,11 +1239,14 @@ class NaptrValue(EqualityTupleMixin): f"\"{regexp}\" {self.replacement}'" -class NaptrRecord(_ValuesMixin, Record): +class NaptrRecord(ValuesMixin, Record): _type = 'NAPTR' _value_type = NaptrValue +Record.register_type(NaptrRecord) + + class _NsValue(object): @classmethod @@ -1241,11 +1269,14 @@ class _NsValue(object): return values -class NsRecord(_ValuesMixin, Record): +class NsRecord(ValuesMixin, Record): _type = 'NS' _value_type = _NsValue +Record.register_type(NsRecord) + + class PtrValue(_TargetValue): @classmethod @@ -1268,7 +1299,7 @@ class PtrValue(_TargetValue): return [super(PtrValue, cls).process(v) for v in values] -class PtrRecord(_ValuesMixin, Record): +class PtrRecord(ValuesMixin, Record): _type = 'PTR' _value_type = PtrValue @@ -1279,6 +1310,9 @@ class PtrRecord(_ValuesMixin, Record): return self.values[0] +Record.register_type(PtrRecord) + + class SshfpValue(EqualityTupleMixin): VALID_ALGORITHMS = (1, 2, 3, 4) VALID_FINGERPRINT_TYPES = (1, 2) @@ -1338,12 +1372,15 @@ class SshfpValue(EqualityTupleMixin): return f"'{self.algorithm} {self.fingerprint_type} {self.fingerprint}'" -class SshfpRecord(_ValuesMixin, Record): +class SshfpRecord(ValuesMixin, Record): _type = 'SSHFP' _value_type = SshfpValue -class _ChunkedValuesMixin(_ValuesMixin): +Record.register_type(SshfpRecord) + + +class _ChunkedValuesMixin(ValuesMixin): CHUNK_SIZE = 255 _unescaped_semicolon_re = re.compile(r'\w;') @@ -1392,6 +1429,9 @@ class SpfRecord(_ChunkedValuesMixin, Record): _value_type = _ChunkedValue +Record.register_type(SpfRecord) + + class SrvValue(EqualityTupleMixin): @classmethod @@ -1460,7 +1500,7 @@ class SrvValue(EqualityTupleMixin): return f"'{self.priority} {self.weight} {self.port} {self.target}'" -class SrvRecord(_ValuesMixin, Record): +class SrvRecord(ValuesMixin, Record): _type = 'SRV' _value_type = SrvValue _name_re = re.compile(r'^(\*|_[^\.]+)\.[^\.]+') @@ -1474,6 +1514,9 @@ class SrvRecord(_ValuesMixin, Record): return reasons +Record.register_type(SrvRecord) + + class _TxtValue(_ChunkedValue): pass @@ -1483,6 +1526,9 @@ class TxtRecord(_ChunkedValuesMixin, Record): _value_type = _TxtValue +Record.register_type(TxtRecord) + + class UrlfwdValue(EqualityTupleMixin): VALID_CODES = (301, 302) VALID_MASKS = (0, 1, 2) @@ -1555,6 +1601,9 @@ class UrlfwdValue(EqualityTupleMixin): f'{self.masking} {self.query}' -class UrlfwdRecord(_ValuesMixin, Record): +class UrlfwdRecord(ValuesMixin, Record): _type = 'URLFWD' _value_type = UrlfwdValue + + +Record.register_type(UrlfwdRecord) diff --git a/script/cibuild b/script/cibuild index 2826596..77d7a2c 100755 --- a/script/cibuild +++ b/script/cibuild @@ -26,16 +26,4 @@ echo "## lint ################################################################## script/lint echo "## tests/coverage ##############################################################" script/coverage -echo "## validate setup.py build #####################################################" -python setup.py build -echo "## validate setup.py install ###################################################" -deactivate -TMP_DIR=$(mktemp -d -t ci-XXXXXXXXXX) -python3 -m venv $TMP_DIR -. "$TMP_DIR/bin/activate" -python setup.py install -octodns-sync --help -echo "## validate tests can run against installed code ###############################" -pip install pytest pytest-network -pytest --disable-network echo "## complete ####################################################################" diff --git a/script/cibuild-setup-py b/script/cibuild-setup-py new file mode 100755 index 0000000..49f8409 --- /dev/null +++ b/script/cibuild-setup-py @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +cd "$(dirname "$0")/.." + +echo "## create test venv ############################################################" +TMP_DIR=$(mktemp -d -t ci-XXXXXXXXXX) +python3 -m venv $TMP_DIR +. "$TMP_DIR/bin/activate" +echo "## environment & versions ######################################################" +python --version +pip --version +echo "## validate setup.py build #####################################################" +python setup.py build +echo "## validate setup.py install ###################################################" +python setup.py install +echo "## validate tests can run against installed code ###############################" +pip install pytest pytest-network +pytest --disable-network +echo "## complete ####################################################################" diff --git a/script/release b/script/release index 975aac2..ea2543b 100755 --- a/script/release +++ b/script/release @@ -16,6 +16,9 @@ if [ ! -f "$ACTIVATE" ]; then fi . "$ACTIVATE" +# Set so that setup.py will create a public release style version number +export OCTODNS_RELEASE=1 + VERSION="$(grep __VERSION__ "$ROOT/octodns/__init__.py" | sed -e "s/.* = '//" -e "s/'$//")" git tag -s "v$VERSION" -m "Release $VERSION" diff --git a/setup.py b/setup.py index 4e5d631..2aa6407 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,9 @@ #!/usr/bin/env python -try: - from StringIO import StringIO -except ImportError: - from io import StringIO +from io import StringIO +from os import environ from os.path import dirname, join +from subprocess import CalledProcessError, check_output import octodns try: @@ -55,6 +54,19 @@ def long_description(): return buf.getvalue() +def version(): + # pep440 style public & local version numbers + if environ.get('OCTODNS_RELEASE', False): + # public + return octodns.__VERSION__ + try: + sha = check_output(['git', 'rev-parse', 'HEAD']).decode('utf-8')[:8] + except (CalledProcessError, FileNotFoundError): + sha = 'unknown' + # local + return f'{octodns.__VERSION__}+{sha}' + + tests_require = ( 'pytest>=6.2.5', 'pytest-cov>=3.0.0', @@ -94,5 +106,5 @@ setup( python_requires='>=3.6', tests_require=tests_require, url='https://github.com/octodns/octodns', - version=octodns.__VERSION__, + version=version(), ) diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index f8819a6..fd3f70f 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -10,9 +10,10 @@ from unittest import TestCase 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, UrlfwdRecord, UrlfwdValue, ValidationError, \ - _Dynamic, _DynamicPool, _DynamicRule + PtrRecord, Record, RecordException, SshfpRecord, SshfpValue, SpfRecord, \ + SrvRecord, SrvValue, TxtRecord, Update, UrlfwdRecord, UrlfwdValue, \ + ValidationError, _Dynamic, _DynamicPool, _DynamicRule, _NsValue, \ + ValuesMixin from octodns.zone import Zone from helpers import DynamicProvider, GeoProvider, SimpleProvider @@ -21,6 +22,24 @@ from helpers import DynamicProvider, GeoProvider, SimpleProvider class TestRecord(TestCase): zone = Zone('unit.tests.', []) + def test_registration(self): + with self.assertRaises(RecordException) as ctx: + Record.register_type(None, 'A') + self.assertEqual('Type "A" already registered by ' + 'octodns.record.ARecord', str(ctx.exception)) + + class AaRecord(ValuesMixin, Record): + _type = 'AA' + _value_type = _NsValue + + Record.register_type(AaRecord) + aa = Record.new(self.zone, 'registered', { + 'ttl': 360, + 'type': 'AA', + 'value': 'does.not.matter.', + }) + self.assertEqual(AaRecord, aa.__class__) + def test_lowering(self): record = ARecord(self.zone, 'MiXeDcAsE', { 'ttl': 30,