浏览代码

Merge pull request #21932 from netbox-community/21782-config

21782 Enable optional config template override in URL
bctiemann 1 月之前
父节点
当前提交
6a9c3dad17

+ 33 - 0
docs/features/configuration-rendering.md

@@ -75,6 +75,39 @@ The configuration can be rendered as JSON or as plaintext by setting the `Accept
 * `Accept: application/json`
 * `Accept: text/plain`
 
+### Overriding the Config Template
+
+To render a specific config template against a device's context data - rather than the template resolved via the fallback chain above — include `config_template_id` in the request body:
+
+```no-highlight
+curl -X POST \
+-H "Authorization: Token $TOKEN" \
+-H "Content-Type: application/json" \
+-H "Accept: application/json; indent=4" \
+http://netbox:8000/api/dcim/devices/123/render-config/ \
+--data '{
+  "config_template_id": 42
+}'
+```
+
+This is useful for rendering partial or alternative templates against a device's assembled context without changing any stored assignments. Any additional keys in the request body are passed into the template as context variables alongside the device's own config context data, as with standard rendering:
+
+```no-highlight
+--data '{
+  "config_template_id": 42,
+  "environment": "staging"
+}'
+```
+
+!!! note "Permissions"
+    Overriding the config template requires the requesting user to have `view` permission for the "Extras > Config Template" object type in addition to the `render_config` permission on the device.
+
+The same override is available in the UI by appending `config_template_id` as a query parameter to the device's render config URL:
+
+```no-highlight
+/dcim/devices/123/render-config/?config_template_id=42
+```
+
 ### General Purpose Use
 
 NetBox config templates can also be rendered without being tied to any specific device, using a separate general purpose REST API endpoint. Any data included with a POST request to this endpoint will be passed as context data for the template.

+ 35 - 0
netbox/dcim/tests/test_api.py

@@ -1634,6 +1634,41 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
         response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
+    def test_render_config_with_config_template_id(self):
+        default_template = ConfigTemplate.objects.create(
+            name='Default Template',
+            template_code='Default config for {{ device.name }}'
+        )
+        override_template = ConfigTemplate.objects.create(
+            name='Override Template',
+            template_code='Override config for {{ device.name }}'
+        )
+
+        device = Device.objects.first()
+        device.config_template = default_template
+        device.save()
+
+        self.add_permissions('dcim.render_config_device', 'dcim.view_device', 'extras.view_configtemplate')
+        url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
+
+        # Render with override template
+        response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['content'], f'Override config for {device.name}')
+
+        # Render with nonexistent config_template_id
+        response = self.client.post(url, {'config_template_id': 999999}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # Render with non-integer config_template_id
+        response = self.client.post(url, {'config_template_id': 'abc'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # Without view_configtemplate permission, override template should not be accessible
+        self.remove_permissions('extras.view_configtemplate')
+        response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
 
 class ModuleTest(APIViewTestCases.APIViewTestCase):
     model = Module

+ 37 - 0
netbox/dcim/tests/test_views.py

@@ -2384,6 +2384,43 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.remove_permissions('dcim.view_device')
         self.assertHttpStatus(self.client.get(url), 403)
 
+    def test_device_renderconfig_with_config_template_id(self):
+        default_template = ConfigTemplate.objects.create(
+            name='Default Template',
+            template_code='Default config for {{ device.name }}'
+        )
+        override_template = ConfigTemplate.objects.create(
+            name='Override Template',
+            template_code='Override config for {{ device.name }}'
+        )
+        device = Device.objects.first()
+        device.config_template = default_template
+        device.save()
+
+        self.add_permissions('dcim.view_device', 'dcim.render_config_device', 'extras.view_configtemplate')
+        url = reverse('dcim:device_render-config', kwargs={'pk': device.pk})
+
+        # Render with override config_template_id
+        response = self.client.get(url, {'config_template_id': override_template.pk})
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'Override config for', response.content)
+
+        # Render with nonexistent config_template_id still returns 200 with error message
+        response = self.client.get(url, {'config_template_id': 999999})
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'Error rendering template', response.content)
+
+        # Render with non-integer config_template_id still returns 200 with error message
+        response = self.client.get(url, {'config_template_id': 'abc'})
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'Error rendering template', response.content)
+
+        # Without view_configtemplate permission, override template should not be accessible
+        self.remove_permissions('extras.view_configtemplate')
+        response = self.client.get(url, {'config_template_id': override_template.pk})
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'Error rendering template', response.content)
+
     def test_device_role_display_colored(self):
         parent_role = DeviceRole.objects.create(name='Parent Role', slug='parent-role', color='111111')
         child_role = DeviceRole.objects.create(name='Child Role', slug='child-role', parent=parent_role, color='aa00bb')

+ 19 - 7
netbox/extras/api/mixins.py

@@ -1,9 +1,11 @@
+from django.utils.translation import gettext as _
 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 extras.models import ConfigTemplate
 from netbox.api.authentication import TokenWritePermission
 from netbox.api.renderers import TextRenderer
 
@@ -76,7 +78,7 @@ class RenderConfigMixin(ConfigTemplateRenderMixin):
     @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
     def render_config(self, request, pk):
         """
-        Resolve and render the preferred ConfigTemplate for this Device.
+        Resolve and render the preferred ConfigTemplate for this Device or Virtual Machine.
         """
         # Override restrict() on the default queryset to enforce the render_config & view actions
         self.queryset = self.queryset.model.objects.restrict(request.user, 'render_config').restrict(
@@ -85,15 +87,25 @@ class RenderConfigMixin(ConfigTemplateRenderMixin):
         instance = self.get_object()
 
         object_type = instance._meta.model_name
-        configtemplate = instance.get_config_template()
-        if not configtemplate:
-            return Response({
-                'error': f'No config template found for this {object_type}.'
-            }, status=HTTP_400_BAD_REQUEST)
+
+        # Check for an optional config_template_id override in the request data
+        if config_template_id := request.data.get('config_template_id'):
+            try:
+                configtemplate = ConfigTemplate.objects.restrict(request.user, 'view').get(pk=config_template_id)
+            except (ConfigTemplate.DoesNotExist, ValueError):
+                return Response({
+                    'error': _('Config template with ID {id} not found.').format(id=config_template_id)
+                }, status=HTTP_400_BAD_REQUEST)
+        else:
+            configtemplate = instance.get_config_template()
+            if not configtemplate:
+                return Response({
+                    'error': _('No config template found for this {object_type}.').format(object_type=object_type)
+                }, status=HTTP_400_BAD_REQUEST)
 
         # Compile context data
         context_data = instance.get_config_context()
-        context_data.update(request.data)
+        context_data.update({k: v for k, v in request.data.items() if k != 'config_template_id'})
         context_data.update({object_type: instance})
 
         return self.render_configtemplate(request, configtemplate, context_data)

+ 12 - 2
netbox/extras/views.py

@@ -1268,10 +1268,20 @@ class ObjectRenderConfigView(generic.ObjectView):
         context_data = instance.get_config_context()
         context_data.update(self.get_extra_context_data(request, instance))
 
+        # Check for an optional config_template_id override in the query params
+        config_template = None
+        error_message = ''
+        if config_template_id := request.GET.get('config_template_id'):
+            try:
+                config_template = ConfigTemplate.objects.restrict(request.user, 'view').get(pk=config_template_id)
+            except (ConfigTemplate.DoesNotExist, ValueError):
+                error_message = _("Config template with ID {id} not found.").format(id=config_template_id)
+        else:
+            config_template = instance.get_config_template()
+
         # Render the config template
         rendered_config = None
-        error_message = ''
-        if config_template := instance.get_config_template():
+        if config_template:
             try:
                 rendered_config = config_template.render(context=context_data)
             except TemplateError as e:

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

@@ -49,13 +49,18 @@
   </div>
   <div class="row">
     <div class="col">
-      {% if config_template %}
+      {% if error_message %}
+        <div class="alert alert-warning">
+          <h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
+          {{ error_message }}
+        </div>
+      {% elif config_template %}
         {% if rendered_config %}
           <div class="card">
             <h2 class="card-header d-flex justify-content-between">
               {% trans "Rendered Config" %}
               <div class="card-actions">
-                <a href="?export=True" class="btn btn-sm btn-ghost-primary" role="button">
+                <a href="?export=True{% if request.GET.config_template_id %}&config_template_id={{ request.GET.config_template_id }}{% endif %}" class="btn btn-sm btn-ghost-primary" role="button">
                   <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
                 </a>
                 {% copy_content "rendered_config" %}
@@ -63,11 +68,6 @@
             </h2>
             <pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>
           </div>
-        {% elif error_message %}
-          <div class="alert alert-warning">
-            <h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
-            {% trans error_message %}
-          </div>
         {% else %}
           <div class="alert alert-warning">
             <h4 class="alert-title mb-1">{% trans "Template output is empty" %}</h4>

+ 38 - 0
netbox/virtualization/tests/test_api.py

@@ -343,6 +343,44 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
         response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
+    def test_render_config_with_config_template_id(self):
+        default_template = ConfigTemplate.objects.create(
+            name='Default Template',
+            template_code='Default config for {{ virtualmachine.name }}'
+        )
+        override_template = ConfigTemplate.objects.create(
+            name='Override Template',
+            template_code='Override config for {{ virtualmachine.name }}'
+        )
+
+        vm = VirtualMachine.objects.first()
+        vm.config_template = default_template
+        vm.save()
+
+        self.add_permissions(
+            'virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine',
+            'extras.view_configtemplate'
+        )
+        url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
+
+        # Render with override template
+        response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['content'], f'Override config for {vm.name}')
+
+        # Render with nonexistent config_template_id
+        response = self.client.post(url, {'config_template_id': 999999}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # Render with non-integer config_template_id
+        response = self.client.post(url, {'config_template_id': 'abc'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
+        # Without view_configtemplate permission, override template should not be accessible
+        self.remove_permissions('extras.view_configtemplate')
+        response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
 
 class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
     model = VMInterface

+ 40 - 0
netbox/virtualization/tests/test_views.py

@@ -357,6 +357,46 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.remove_permissions('virtualization.view_virtualmachine')
         self.assertHttpStatus(self.client.get(url), 403)
 
+    def test_virtualmachine_renderconfig_with_config_template_id(self):
+        default_template = ConfigTemplate.objects.create(
+            name='Default Template',
+            template_code='Default config for {{ virtualmachine.name }}'
+        )
+        override_template = ConfigTemplate.objects.create(
+            name='Override Template',
+            template_code='Override config for {{ virtualmachine.name }}'
+        )
+        vm = VirtualMachine.objects.first()
+        vm.config_template = default_template
+        vm.save()
+
+        self.add_permissions(
+            'virtualization.view_virtualmachine', 'virtualization.render_config_virtualmachine',
+            'extras.view_configtemplate'
+        )
+        url = reverse('virtualization:virtualmachine_render-config', kwargs={'pk': vm.pk})
+
+        # Render with override config_template_id
+        response = self.client.get(url, {'config_template_id': override_template.pk})
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'Override config for', response.content)
+
+        # Render with nonexistent config_template_id still returns 200 with error message
+        response = self.client.get(url, {'config_template_id': 999999})
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'Error rendering template', response.content)
+
+        # Render with non-integer config_template_id still returns 200 with error message
+        response = self.client.get(url, {'config_template_id': 'abc'})
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'Error rendering template', response.content)
+
+        # Without view_configtemplate permission, override template should not be accessible
+        self.remove_permissions('extras.view_configtemplate')
+        response = self.client.get(url, {'config_template_id': override_template.pk})
+        self.assertHttpStatus(response, 200)
+        self.assertIn(b'Error rendering template', response.content)
+
 
 class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = VMInterface