From 73a7a2d27aff41e18224a91488745e95417ceb77 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Feb 2023 08:33:08 -0500 Subject: [PATCH] Closes #11559: Implement config template rendering (#11769) * WIP * Add config_template field to Device * Pre-fetch referenced templates * Correct up_to_date callable * Add config_template FK to Device * Update & merge migrations * Add config_template FK to Platform * Add tagging support for ConfigTemplate * Catch exceptions when rendering device templates in UI * Refactor ConfigTemplate.render() * Add support for returning plain text content * Add ConfigTemplate model documentation * Add feature documentation for config rendering --- docs/features/configuration-rendering.md | 38 +++++++++ docs/features/context-data.md | 2 + docs/models/dcim/device.md | 4 + docs/models/dcim/devicerole.md | 4 + docs/models/dcim/platform.md | 4 + docs/models/extras/configtemplate.md | 29 +++++++ mkdocs.yml | 2 + netbox/dcim/api/serializers.py | 12 +-- netbox/dcim/api/views.py | 4 +- netbox/dcim/filtersets.py | 13 +++ netbox/dcim/forms/bulk_edit.py | 23 ++++-- netbox/dcim/forms/bulk_import.py | 27 +++++- netbox/dcim/forms/filtersets.py | 18 +++- netbox/dcim/forms/model_forms.py | 23 ++++-- netbox/dcim/migrations/0170_configtemplate.py | 28 +++++++ netbox/dcim/models/devices.py | 32 ++++++++ netbox/dcim/tables/devices.py | 19 +++-- netbox/dcim/views.py | 40 ++++++++- netbox/extras/api/nested_serializers.py | 9 ++ netbox/extras/api/serializers.py | 23 ++++++ netbox/extras/api/urls.py | 1 + netbox/extras/api/views.py | 32 ++++++++ netbox/extras/filtersets.py | 33 +++++++- netbox/extras/forms/bulk_edit.py | 14 ++++ netbox/extras/forms/bulk_import.py | 10 +++ netbox/extras/forms/filtersets.py | 22 +++++ netbox/extras/forms/model_forms.py | 29 +++++++ netbox/extras/graphql/schema.py | 3 + netbox/extras/graphql/types.py | 9 ++ .../extras/migrations/0086_configtemplate.py | 34 ++++++++ netbox/extras/models/__init__.py | 3 +- .../models/{configcontexts.py => configs.py} | 82 ++++++++++++++++++- netbox/extras/tables/tables.py | 29 +++++++ netbox/extras/urls.py | 8 ++ netbox/extras/views.py | 52 ++++++++++++ netbox/netbox/api/renderers.py | 18 +++- netbox/netbox/navigation/menu.py | 1 + netbox/templates/dcim/device.html | 8 +- .../templates/dcim/device/render_config.html | 47 +++++++++++ netbox/templates/dcim/device_edit.html | 1 + netbox/templates/dcim/devicerole.html | 4 + netbox/templates/dcim/platform.html | 4 + netbox/templates/extras/configtemplate.html | 77 +++++++++++++++++ .../templates/extras/configtemplate_list.html | 10 +++ netbox/utilities/jinja2.py | 37 +++++++++ 45 files changed, 886 insertions(+), 36 deletions(-) create mode 100644 docs/features/configuration-rendering.md create mode 100644 docs/models/extras/configtemplate.md create mode 100644 netbox/dcim/migrations/0170_configtemplate.py create mode 100644 netbox/extras/migrations/0086_configtemplate.py rename netbox/extras/models/{configcontexts.py => configs.py} (68%) create mode 100644 netbox/templates/dcim/device/render_config.html create mode 100644 netbox/templates/extras/configtemplate.html create mode 100644 netbox/templates/extras/configtemplate_list.html create mode 100644 netbox/utilities/jinja2.py diff --git a/docs/features/configuration-rendering.md b/docs/features/configuration-rendering.md new file mode 100644 index 000000000..2fe43f046 --- /dev/null +++ b/docs/features/configuration-rendering.md @@ -0,0 +1,38 @@ +# Configuration Rendering + +One of the critical aspects of operating a network is ensuring that every network node is configured correctly. By leveraging configuration templates and [context data](./context-data.md), NetBox can render complete configuration files for each device on your network. + +```mermaid +flowchart TD + ConfigContext & ConfigTemplate --> Config{{Rendered configuration}} + +click ConfigContext "../../models/extras/configcontext/" +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. + +```jinja2 +{% extends 'base.j2' %} + +{% block content %} + system { + host-name {{ device.name }}; + domain-name example.com; + time-zone UTC; + authentication-order [ password radius ]; + ntp { + {% for server in ntp_servers %} + server {{ server }}; + {% endfor %} + } + } + {% for interface in device.interfaces.all() %} + {% include 'common/interface.j2' %} + {% endfor %} +{% endblock %} +``` + +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. diff --git a/docs/features/context-data.md b/docs/features/context-data.md index 04e795fd5..22aeccea2 100644 --- a/docs/features/context-data.md +++ b/docs/features/context-data.md @@ -11,6 +11,8 @@ Configuration context data (or "config contexts" for short) is a powerful featur } ``` +Context data can be consumed by remote API clients, or it can be employed natively to render [configuration templates](./configuration-rendering.md). + Config contexts can be computed for objects based on the following criteria: | Type | Devices | Virtual Machines | diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index 33d07e07e..8f97b920b 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -72,6 +72,10 @@ The device's operational status. A device may be associated with a particular [platform](./platform.md) to indicate its operating system. Note that only platforms assigned to the associated manufacturer (or to no manufacturer) will be available for selection. +### Configuration Template + +The [configuration template](../extras/configtemplate.md) from which the configuration for this device can be rendered. If set, this will override any config template referenced by the device's role or platform. + ### Primary IPv4 & IPv6 Addresses Each device may designate one primary IPv4 address and/or one primary IPv6 address for management purposes. diff --git a/docs/models/dcim/devicerole.md b/docs/models/dcim/devicerole.md index e9bdc0fa6..786170f2b 100644 --- a/docs/models/dcim/devicerole.md +++ b/docs/models/dcim/devicerole.md @@ -19,3 +19,7 @@ The color used when displaying the role in the NetBox UI. ### VM Role If selected, this role may be assigned to [virtual machines](../virtualization/virtualmachine.md) + +### Configuration Template + +The default [configuration template](../extras/configtemplate.md) for devices assigned to this role. diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md index d080f74a4..cea3efb55 100644 --- a/docs/models/dcim/platform.md +++ b/docs/models/dcim/platform.md @@ -22,6 +22,10 @@ A unique URL-friendly identifier. (This value can be used for filtering.) If designated, this platform will be available for use only to devices assigned to this [manufacturer](./manufacturer.md). This can be handy e.g. for limiting network operating systems to use on hardware produced by the relevant vendor. However, it should not be used when defining general-purpose software platforms. +### Configuration Template + +The default [configuration template](../extras/configtemplate.md) for devices assigned to this platform. + ### NAPALM Driver The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform. diff --git a/docs/models/extras/configtemplate.md b/docs/models/extras/configtemplate.md new file mode 100644 index 000000000..c3585dbdf --- /dev/null +++ b/docs/models/extras/configtemplate.md @@ -0,0 +1,29 @@ +# 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. + +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. + +See the [configuration rendering documentation](../../features/configuration-rendering.md) for more information. + +## Fields + +### Name + +A unique human-friendly name. + +### Weight + +A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight. + +### Data File + +Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify template code: It will be populated automatically from the data file. + +### Template Code + +Jinja2 template code, if being defined locally rather than replicated from a data file. + +### Environment Parameters + +A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior. diff --git a/mkdocs.yml b/mkdocs.yml index ff9174455..fcfe0d21d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,7 @@ nav: - Contacts: 'features/contacts.md' - Search: 'features/search.md' - Context Data: 'features/context-data.md' + - Configuration Rendering: 'features/configuration-rendering.md' - Change Logging: 'features/change-logging.md' - Journaling: 'features/journaling.md' - Auth & Permissions: 'features/authentication-permissions.md' @@ -196,6 +197,7 @@ nav: - Extras: - Branch: 'models/extras/branch.md' - ConfigContext: 'models/extras/configcontext.md' + - ConfigTemplate: 'models/extras/configtemplate.md' - CustomField: 'models/extras/customfield.md' - CustomLink: 'models/extras/customlink.md' - ExportTemplate: 'models/extras/exporttemplate.md' diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 379d71b0d..38cfc8866 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -9,6 +9,7 @@ from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import * +from extras.api.nested_serializers import NestedConfigTemplateSerializer from ipam.api.nested_serializers import ( NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer, @@ -605,8 +606,8 @@ class DeviceRoleSerializer(NetBoxModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -619,8 +620,8 @@ class PlatformSerializer(NetBoxModelSerializer): class Meta: model = Platform fields = [ - 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', - 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', + 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -651,6 +652,7 @@ class DeviceSerializer(NetBoxModelSerializer): cluster = NestedClusterSerializer(required=False, allow_null=True) virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None) vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None) + config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) class Meta: model = Device @@ -658,7 +660,7 @@ class DeviceSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', - 'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', + 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 954c6a2a5..21b05fece 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -366,7 +366,7 @@ class InventoryItemTemplateViewSet(NetBoxModelViewSet): # class DeviceRoleViewSet(NetBoxModelViewSet): - queryset = DeviceRole.objects.prefetch_related('tags').annotate( + queryset = DeviceRole.objects.prefetch_related('config_template', 'tags').annotate( device_count=count_related(Device, 'device_role'), virtualmachine_count=count_related(VirtualMachine, 'role') ) @@ -379,7 +379,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet): # class PlatformViewSet(NetBoxModelViewSet): - queryset = Platform.objects.prefetch_related('tags').annotate( + queryset = Platform.objects.prefetch_related('config_template', 'tags').annotate( device_count=count_related(Device, 'platform'), virtualmachine_count=count_related(VirtualMachine, 'platform') ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 774f8a41f..fd3f9425e 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User from django.utils.translation import gettext as _ from extras.filtersets import LocalConfigContextFilterSet +from extras.models import ConfigTemplate from ipam.models import ASN, L2VPN, IPAddress, VRF from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, @@ -776,6 +777,10 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo class DeviceRoleFilterSet(OrganizationalModelFilterSet): + config_template_id = django_filters.ModelMultipleChoiceFilter( + queryset=ConfigTemplate.objects.all(), + label=_('Config template (ID)'), + ) class Meta: model = DeviceRole @@ -794,6 +799,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label=_('Manufacturer (slug)'), ) + config_template_id = django_filters.ModelMultipleChoiceFilter( + queryset=ConfigTemplate.objects.all(), + label=_('Config template (ID)'), + ) class Meta: model = Platform @@ -936,6 +945,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter method='_virtual_chassis_member', label=_('Is a virtual chassis member') ) + config_template_id = django_filters.ModelMultipleChoiceFilter( + queryset=ConfigTemplate.objects.all(), + label=_('Config template (ID)'), + ) console_ports = django_filters.BooleanFilter( method='_console_ports', label=_('Has console ports'), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index c00359d4c..ea7ab65cd 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -6,6 +6,7 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * +from extras.models import ConfigTemplate from ipam.models import ASN, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant @@ -454,6 +455,10 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): widget=BulkEditNullBooleanSelect, label=_('VM role') ) + config_template = DynamicModelChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False + ) description = forms.CharField( max_length=200, required=False @@ -461,9 +466,9 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): model = DeviceRole fieldsets = ( - (None, ('color', 'vm_role', 'description')), + (None, ('color', 'vm_role', 'config_template', 'description')), ) - nullable_fields = ('color', 'description') + nullable_fields = ('color', 'config_template', 'description') class PlatformBulkEditForm(NetBoxModelBulkEditForm): @@ -475,7 +480,10 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): max_length=50, required=False ) - # TODO: Bulk edit support for napalm_args + config_template = DynamicModelChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False + ) description = forms.CharField( max_length=200, required=False @@ -483,9 +491,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm): model = Platform fieldsets = ( - (None, ('manufacturer', 'napalm_driver', 'description')), + (None, ('manufacturer', 'config_template', 'napalm_driver', 'description')), ) - nullable_fields = ('manufacturer', 'napalm_driver', 'description') + nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description') class DeviceBulkEditForm(NetBoxModelBulkEditForm): @@ -540,6 +548,10 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + config_template = DynamicModelChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False + ) comments = CommentField( widget=forms.Textarea, label='Comments' @@ -550,6 +562,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): ('Device', ('device_role', 'status', 'tenant', 'platform', 'description')), ('Location', ('site', 'location')), ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), + ('Configuration', ('config_template',)), ) nullable_fields = ( 'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments', diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 1e8abcac6..e495ec34d 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * from dcim.models import * +from extras.models import ConfigTemplate from ipam.models import VRF from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant @@ -307,11 +308,17 @@ class ModuleTypeImportForm(NetBoxModelImportForm): class DeviceRoleImportForm(NetBoxModelImportForm): + config_template = CSVModelChoiceField( + queryset=ConfigTemplate.objects.all(), + to_field_name='name', + required=False, + help_text=_('Config template') + ) slug = SlugField() class Meta: model = DeviceRole - fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags') + fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags') help_texts = { 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } @@ -325,10 +332,18 @@ class PlatformImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Limit platform assignments to this manufacturer') ) + config_template = CSVModelChoiceField( + queryset=ConfigTemplate.objects.all(), + to_field_name='name', + required=False, + help_text=_('Config template') + ) class Meta: model = Platform - fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags') + fields = ( + 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags', + ) class BaseDeviceImportForm(NetBoxModelImportForm): @@ -434,12 +449,18 @@ class DeviceImportForm(BaseDeviceImportForm): required=False, help_text=_('Airflow direction') ) + config_template = CSVModelChoiceField( + queryset=ConfigTemplate.objects.all(), + to_field_name='name', + required=False, + help_text=_('Config template') + ) class Meta(BaseDeviceImportForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis', - 'vc_position', 'vc_priority', 'cluster', 'description', 'comments', 'tags', + 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index b5a6cd53b..4ccc2fe54 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -6,6 +6,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import LocalConfigContextFilterForm +from extras.models import ConfigTemplate from ipam.models import ASN, L2VPN, VRF from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm @@ -568,6 +569,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): class DeviceRoleFilterForm(NetBoxModelFilterSetForm): model = DeviceRole + config_template_id = DynamicModelMultipleChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False, + label=_('Config template') + ) tag = TagFilterField(model) @@ -578,6 +584,11 @@ class PlatformFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Manufacturer') ) + config_template_id = DynamicModelMultipleChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False, + label=_('Config template') + ) tag = TagFilterField(model) @@ -598,7 +609,7 @@ class DeviceFilterForm( ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', )), - ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'local_context_data')) + ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data')) ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -680,6 +691,11 @@ class DeviceFilterForm( required=False, label='MAC address' ) + config_template_id = DynamicModelMultipleChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False, + label=_('Config template') + ) has_primary_ip = forms.NullBooleanField( required=False, label='Has a primary IP', diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 8bac5d342..2e7ca0d4b 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -7,6 +7,7 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * +from extras.models import ConfigTemplate from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -416,18 +417,22 @@ class ModuleTypeForm(NetBoxModelForm): class DeviceRoleForm(NetBoxModelForm): + config_template = DynamicModelChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False + ) slug = SlugField() fieldsets = ( ('Device Role', ( - 'name', 'slug', 'color', 'vm_role', 'description', 'tags', + 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', )), ) class Meta: model = DeviceRole fields = [ - 'name', 'slug', 'color', 'vm_role', 'description', 'tags', + 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', ] @@ -436,13 +441,17 @@ class PlatformForm(NetBoxModelForm): queryset=Manufacturer.objects.all(), required=False ) + config_template = DynamicModelChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False + ) slug = SlugField( max_length=64 ) fieldsets = ( ('Platform', ( - 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags', + 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags', )), ) @@ -450,7 +459,7 @@ class PlatformForm(NetBoxModelForm): class Meta: model = Platform fields = [ - 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags', + 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags', ] widgets = { 'napalm_args': forms.Textarea(), @@ -565,6 +574,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm): label=_('Priority'), help_text=_("The priority of the device in the virtual chassis") ) + config_template = DynamicModelChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False + ) class Meta: model = Device @@ -572,7 +585,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', 'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', - 'description', 'comments', 'tags', 'local_context_data' + 'description', 'config_template', 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': _("The function this device serves"), diff --git a/netbox/dcim/migrations/0170_configtemplate.py b/netbox/dcim/migrations/0170_configtemplate.py new file mode 100644 index 000000000..b1aac0ad2 --- /dev/null +++ b/netbox/dcim/migrations/0170_configtemplate.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0086_configtemplate'), + ('dcim', '0169_devicetype_default_platform'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='config_template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='extras.configtemplate'), + ), + migrations.AddField( + model_name='devicerole', + name='config_template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='device_roles', to='extras.configtemplate'), + ), + migrations.AddField( + model_name='platform', + name='config_template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='platforms', to='extras.configtemplate'), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 94f61aba7..7ce1a2388 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -410,6 +410,13 @@ class DeviceRole(OrganizationalModel): verbose_name='VM Role', help_text=_('Virtual machines may be assigned to this role') ) + config_template = models.ForeignKey( + to='extras.ConfigTemplate', + on_delete=models.PROTECT, + related_name='device_roles', + blank=True, + null=True + ) def get_absolute_url(self): return reverse('dcim:devicerole', args=[self.pk]) @@ -429,6 +436,13 @@ class Platform(OrganizationalModel): null=True, help_text=_('Optionally limit this platform to devices of a certain manufacturer') ) + config_template = models.ForeignKey( + to='extras.ConfigTemplate', + on_delete=models.PROTECT, + related_name='platforms', + blank=True, + null=True + ) napalm_driver = models.CharField( max_length=50, blank=True, @@ -590,6 +604,13 @@ class Device(PrimaryModel, ConfigContextModel): null=True, validators=[MaxValueValidator(255)] ) + config_template = models.ForeignKey( + to='extras.ConfigTemplate', + on_delete=models.PROTECT, + related_name='devices', + blank=True, + null=True + ) # Generic relations contacts = GenericRelation( @@ -862,6 +883,17 @@ class Device(PrimaryModel, ConfigContextModel): def interfaces_count(self): return self.vc_interfaces().count() + def get_config_template(self): + """ + Return the appropriate ConfigTemplate (if any) for this Device. + """ + if self.config_template: + return self.config_template + if self.device_role.config_template: + return self.device_role.config_template + if self.platform and self.platform.config_template: + return self.platform.config_template + def get_vc_master(self): """ If this Device is a VirtualChassis member, return the VC master. Otherwise, return None. diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 904e96b83..f68960965 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -86,6 +86,9 @@ class DeviceRoleTable(NetBoxTable): ) color = columns.ColorColumn() vm_role = columns.BooleanColumn() + config_template = tables.Column( + linkify=True + ) tags = columns.TagColumn( url_name='dcim:devicerole_list' ) @@ -93,8 +96,8 @@ class DeviceRoleTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = models.DeviceRole fields = ( - 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', - 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description', + 'slug', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description') @@ -110,6 +113,9 @@ class PlatformTable(NetBoxTable): manufacturer = tables.Column( linkify=True ) + config_template = tables.Column( + linkify=True + ) device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'platform_id': 'pk'}, @@ -127,8 +133,8 @@ class PlatformTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = models.Platform fields = ( - 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', - 'description', 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver', + 'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', @@ -203,6 +209,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): vc_priority = tables.Column( verbose_name='VC Priority' ) + config_template = tables.Column( + linkify=True + ) comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:device_list' @@ -214,7 +223,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', - 'vc_priority', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 095314e7b..62359553d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,3 +1,5 @@ +import traceback + from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger @@ -10,10 +12,11 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from django.views.generic import View +from jinja2.exceptions import TemplateError from circuits.models import Circuit, CircuitTermination from extras.views import ObjectConfigContextView -from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup +from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup from ipam.tables import InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm @@ -1997,6 +2000,39 @@ class DeviceInventoryView(DeviceComponentsView): ) +@register_model_view(Device, 'render-config') +class DeviceRenderConfigView(generic.ObjectView): + queryset = Device.objects.all() + template_name = 'dcim/device/render_config.html' + tab = ViewTab( + label=_('Render Config'), + permission='extras.view_configtemplate', + weight=2000 + ) + + def get_extra_context(self, request, instance): + # Compile context data + context_data = { + 'device': instance, + } + context_data.update(**instance.get_config_context()) + + # Render the config template + rendered_config = None + if config_template := instance.get_config_template(): + try: + rendered_config = config_template.render(context=context_data) + except TemplateError as e: + messages.error(request, f"An error occurred while rendering the template: {e}") + rendered_config = traceback.format_exc() + + return { + 'config_template': config_template, + 'context_data': context_data, + 'rendered_config': rendered_config, + } + + @register_model_view(Device, 'configcontext', path='config-context') class DeviceConfigContextView(ObjectConfigContextView): queryset = Device.objects.annotate_config_context_data() @@ -2004,7 +2040,7 @@ class DeviceConfigContextView(ObjectConfigContextView): tab = ViewTab( label=_('Config Context'), permission='extras.view_configcontext', - weight=2000 + weight=2100 ) diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 5644b0b4e..dab0798fe 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -7,6 +7,7 @@ from users.api.nested_serializers import NestedUserSerializer __all__ = [ 'NestedConfigContextSerializer', + 'NestedConfigTemplateSerializer', 'NestedCustomFieldSerializer', 'NestedCustomLinkSerializer', 'NestedExportTemplateSerializer', @@ -51,6 +52,14 @@ class NestedConfigContextSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedConfigTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail') + + class Meta: + model = models.ConfigTemplate + fields = ['id', 'url', 'display', 'name'] + + class NestedExportTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 6a8248548..5e0a484f8 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -16,6 +16,7 @@ from extras.utils import FeatureQuery from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer +from netbox.api.serializers.features import TaggableModelSerializer from netbox.constants import NESTED_SERIALIZER_PREFIX from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup @@ -29,6 +30,7 @@ from .nested_serializers import * __all__ = ( 'ConfigContextSerializer', + 'ConfigTemplateSerializer', 'ContentTypeSerializer', 'CustomFieldSerializer', 'CustomLinkSerializer', @@ -383,6 +385,27 @@ class ConfigContextSerializer(ValidatedModelSerializer): ] +# +# Config templates +# + +class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail') + data_source = NestedDataSourceSerializer( + required=False + ) + data_file = NestedDataFileSerializer( + read_only=True + ) + + class Meta: + model = ConfigTemplate + fields = [ + 'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source', + 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated', + ] + + # # Job Results # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 91067d40d..f01cdcd00 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -14,6 +14,7 @@ router.register('tags', views.TagViewSet) router.register('image-attachments', views.ImageAttachmentViewSet) router.register('journal-entries', views.JournalEntryViewSet) router.register('config-contexts', views.ConfigContextViewSet) +router.register('config-templates', views.ConfigTemplateViewSet) router.register('reports', views.ReportViewSet, basename='report') router.register('scripts', views.ScriptViewSet, basename='script') router.register('object-changes', views.ObjectChangeViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 190b32f53..75f0eb464 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -5,6 +5,7 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated +from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet @@ -19,10 +20,12 @@ from extras.scripts import get_script, get_scripts, run_script from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.features import SyncedDataMixin from netbox.api.metadata import ContentTypeMetadata +from netbox.api.renderers import TextRenderer from netbox.api.viewsets import NetBoxModelViewSet from utilities.exceptions import RQWorkerNotRunningException from utilities.utils import copy_safe_request, count_related from . import serializers +from .nested_serializers import NestedConfigTemplateSerializer class ExtrasRootView(APIRootView): @@ -157,6 +160,35 @@ class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet): filterset_class = filtersets.ConfigContextFilterSet +# +# Config templates +# + +class ConfigTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet): + queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file') + serializer_class = serializers.ConfigTemplateSerializer + filterset_class = filtersets.ConfigTemplateFilterSet + + @action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer]) + def render(self, request, pk): + """ + Render a ConfigTemplate using the context data provided (if any). If the client requests "text/plain" data, + return the raw rendered content, rather than serialized JSON. + """ + configtemplate = self.get_object() + output = configtemplate.render(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 + }) + + # # Reports # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index f7f34e17a..816406647 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -4,18 +4,19 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext as _ -from core.models import DataFile, DataSource +from core.models import DataSource from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter from virtualization.models import Cluster, ClusterGroup, ClusterType from .choices import * +from .filters import TagFilter from .models import * - __all__ = ( 'ConfigContextFilterSet', + 'ConfigTemplateFilterSet', 'ContentTypeFilterSet', 'CustomFieldFilterSet', 'CustomLinkFilterSet', @@ -454,6 +455,34 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): ) +class ConfigTemplateFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + data_source_id = django_filters.ModelMultipleChoiceFilter( + queryset=DataSource.objects.all(), + label=_('Data source (ID)'), + ) + data_file_id = django_filters.ModelMultipleChoiceFilter( + queryset=DataSource.objects.all(), + label=_('Data file (ID)'), + ) + tag = TagFilter() + + class Meta: + model = ConfigTemplate + fields = ['id', 'name', 'description', 'data_synced'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + # # Filter for Local Config Context Data # diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 47a529772..bba585591 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -9,6 +9,7 @@ from utilities.forms import ( __all__ = ( 'ConfigContextBulkEditForm', + 'ConfigTemplateBulkEditForm', 'CustomFieldBulkEditForm', 'CustomLinkBulkEditForm', 'ExportTemplateBulkEditForm', @@ -201,6 +202,19 @@ class ConfigContextBulkEditForm(BulkEditForm): nullable_fields = ('description',) +class ConfigTemplateBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConfigTemplate.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + nullable_fields = ('description',) + + class JournalEntryBulkEditForm(BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=JournalEntry.objects.all(), diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index cf723c4f7..b035c2579 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -10,6 +10,7 @@ from extras.utils import FeatureQuery from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField __all__ = ( + 'ConfigTemplateImportForm', 'CustomFieldImportForm', 'CustomLinkImportForm', 'ExportTemplateImportForm', @@ -83,6 +84,15 @@ class ExportTemplateImportForm(CSVModelForm): ) +class ConfigTemplateImportForm(CSVModelForm): + + class Meta: + model = ConfigTemplate + fields = ( + 'name', 'description', 'environment_params', 'template_code', 'tags', + ) + + class SavedFilterImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( queryset=ContentType.objects.all(), diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 4a92ff606..114eb1a59 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -20,6 +20,7 @@ from .mixins import SavedFiltersMixin __all__ = ( 'ConfigContextFilterForm', + 'ConfigTemplateFilterForm', 'CustomFieldFilterForm', 'CustomLinkFilterForm', 'ExportTemplateFilterForm', @@ -358,6 +359,27 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): ) +class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm): + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + ('Data', ('data_source_id', 'data_file_id')), + ) + data_source_id = DynamicModelMultipleChoiceField( + queryset=DataSource.objects.all(), + required=False, + label=_('Data source') + ) + data_file_id = DynamicModelMultipleChoiceField( + queryset=DataFile.objects.all(), + required=False, + label=_('Data file'), + query_params={ + 'source_id': '$data_source_id' + } + ) + tag = TagFilterField(ConfigTemplate) + + class LocalConfigContextFilterForm(forms.Form): local_context_data = forms.NullBooleanField( required=False, diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 69c124ee2..4ce81c01b 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -18,6 +18,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( 'ConfigContextForm', + 'ConfigTemplateForm', 'CustomFieldForm', 'CustomLinkForm', 'ExportTemplateForm', @@ -269,6 +270,34 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): return self.cleaned_data +class ConfigTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + template_code = forms.CharField( + required=False, + widget=forms.Textarea(attrs={'class': 'font-monospace'}) + ) + + fieldsets = ( + ('Config Template', ('name', 'description', 'environment_params', 'tags')), + ('Content', ('data_source', 'data_file', 'template_code',)), + ) + + class Meta: + model = ConfigTemplate + fields = '__all__' + + def clean(self): + super().clean() + + if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'): + raise forms.ValidationError("Must specify either local content or a data file") + + return self.cleaned_data + + class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): class Meta: diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index 0c3113879..3e116023f 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -8,6 +8,9 @@ class ExtrasQuery(graphene.ObjectType): config_context = ObjectField(ConfigContextType) config_context_list = ObjectListField(ConfigContextType) + config_template = ObjectField(ConfigTemplateType) + config_template_list = ObjectListField(ConfigTemplateType) + custom_field = ObjectField(CustomFieldType) custom_field_list = ObjectListField(CustomFieldType) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index b5d4dffce..ba16ccd3e 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -4,6 +4,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType __all__ = ( 'ConfigContextType', + 'ConfigTemplateType', 'CustomFieldType', 'CustomLinkType', 'ExportTemplateType', @@ -24,6 +25,14 @@ class ConfigContextType(ObjectType): filterset_class = filtersets.ConfigContextFilterSet +class ConfigTemplateType(ObjectType): + + class Meta: + model = models.ConfigTemplate + fields = '__all__' + filterset_class = filtersets.ConfigTemplateFilterSet + + class CustomFieldType(ObjectType): class Meta: diff --git a/netbox/extras/migrations/0086_configtemplate.py b/netbox/extras/migrations/0086_configtemplate.py new file mode 100644 index 000000000..bd47254e9 --- /dev/null +++ b/netbox/extras/migrations/0086_configtemplate.py @@ -0,0 +1,34 @@ +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ('extras', '0085_synced_data'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('data_path', models.CharField(blank=True, editable=False, max_length=1000)), + ('data_synced', models.DateTimeField(blank=True, editable=False, null=True)), + ('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)), + ('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')), + ], + options={ + 'ordering': ('name',), + }, + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 9b5c660c4..33936cc4f 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,5 +1,5 @@ from .change_logging import ObjectChange -from .configcontexts import ConfigContext, ConfigContextModel +from .configs import * from .customfields import CustomField from .models import * from .search import * @@ -12,6 +12,7 @@ __all__ = ( 'ConfigContext', 'ConfigContextModel', 'ConfigRevision', + 'ConfigTemplate', 'CustomField', 'CustomLink', 'ExportTemplate', diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configs.py similarity index 68% rename from netbox/extras/models/configcontexts.py rename to netbox/extras/models/configs.py index eed8babcd..f2b50f161 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configs.py @@ -3,15 +3,21 @@ from django.core.validators import ValidationError from django.db import models from django.urls import reverse from django.utils import timezone +from django.utils.translation import gettext as _ +from jinja2.loaders import BaseLoader +from jinja2.sandbox import SandboxedEnvironment from extras.querysets import ConfigContextQuerySet +from netbox.config import get_config from netbox.models import ChangeLoggedModel -from netbox.models.features import SyncedDataMixin +from netbox.models.features import ExportTemplatesMixin, SyncedDataMixin, TagsMixin +from utilities.jinja2 import ConfigTemplateLoader from utilities.utils import deepmerge __all__ = ( 'ConfigContext', 'ConfigContextModel', + 'ConfigTemplate', ) @@ -182,3 +188,77 @@ class ConfigContextModel(models.Model): raise ValidationError( {'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'} ) + + +# +# Config templates +# + +class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True + ) + template_code = models.TextField( + help_text=_('Jinja2 template code.') + ) + environment_params = models.JSONField( + blank=True, + null=True + ) + + class Meta: + ordering = ('name',) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:configtemplate', args=[self.pk]) + + def sync_data(self): + """ + Synchronize template content from the designated DataFile (if any). + """ + self.template_code = self.data_file.data_as_string + self.data_synced = timezone.now() + + def render(self, context=None): + """ + Render the contents of the template. + """ + context = context or {} + + # 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) + + output = template.render(**context) + + # Replace CRLF-style line terminators + return output.replace('\r\n', '\n') + + def _get_environment(self): + """ + Instantiate and return a Jinja2 environment suitable for rendering the ConfigTemplate. + """ + # Initialize the template loader & cache the base template code (if applicable) + if self.data_file: + loader = ConfigTemplateLoader(data_source=self.data_source) + loader.cache_templates({ + self.data_file.path: self.template_code + }) + else: + loader = BaseLoader() + + # Initialize the environment + environment = SandboxedEnvironment(loader=loader) + environment.filters.update(get_config().JINJA2_FILTERS) + + return environment diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 6b2f34de4..5991203f2 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -8,6 +8,7 @@ from .template_code import * __all__ = ( 'ConfigContextTable', + 'ConfigTemplateTable', 'CustomFieldTable', 'CustomLinkTable', 'ExportTemplateTable', @@ -223,6 +224,34 @@ class ConfigContextTable(NetBoxTable): default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description') +class ConfigTemplateTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + data_source = tables.Column( + linkify=True + ) + data_file = tables.Column( + linkify=True + ) + is_synced = columns.BooleanColumn( + verbose_name='Synced' + ) + tags = columns.TagColumn( + url_name='extras:configtemplate_list' + ) + + class Meta(NetBoxTable.Meta): + model = ConfigTemplate + fields = ( + 'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated', + 'tags', + ) + default_columns = ( + 'pk', 'name', 'description', 'is_synced', + ) + + class ObjectChangeTable(NetBoxTable): time = tables.DateTimeColumn( linkify=True, diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index dabb9f977..dfbaa1bc6 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -64,6 +64,14 @@ urlpatterns = [ path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'), path('config-contexts//', include(get_model_urls('extras', 'configcontext'))), + # Config templates + path('config-templates/', views.ConfigTemplateListView.as_view(), name='configtemplate_list'), + path('config-templates/add/', views.ConfigTemplateEditView.as_view(), name='configtemplate_add'), + path('config-templates/edit/', views.ConfigTemplateBulkEditView.as_view(), name='configtemplate_bulk_edit'), + path('config-templates/delete/', views.ConfigTemplateBulkDeleteView.as_view(), name='configtemplate_bulk_delete'), + path('config-templates/sync/', views.ConfigTemplateBulkSyncDataView.as_view(), name='configtemplate_bulk_sync'), + path('config-templates//', include(get_model_urls('extras', 'configtemplate'))), + # Image attachments path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'), path('image-attachments//', include(get_model_urls('extras', 'imageattachment'))), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index de06b5739..3edb70cf1 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -452,6 +452,58 @@ class ObjectConfigContextView(generic.ObjectView): } +# +# Config templates +# + +class ConfigTemplateListView(generic.ObjectListView): + queryset = ConfigTemplate.objects.all() + filterset = filtersets.ConfigTemplateFilterSet + filterset_form = forms.ConfigTemplateFilterForm + table = tables.ConfigTemplateTable + template_name = 'extras/configtemplate_list.html' + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync') + + +@register_model_view(ConfigTemplate) +class ConfigTemplateView(generic.ObjectView): + queryset = ConfigTemplate.objects.all() + + +@register_model_view(ConfigTemplate, 'edit') +class ConfigTemplateEditView(generic.ObjectEditView): + queryset = ConfigTemplate.objects.all() + form = forms.ConfigTemplateForm + + +@register_model_view(ConfigTemplate, 'delete') +class ConfigTemplateDeleteView(generic.ObjectDeleteView): + queryset = ConfigTemplate.objects.all() + + +class ConfigTemplateBulkImportView(generic.BulkImportView): + queryset = ConfigTemplate.objects.all() + model_form = forms.ConfigTemplateImportForm + table = tables.ConfigTemplateTable + + +class ConfigTemplateBulkEditView(generic.BulkEditView): + queryset = ConfigTemplate.objects.all() + filterset = filtersets.ConfigTemplateFilterSet + table = tables.ConfigTemplateTable + form = forms.ConfigTemplateBulkEditForm + + +class ConfigTemplateBulkDeleteView(generic.BulkDeleteView): + queryset = ConfigTemplate.objects.all() + filterset = filtersets.ConfigTemplateFilterSet + table = tables.ConfigTemplateTable + + +class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView): + queryset = ConfigTemplate.objects.all() + + # # Change logging # diff --git a/netbox/netbox/api/renderers.py b/netbox/netbox/api/renderers.py index c492510fb..e41fbe450 100644 --- a/netbox/netbox/api/renderers.py +++ b/netbox/netbox/api/renderers.py @@ -1,4 +1,9 @@ -from rest_framework.renderers import BrowsableAPIRenderer +from rest_framework.renderers import BaseRenderer, BrowsableAPIRenderer + +__all__ = ( + 'FormlessBrowsableAPIRenderer', + 'TextRenderer', +) class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): @@ -10,3 +15,14 @@ class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): def get_filter_form(self, data, view, request): return None + + +class TextRenderer(BaseRenderer): + """ + Return raw data as plain text. + """ + media_type = 'text/plain' + format = 'txt' + + def render(self, data, accepted_media_type=None, renderer_context=None): + return str(data) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 6fce7dfe6..03c361002 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -311,6 +311,7 @@ OTHER_MENU = Menu( items=( get_model_item('extras', 'tag', 'Tags'), get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']), + get_model_item('extras', 'configtemplate', _('Config Templates'), actions=['add']), ), ), ), diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 3c2cc6299..3af7814ae 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -9,9 +9,7 @@
-
- Device -
+
Device
@@ -111,6 +109,10 @@ + + + +
Asset Tag {{ object.asset_tag|placeholder }}
Config Template{{ object.config_template|linkify|placeholder }}
diff --git a/netbox/templates/dcim/device/render_config.html b/netbox/templates/dcim/device/render_config.html new file mode 100644 index 000000000..bc5642e2e --- /dev/null +++ b/netbox/templates/dcim/device/render_config.html @@ -0,0 +1,47 @@ +{% extends 'dcim/device/base.html' %} +{% load static %} + +{% block title %}{{ object }} - Config{% endblock %} + +{% block content %} +
+
+
+
Config Template
+
+ + + + + + + + + + + + + +
Config Template{{ config_template|linkify|placeholder }}
Data Source{{ config_template.data_file.source|linkify|placeholder }}
Data File{{ config_template.data_file|linkify|placeholder }}
+
+
+
+
+
+
Context Data
+
{{ context_data|pprint }}
+
+
+
+
+
+
+ {% if config_template %} +
{{ rendered_config }}
+ {% else %} +
No configuration template found
+ {% endif %} +
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index b814e65ef..07e3bbdc9 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -65,6 +65,7 @@
{% render_field form.status %} {% render_field form.platform %} + {% render_field form.config_template %} {% if object.pk %} {% render_field form.primary_ip4 %} {% render_field form.primary_ip6 %} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index bc01dbdb7..7c0bf67f6 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -42,6 +42,10 @@ VM Role {% checkmark object.vm_role %} + + Config Template + {{ object.config_template|linkify|placeholder }} +
diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index 17a313d82..5123699d4 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -39,6 +39,10 @@ Manufacturer {{ object.manufacturer|linkify|placeholder }} + + Config Template + {{ object.config_template|linkify|placeholder }} + NAPALM Driver {{ object.napalm_driver|placeholder }} diff --git a/netbox/templates/extras/configtemplate.html b/netbox/templates/extras/configtemplate.html new file mode 100644 index 000000000..937789ee2 --- /dev/null +++ b/netbox/templates/extras/configtemplate.html @@ -0,0 +1,77 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Config Template
+
+ + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
Data Source + {% if object.data_source %} + {{ object.data_source }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Data File + {% if object.data_file %} + {{ object.data_file }} + {% elif object.data_path %} +
+ +
+ {{ object.data_path }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
Data Synced{{ object.data_synced|placeholder }}
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+
+
Environment Parameters
+
+
{{ object.environment_params }}
+
+
+ {% plugin_right_page object %} +
+
+
+
+
+
Template
+
+ {% include 'inc/sync_warning.html' %} +
{{ object.template_code }}
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/configtemplate_list.html b/netbox/templates/extras/configtemplate_list.html new file mode 100644 index 000000000..5a3e8781e --- /dev/null +++ b/netbox/templates/extras/configtemplate_list.html @@ -0,0 +1,10 @@ +{% extends 'generic/object_list.html' %} + +{% block bulk_buttons %} + {% if perms.extras.sync_configtemplate %} + + {% endif %} + {{ block.super }} +{% endblock %} diff --git a/netbox/utilities/jinja2.py b/netbox/utilities/jinja2.py new file mode 100644 index 000000000..7e5bcca14 --- /dev/null +++ b/netbox/utilities/jinja2.py @@ -0,0 +1,37 @@ +from django.apps import apps +from jinja2 import BaseLoader, TemplateNotFound +from jinja2.meta import find_referenced_templates + +__all__ = ( + 'ConfigTemplateLoader', +) + + +class ConfigTemplateLoader(BaseLoader): + """ + Custom Jinja2 loader to facilitate populating template content from DataFiles. + """ + def __init__(self, data_source): + self.data_source = data_source + self._template_cache = {} + + def get_source(self, environment, template): + DataFile = apps.get_model('core', 'DataFile') + + # Retrieve template content from cache + try: + template_source = self._template_cache[template] + except KeyError: + raise TemplateNotFound(template) + + # Find and pre-fetch referenced templates + if referenced_templates := find_referenced_templates(environment.parse(template_source)): + self.cache_templates({ + df.path: df.data_as_string for df in + DataFile.objects.filter(source=self.data_source, path__in=referenced_templates) + }) + + return template_source, template, lambda: True + + def cache_templates(self, templates): + self._template_cache.update(templates)