Просмотр исходного кода

#11559: Add device config API endpoint & cleanup

jeremystretch 2 лет назад
Родитель
Сommit
00088cba6d

+ 42 - 1
docs/features/configuration-rendering.md

@@ -12,7 +12,7 @@ click ConfigTemplate "../../models/extras/configtemplate/"
 
 ## Configuration Templates
 
-Configuration templates are written in the [Jinja2 templating language](https://jinja.palletsprojects.com/), and may be automatically populated from remote data sources. Context data is applied to a template during rendering to output a complete configuration file. Below is an example template.
+Configuration templates are written in the [Jinja2 templating language](https://jinja.palletsprojects.com/), and may be automatically populated from remote data sources. Context data is applied to a template during rendering to output a complete configuration file. Below is an example Jinja2 template which renders a simple network switch configuration file.
 
 ```jinja2
 {% extends 'base.j2' %}
@@ -36,3 +36,44 @@ Configuration templates are written in the [Jinja2 templating language](https://
 ```
 
 When rendered for a specific NetBox device, the template's `device` variable will be populated with the device instance, and `ntp_servers` will be pulled from the device's available context data. The resulting output will be a valid configuration segment that can be applied directly to a compatible network device.
+
+## Rendering Templates
+
+### Device Configurations
+
+NetBox provides a REST API endpoint specifically for rendering the default configuration template for a specific device. This is accomplished by sending a POST request to the device's unique URL, optionally including additional context data.
+
+```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 '{
+  "extra_data": "abc123"
+}'
+```
+
+This request will trigger resolution of the device's preferred config template in the following order:
+
+* The config template assigned to the individual device
+* The config template assigned to the device's role
+* The config template assigned to the device's platform
+
+If no config template has been assigned to any of these three objects, the request will fail.
+
+### 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.
+
+```no-highlight
+curl -X POST \
+-H "Authorization: Token $TOKEN" \
+-H "Content-Type: application/json" \
+-H "Accept: application/json; indent=4" \
+http://netbox:8000/api/extras/config-templates/123/render/ \
+--data '{
+  "foo": "abc",
+  "bar": 123
+}'
+```

+ 1 - 1
docs/models/extras/configtemplate.md

@@ -1,6 +1,6 @@
 # Configuration Templates
 
-Configuration templates can be used to render [devices](../dcim/device.md) configurations from [context data](../../features/context-data.md). Templates are written in the [Jinja2 language](https://jinja.palletsprojects.com/) and can be associated with devices roles, platforms, and/or individual devices.
+Configuration templates can be used to render [device](../dcim/device.md) configurations from [context data](../../features/context-data.md). Templates are written in the [Jinja2 language](https://jinja.palletsprojects.com/) and can be associated with devices roles, platforms, and/or individual devices.
 
 Context data is made available to [devices](../dcim/device.md) and/or [virtual machines](../virtualization/virtualmachine.md) based on their relationships to other objects in NetBox. For example, context data can be associated only with devices assigned to a particular site, or only to virtual machines in a certain cluster.
 

+ 2 - 0
netbox/dcim/api/serializers.py

@@ -604,6 +604,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
 
 class DeviceRoleSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
+    config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
     device_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
 
@@ -618,6 +619,7 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
 class PlatformSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
+    config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
     device_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
 

+ 21 - 8
netbox/dcim/api/views.py

@@ -1,12 +1,12 @@
-import socket
-
-from django.http import Http404, HttpResponse, HttpResponseForbidden
+from django.http import Http404, HttpResponse
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
 from drf_yasg.openapi import Parameter
 from drf_yasg.utils import swagger_auto_schema
 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.routers import APIRootView
 from rest_framework.viewsets import ViewSet
 
@@ -15,14 +15,14 @@ from dcim import filtersets
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
 from dcim.svg import CableTraceSVG
-from extras.api.views import ConfigContextQuerySetMixin
+from extras.api.nested_serializers import NestedConfigTemplateSerializer
+from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
 from ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
-from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
+from netbox.api.renderers import TextRenderer
 from netbox.api.viewsets import NetBoxModelViewSet
-from netbox.config import get_config
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from utilities.api import get_serializer_for_model
 from utilities.utils import count_related
@@ -391,10 +391,10 @@ class PlatformViewSet(NetBoxModelViewSet):
 # Devices/modules
 #
 
-class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
+class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
     queryset = Device.objects.prefetch_related(
         'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
-        'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
+        'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
     )
     filterset_class = filtersets.DeviceFilterSet
     pagination_class = StripCountAnnotationsPaginator
@@ -419,6 +419,19 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
 
         return serializers.DeviceWithConfigContextSerializer
 
+    @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.
+        """
+        device = self.get_object()
+        configtemplate = device.get_config_template()
+        if not configtemplate:
+            return Response({'error': 'No config template found for this device.'}, status=HTTP_400_BAD_REQUEST)
+        context = {**request.data, 'device': device}
+
+        return self.render_configtemplate(request, configtemplate, context)
+
 
 class VirtualDeviceContextViewSet(NetBoxModelViewSet):
     queryset = VirtualDeviceContext.objects.prefetch_related(

+ 12 - 12
netbox/dcim/views.py

@@ -1987,6 +1987,17 @@ class DeviceInventoryView(DeviceComponentsView):
     )
 
 
+@register_model_view(Device, 'configcontext', path='config-context')
+class DeviceConfigContextView(ObjectConfigContextView):
+    queryset = Device.objects.annotate_config_context_data()
+    base_template = 'dcim/device/base.html'
+    tab = ViewTab(
+        label=_('Config Context'),
+        permission='extras.view_configcontext',
+        weight=2000
+    )
+
+
 @register_model_view(Device, 'render-config')
 class DeviceRenderConfigView(generic.ObjectView):
     queryset = Device.objects.all()
@@ -1994,7 +2005,7 @@ class DeviceRenderConfigView(generic.ObjectView):
     tab = ViewTab(
         label=_('Render Config'),
         permission='extras.view_configtemplate',
-        weight=2000
+        weight=2100
     )
 
     def get_extra_context(self, request, instance):
@@ -2020,17 +2031,6 @@ class DeviceRenderConfigView(generic.ObjectView):
         }
 
 
-@register_model_view(Device, 'configcontext', path='config-context')
-class DeviceConfigContextView(ObjectConfigContextView):
-    queryset = Device.objects.annotate_config_context_data()
-    base_template = 'dcim/device/base.html'
-    tab = ViewTab(
-        label=_('Config Context'),
-        permission='extras.view_configcontext',
-        weight=2100
-    )
-
-
 class DeviceBulkImportView(generic.BulkImportView):
     queryset = Device.objects.all()
     model_form = forms.DeviceImportForm

+ 46 - 0
netbox/extras/api/mixins.py

@@ -0,0 +1,46 @@
+from rest_framework.response import Response
+
+from .nested_serializers import NestedConfigTemplateSerializer
+
+__all__ = (
+    'ConfigContextQuerySetMixin',
+)
+
+
+class ConfigContextQuerySetMixin:
+    """
+    Used by views that work with config context models (device and virtual machine).
+    Provides a get_queryset() method which deals with adding the config context
+    data annotation or not.
+    """
+    def get_queryset(self):
+        """
+        Build the proper queryset based on the request context
+
+        If the `brief` query param equates to True or the `exclude` query param
+        includes `config_context` as a value, return the base queryset.
+
+        Else, return the queryset annotated with config context data
+        """
+        queryset = super().get_queryset()
+        request = self.get_serializer_context()['request']
+        if self.brief or 'config_context' in request.query_params.get('exclude', []):
+            return queryset
+        return queryset.annotate_config_context_data()
+
+
+class ConfigTemplateRenderMixin:
+
+    def render_configtemplate(self, request, configtemplate, context):
+        output = configtemplate.render(context=context)
+
+        # If the client has requested "text/plain", return the raw content.
+        if request.accepted_renderer.format == 'txt':
+            return Response(output)
+
+        template_serializer = NestedConfigTemplateSerializer(configtemplate, context={'request': request})
+
+        return Response({
+            'configtemplate': template_serializer.data,
+            'content': output
+        })

+ 4 - 33
netbox/extras/api/views.py

@@ -26,6 +26,7 @@ from netbox.api.viewsets import NetBoxModelViewSet
 from utilities.exceptions import RQWorkerNotRunningException
 from utilities.utils import copy_safe_request, count_related
 from . import serializers
+from .mixins import ConfigTemplateRenderMixin
 from .nested_serializers import NestedConfigTemplateSerializer
 
 
@@ -37,28 +38,6 @@ class ExtrasRootView(APIRootView):
         return 'Extras'
 
 
-class ConfigContextQuerySetMixin:
-    """
-    Used by views that work with config context models (device and virtual machine).
-    Provides a get_queryset() method which deals with adding the config context
-    data annotation or not.
-    """
-    def get_queryset(self):
-        """
-        Build the proper queryset based on the request context
-
-        If the `brief` query param equates to True or the `exclude` query param
-        includes `config_context` as a value, return the base queryset.
-
-        Else, return the queryset annotated with config context data
-        """
-        queryset = super().get_queryset()
-        request = self.get_serializer_context()['request']
-        if self.brief or 'config_context' in request.query_params.get('exclude', []):
-            return queryset
-        return queryset.annotate_config_context_data()
-
-
 #
 # Webhooks
 #
@@ -165,7 +144,7 @@ class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
 # Config templates
 #
 
-class ConfigTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
+class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
     queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file')
     serializer_class = serializers.ConfigTemplateSerializer
     filterset_class = filtersets.ConfigTemplateFilterSet
@@ -177,17 +156,9 @@ class ConfigTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
         return the raw rendered content, rather than serialized JSON.
         """
         configtemplate = self.get_object()
-        output = configtemplate.render(context=request.data)
+        context = request.data
 
-        # If the client has requested "text/plain", return the raw content.
-        if request.accepted_renderer.format == 'txt':
-            return Response(output)
-
-        template_serializer = NestedConfigTemplateSerializer(configtemplate, context={'request': request})
-        return Response({
-            'configtemplate': template_serializer.data,
-            'content': output
-        })
+        return self.render_configtemplate(request, configtemplate, context)
 
 
 #

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

@@ -306,12 +306,26 @@ class ConfigTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
 
     fieldsets = (
         ('Config Template', ('name', 'description', 'environment_params', 'tags')),
-        ('Content', ('data_source', 'data_file', 'template_code',)),
+        ('Content', ('template_code',)),
+        ('Data Source', ('data_source', 'data_file')),
     )
 
     class Meta:
         model = ConfigTemplate
         fields = '__all__'
+        widgets = {
+            'environment_params': forms.Textarea(attrs={'rows': 5})
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Disable content field when a DataFile has been set
+        if self.instance.data_file:
+            self.fields['template_code'].widget.attrs['readonly'] = True
+            self.fields['template_code'].help_text = _(
+                'Template content is populated from the remote source selected below.'
+            )
 
     def clean(self):
         super().clean()

+ 1 - 1
netbox/extras/migrations/0086_configtemplate.py

@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
                 ('name', models.CharField(max_length=100)),
                 ('description', models.CharField(blank=True, max_length=200)),
                 ('template_code', models.TextField()),
-                ('environment_params', models.JSONField(blank=True, null=True)),
+                ('environment_params', models.JSONField(blank=True, default=dict, null=True)),
                 ('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')),
                 ('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')),
                 ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),

+ 9 - 7
netbox/extras/models/configs.py

@@ -209,7 +209,12 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
     )
     environment_params = models.JSONField(
         blank=True,
-        null=True
+        null=True,
+        default=dict,
+        help_text=_(
+            'Any <a href="https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment">additional parameters</a>'
+            ' to pass when constructing the Jinja2 environment.'
+        )
     )
 
     class Meta:
@@ -235,11 +240,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
 
         # Initialize the Jinja2 environment and instantiate the Template
         environment = self._get_environment()
-        if self.data_file:
-            template = environment.get_template(self.data_file.path)
-        else:
-            template = environment.from_string(self.template_code)
-
+        template = environment.from_string(self.template_code)
         output = template.render(**context)
 
         # Replace CRLF-style line terminators
@@ -259,7 +260,8 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
             loader = BaseLoader()
 
         # Initialize the environment
-        environment = SandboxedEnvironment(loader=loader)
+        env_params = self.environment_params or {}
+        environment = SandboxedEnvironment(loader=loader, **env_params)
         environment.filters.update(get_config().JINJA2_FILTERS)
 
         return environment

+ 1 - 1
netbox/virtualization/api/views.py

@@ -1,7 +1,7 @@
 from rest_framework.routers import APIRootView
 
 from dcim.models import Device
-from extras.api.views import ConfigContextQuerySetMixin
+from extras.api.mixins import ConfigContextQuerySetMixin
 from netbox.api.viewsets import NetBoxModelViewSet
 from utilities.utils import count_related
 from virtualization import filtersets