Răsfoiți Sursa

Fixes: #19953 - ConfigTemplate debug rendering mode (#21652)

Add debug field to ConfigTemplate and (if True) render template errors
with a full traceback.
bctiemann 23 ore în urmă
părinte
comite
b01d92c98b

+ 6 - 6
netbox/extras/api/mixins.py

@@ -1,8 +1,7 @@
-from jinja2.exceptions import TemplateError
 from rest_framework.decorators import action
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
-from rest_framework.status import HTTP_400_BAD_REQUEST
+from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR
 
 from netbox.api.authentication import TokenWritePermission
 from netbox.api.renderers import TextRenderer
@@ -45,10 +44,11 @@ class ConfigTemplateRenderMixin:
     def render_configtemplate(self, request, configtemplate, context):
         try:
             output = configtemplate.render(context=context)
-        except TemplateError as e:
-            return Response({
-                'detail': f"An error occurred while rendering the template (line {e.lineno}): {e}"
-            }, status=500)
+        except Exception as e:
+            detail = configtemplate.format_render_error(e)
+            if request.accepted_renderer.format == 'txt':
+                return Response(detail, status=HTTP_500_INTERNAL_SERVER_ERROR)
+            return Response({'detail': detail}, status=HTTP_500_INTERNAL_SERVER_ERROR)
 
         # If the client has requested "text/plain", return the raw content.
         if request.accepted_renderer.format == 'txt':

+ 2 - 2
netbox/extras/api/serializers_/configtemplates.py

@@ -28,7 +28,7 @@ class ConfigTemplateSerializer(
         model = ConfigTemplate
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code',
-            'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file',
-            'auto_sync_enabled', 'data_synced', 'owner', 'tags', 'created', 'last_updated',
+            'mime_type', 'file_name', 'file_extension', 'as_attachment', 'debug', 'data_source', 'data_path',
+            'data_file', 'auto_sync_enabled', 'data_synced', 'owner', 'tags', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')

+ 1 - 1
netbox/extras/filtersets.py

@@ -857,7 +857,7 @@ class ConfigTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
     class Meta:
         model = ConfigTemplate
         fields = (
-            'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
+            'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'debug',
             'auto_sync_enabled', 'data_synced'
         )
 

+ 5 - 0
netbox/extras/forms/bulk_edit.py

@@ -392,6 +392,11 @@ class ConfigTemplateBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm
         required=False,
         widget=BulkEditNullBooleanSelect()
     )
