diff --git a/.changelog/3e57e696039c4f37a3062043be81199c.md b/.changelog/3e57e696039c4f37a3062043be81199c.md new file mode 100644 index 0000000..56c3112 --- /dev/null +++ b/.changelog/3e57e696039c4f37a3062043be81199c.md @@ -0,0 +1,4 @@ +--- +type: minor +--- +Add trailing_dots parameter to templating processor diff --git a/.changelog/af8522cac7e54d22a615eab351d445b3.md b/.changelog/af8522cac7e54d22a615eab351d445b3.md new file mode 100644 index 0000000..72130ef --- /dev/null +++ b/.changelog/af8522cac7e54d22a615eab351d445b3.md @@ -0,0 +1,4 @@ +--- +type: patch +--- +Improve error messaging for unknown templating parameters \ No newline at end of file diff --git a/octodns/processor/templating.py b/octodns/processor/templating.py index 2551e32..88d902d 100644 --- a/octodns/processor/templating.py +++ b/octodns/processor/templating.py @@ -5,6 +5,14 @@ from octodns.processor.base import BaseProcessor +class TemplatingError(Exception): + + def __init__(self, record, msg): + self.record = record + msg = f'Invalid record "{record.fqdn}", {msg}' + super().__init__(msg) + + class Templating(BaseProcessor): ''' Record templating using python format. For simple records like TXT and CAA @@ -15,6 +23,10 @@ class Templating(BaseProcessor): templating: class: octodns.processor.templating.Templating + # When `trailing_dots` is disabled, trailing dots are removed from all + # built-in variables values who represent a FQDN, like `{zone_name}` + # or `{record_fqdn}`. Optional. Default to `True`. + trailing_dots: False # Any k/v present in context will be passed into the .format method and # thus be available as additional variables in the template. This is all # optional. @@ -55,15 +67,23 @@ class Templating(BaseProcessor): ''' - def __init__(self, id, *args, context={}, **kwargs): + def __init__(self, id, *args, trailing_dots=True, context={}, **kwargs): super().__init__(id, *args, **kwargs) + self.trailing_dots = trailing_dots self.context = context def process_source_and_target_zones(self, desired, existing, provider): + zone_name = desired.decoded_name + zone_decoded_name = desired.decoded_name + zone_encoded_name = desired.name + if not self.trailing_dots: + zone_name = zone_name[:-1] + zone_decoded_name = zone_decoded_name[:-1] + zone_encoded_name = zone_encoded_name[:-1] zone_params = { - 'zone_name': desired.decoded_name, - 'zone_decoded_name': desired.decoded_name, - 'zone_encoded_name': desired.name, + 'zone_name': zone_name, + 'zone_decoded_name': zone_decoded_name, + 'zone_encoded_name': zone_encoded_name, 'zone_num_records': len(desired.records), # add any extra context provided to us, if the value is a callable # object call it passing our params so that arbitrary dynamic @@ -74,26 +94,45 @@ class Templating(BaseProcessor): }, } - def params(record): + def build_params(record): + record_fqdn = record.decoded_fqdn + record_decoded_fqdn = record.decoded_fqdn + record_encoded_fqdn = record.fqdn + if not self.trailing_dots: + record_fqdn = record_fqdn[:-1] + record_decoded_fqdn = record_decoded_fqdn[:-1] + record_encoded_fqdn = record_encoded_fqdn[:-1] return { 'record_name': record.decoded_name, 'record_decoded_name': record.decoded_name, 'record_encoded_name': record.name, - 'record_fqdn': record.decoded_fqdn, - 'record_decoded_fqdn': record.decoded_fqdn, - 'record_encoded_fqdn': record.fqdn, + 'record_fqdn': record_fqdn, + 'record_decoded_fqdn': record_decoded_fqdn, + 'record_encoded_fqdn': record_encoded_fqdn, 'record_type': record._type, 'record_ttl': record.ttl, 'record_source_id': record.source.id if record.source else None, **zone_params, } + def template(value, params, record): + try: + return value.template(params) + except KeyError as e: + raise TemplatingError( + record, + f'undefined template parameter "{e.args[0]}" in value', + ) from e + for record in desired.records: + params = build_params(record) if hasattr(record, 'values'): if record.values and not hasattr(record.values[0], 'template'): # the (custom) value type does not support templating continue - new_values = [v.template(params(record)) for v in record.values] + new_values = [ + template(v, params, record) for v in record.values + ] if record.values != new_values: new = record.copy() new.values = new_values @@ -102,7 +141,7 @@ class Templating(BaseProcessor): if not hasattr(record.value, 'template'): # the (custom) value type does not support templating continue - new_value = record.value.template(params(record)) + new_value = template(record.value, params, record) if record.value != new_value: new = record.copy() new.value = new_value diff --git a/tests/test_octodns_processor_templating.py b/tests/test_octodns_processor_templating.py index 20cc1b4..a9dcaed 100644 --- a/tests/test_octodns_processor_templating.py +++ b/tests/test_octodns_processor_templating.py @@ -3,8 +3,9 @@ # from unittest import TestCase +from unittest.mock import call, patch -from octodns.processor.templating import Templating +from octodns.processor.templating import Templating, TemplatingError from octodns.record import Record, ValueMixin, ValuesMixin from octodns.zone import Zone @@ -143,6 +144,47 @@ class TemplatingTest(TestCase): self.assertEqual({'template'}, s.value._asked_for) self.assertEqual({'template'}, m.values[0]._asked_for) + @patch('octodns.record.TxtValue.template') + def test_trailing_dots(self, mock_template): + templ = Templating('test', trailing_dots=False) + + zone = Zone('unit.tests.', []) + record_source = DummySource('record') + txt = Record.new( + zone, + 'txt', + { + 'type': 'TXT', + 'ttl': 42, + 'value': 'There are {zone_num_records} record(s) in {zone_name}.', + }, + source=record_source, + ) + zone.add_record(txt) + + templ.process_source_and_target_zones(zone, None, None) + mock_template.assert_called_once() + self.assertEqual( + call( + { + 'record_name': 'txt', + 'record_decoded_name': 'txt', + 'record_encoded_name': 'txt', + 'record_fqdn': 'txt.unit.tests', + 'record_decoded_fqdn': 'txt.unit.tests', + 'record_encoded_fqdn': 'txt.unit.tests', + 'record_source_id': 'record', + 'record_type': 'TXT', + 'record_ttl': 42, + 'zone_name': 'unit.tests', + 'zone_decoded_name': 'unit.tests', + 'zone_encoded_name': 'unit.tests', + 'zone_num_records': 1, + } + ), + mock_template.call_args, + ) + def test_context(self): templ = Templating( 'test', @@ -178,3 +220,41 @@ class TemplatingTest(TestCase): self.assertEqual('provider: da-pro', txt.values[0]) self.assertEqual('the_answer: 42', txt.values[1]) self.assertEqual('the_date: today', txt.values[2]) + + def test_bad_key(self): + templ = Templating('test') + + zone = Zone('unit.tests.', []) + txt = Record.new( + zone, + 'txt', + {'type': 'TXT', 'ttl': 42, 'value': 'this {bad} does not exist'}, + ) + zone.add_record(txt) + + with self.assertRaises(TemplatingError) as ctx: + templ.process_source_and_target_zones(zone, None, None) + self.assertEqual( + 'Invalid record "txt.unit.tests.", undefined template parameter "bad" in value', + str(ctx.exception), + ) + + zone = Zone('unit.tests.', []) + cname = Record.new( + zone, + 'cname', + { + 'type': 'CNAME', + 'ttl': 42, + 'value': '_cname.{bad}something.else.', + }, + lenient=True, + ) + zone.add_record(cname) + + with self.assertRaises(TemplatingError) as ctx: + templ.process_source_and_target_zones(zone, None, None) + self.assertEqual( + 'Invalid record "cname.unit.tests.", undefined template parameter "bad" in value', + str(ctx.exception), + )