mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
#11559: Add device config API endpoint & cleanup
This commit is contained in:
@ -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,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.
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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
netbox/extras/api/mixins.py
Normal file
46
netbox/extras/api/mixins.py
Normal file
@ -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
|
||||
})
|
@ -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)
|
||||
|
||||
|
||||
#
|
||||
|
@ -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()
|
||||
|
@ -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')),
|
||||
|
@ -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,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
|
||||
|
Reference in New Issue
Block a user