+    debug = forms.NullBooleanField(
+        label=_('Debug'),
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
     auto_sync_enabled = forms.NullBooleanField(
         label=_('Auto sync enabled'),
         required=False,

+ 2 - 1
netbox/extras/forms/bulk_import.py

@@ -190,7 +190,8 @@ class ConfigTemplateImportForm(OwnerCSVMixin, CSVModelForm):
         model = ConfigTemplate
         fields = (
             'name', 'description', 'template_code', 'data_source', 'data_file', 'auto_sync_enabled',
-            'environment_params', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'owner', 'tags',
+            'environment_params', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'debug', 'owner',
+            'tags',
         )
 
     def clean(self):

+ 8 - 1
netbox/extras/forms/filtersets.py

@@ -497,7 +497,7 @@ class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')),
-        FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
+        FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', 'debug', name=_('Rendering')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     data_source_id = DynamicModelMultipleChoiceField(
@@ -540,6 +540,13 @@ class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
+    debug = forms.NullBooleanField(
+        label=_('Debug'),
+        required=False,
+        widget=forms.Select(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
 
 
 class LocalConfigContextFilterForm(forms.Form):

+ 2 - 1
netbox/extras/forms/model_forms.py

@@ -754,7 +754,8 @@ class ConfigTemplateForm(ChangelogMessageMixin, SyncedDataMixin, OwnerMixin, for
         FieldSet('name', 'description', 'tags', 'template_code', name=_('Config Template')),
         FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
         FieldSet(
-            'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
+            'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', 'debug',
+            name=_('Rendering')
         ),
     )
 

+ 22 - 0
netbox/extras/migrations/0135_configtemplate_debug.py

@@ -0,0 +1,22 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('extras', '0134_owner'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='configtemplate',
+            name='debug',
+            field=models.BooleanField(
+                default=False,
+                help_text=(
+                    'Enable verbose error output when rendering this template. '
+                    'Not recommended for production use.'
+                ),
+                verbose_name='debug',
+            ),
+        ),
+    ]

+ 27 - 0
netbox/extras/models/configs.py

@@ -1,9 +1,12 @@
+import traceback
+
 import jsonschema
 from django.conf import settings
 from django.core.validators import ValidationError
 from django.db import models
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
+from jinja2.exceptions import TemplateError
 from jsonschema.exceptions import ValidationError as JSONValidationError
 
 from extras.models.mixins import RenderTemplateMixin
@@ -281,6 +284,13 @@ class ConfigTemplate(
         max_length=200,
         blank=True
     )
+    debug = models.BooleanField(
+        verbose_name=_('debug'),
+        default=False,
+        help_text=_(
+            'Enable verbose error output when rendering this template. Not recommended for production use.'
+        )
+    )
 
     class Meta:
         ordering = ('name',)
@@ -299,3 +309,20 @@ class ConfigTemplate(
         """
         self.template_code = self.data_file.data_as_string
     sync_data.alters_data = True
+
+    def format_render_error(self, exc):
+        """
+        Return a formatted error string for a rendering exception. When debug is enabled, the full
+        traceback for the provided exception is returned. Otherwise, a concise, user-facing message
+        is returned.
+        """
+        if self.debug:
+            return ''.join(traceback.format_exception(exc))
+        if isinstance(exc, TemplateError):
+            parts = [f"{type(exc).__name__}: {exc}"]
+            if getattr(exc, 'name', None):
+                parts.append(_("Template: {name}").format(name=exc.name))
+            if getattr(exc, 'lineno', None):
+                parts.append(_("Line: {lineno}").format(lineno=exc.lineno))
+            return "\n".join(parts)
+        return f"{type(exc).__name__}: {exc}"

+ 2 - 1
netbox/extras/models/mixins.py

@@ -150,7 +150,8 @@ class RenderTemplateMixin(models.Model):
         """
         context = self.get_context(context=context, queryset=queryset)
         env_params = self.get_environment_params()
-        output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None))
+        debug = getattr(self, 'debug', False)
+        output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None), debug=debug)
 
         # Replace CRLF-style line terminators
         output = output.replace('\r\n', '\n')

+ 5 - 1
netbox/extras/tables/tables.py

@@ -697,6 +697,10 @@ class ConfigTemplateTable(NetBoxTable):
         verbose_name=_('As Attachment'),
         false_mark=None
     )
+    debug = columns.BooleanColumn(
+        verbose_name=_('Debug'),
+        false_mark=None
+    )
     owner = tables.Column(
         linkify=True,
         verbose_name=_('Owner')
@@ -728,7 +732,7 @@ class ConfigTemplateTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = ConfigTemplate
         fields = (
-            'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'as_attachment',
+            'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'as_attachment', 'debug',
             'mime_type', 'file_name', 'file_extension', 'role_count', 'platform_count', 'device_count',
             'vm_count', 'created', 'last_updated', 'tags',
         )

+ 48 - 0
netbox/extras/tests/test_models.py

@@ -818,6 +818,54 @@ class ConfigTemplateTest(TestCase):
             self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
 
 
+class ConfigTemplateDebugTest(TestCase):
+    """
+    Tests for the ConfigTemplate debug field and its effect on template rendering error output.
+    """
+
+    def _make_template(self, template_code, debug=False):
+        t = ConfigTemplate(
+            name=f"DebugTestTemplate-{debug}",
+            template_code=template_code,
+            debug=debug,
+        )
+        t.save()
+        return t
+
+    def test_debug_default_is_false(self):
+        t = ConfigTemplate(name="t", template_code="hello")
+        self.assertFalse(t.debug)
+
+    def test_template_error_non_debug_no_traceback(self):
+        """In non-debug mode, a TemplateError raises with no traceback exposure."""
+        from jinja2 import TemplateError
+        t = self._make_template("{{ unclosed", debug=False)
+        with self.assertRaises(TemplateError):
+            t.render({})
+
+    def test_template_error_debug_mode_raises(self):
+        """In debug mode, a TemplateError still raises (callers handle display)."""
+        from jinja2 import TemplateError
+        t = self._make_template("{{ unclosed", debug=True)
+        with self.assertRaises(TemplateError):
+            t.render({})
+
+    def test_render_jinja2_debug_extension_enabled(self):
+        """When debug=True, the Jinja2 debug extension is loaded in the environment."""
+        from utilities.jinja2 import render_jinja2
+        # The {% debug %} tag is only available when the debug extension is loaded.
+        output = render_jinja2("{% debug %}", {}, debug=True)
+        self.assertIsInstance(output, str)
+
+    def test_render_jinja2_debug_extension_not_loaded_by_default(self):
+        """When debug=False, the {% debug %} tag is not available."""
+        from jinja2 import TemplateSyntaxError
+
+        from utilities.jinja2 import render_jinja2
+        with self.assertRaises(TemplateSyntaxError):
+            render_jinja2("{% debug %}", {}, debug=False)
+
+
 class ExportTemplateContextTest(TestCase):
     """
     Tests for ExportTemplate.get_context() including public model population.

+ 4 - 4
netbox/extras/views.py

@@ -12,7 +12,6 @@ from django.utils import timezone
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext as _
 from django.views.generic import View
-from jinja2.exceptions import TemplateError
 
 from core.choices import ManagedFileRootPathChoices
 from core.models import Job
@@ -1120,11 +1119,12 @@ class ObjectRenderConfigView(generic.ObjectView):
         # Render the config template
         rendered_config = None
         error_message = ''
-        if config_template := instance.get_config_template():
+        config_template = instance.get_config_template()
+        if config_template:
             try:
                 rendered_config = config_template.render(context=context_data)
-            except TemplateError as e:
-                error_message = _("An error occurred while rendering the template: {error}").format(error=e)
+            except Exception as e:
+                error_message = config_template.format_render_error(e)
 
         return {
             'base_template': self.base_template,

+ 4 - 0
netbox/templates/extras/configtemplate.html

@@ -33,6 +33,10 @@
             <th scope="row">{% trans "Attachment" %}</th>
             <td>{% checkmark object.as_attachment %}</td>
           </tr>
+          <tr>
+            <th scope="row">{% trans "Debug" %}</th>
+            <td>{% checkmark object.debug %}</td>
+          </tr>
           <tr>
             <th scope="row">{% trans "Data Source" %}</th>
             <td>

+ 7 - 1
netbox/templates/extras/object_render_config.html

@@ -66,7 +66,13 @@
         {% elif error_message %}
           <div class="alert alert-warning">
             <h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
-            {% trans error_message %}
+            {% if config_template.debug %}
+              <div class="overflow-auto" style="max-height: 30rem;">
+                <pre class="mb-0">{{ error_message }}</pre>
+              </div>
+            {% else %}
+              <pre class="mb-0 text-warning-emphasis bg-transparent border-0 p-0">{{ error_message }}</pre>
+            {% endif %}
           </div>
         {% else %}
           <div class="alert alert-warning">

+ 10 - 2
netbox/utilities/jinja2.py

@@ -49,11 +49,19 @@ class DataFileLoader(BaseLoader):
 # Utility functions
 #
 
-def render_jinja2(template_code, context, environment_params=None, data_file=None):
+def render_jinja2(template_code, context, environment_params=None, data_file=None, debug=False):
     """
     Render a Jinja2 template with the provided context. Return the rendered content.
+
+    If debug is True, the Jinja2 debug extension is enabled to assist with template development.
     """
-    environment_params = environment_params or {}
+    environment_params = dict(environment_params or {})
+
+    if debug:
+        extensions = list(environment_params.get('extensions', []))
+        if 'jinja2.ext.debug' not in extensions:
+            extensions.append('jinja2.ext.debug')
+        environment_params['extensions'] = extensions
 
     if 'loader' not in environment_params:
         if data_file: