diff --git a/netbox/dcim/migrations/0170_configtemplate.py b/netbox/dcim/migrations/0170_configtemplate.py index b1aac0ad20..f9508424d4 100644 --- a/netbox/dcim/migrations/0170_configtemplate.py +++ b/netbox/dcim/migrations/0170_configtemplate.py @@ -13,7 +13,7 @@ class Migration(migrations.Migration): 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'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'), ), migrations.AddField( model_name='devicerole', diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 6b8e927436..857251caf4 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -24,7 +24,7 @@ from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField from utilities.tracking import TrackingModelMixin from .device_components import * -from .mixins import WeightMixin +from .mixins import RenderConfigMixin, WeightMixin __all__ = ( @@ -525,7 +525,14 @@ def update_interface_bridges(device, interface_templates, module=None): interface.save() -class Device(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, ConfigContextModel, TrackingModelMixin): +class Device( + ContactsMixin, + ImageAttachmentsMixin, + RenderConfigMixin, + ConfigContextModel, + TrackingModelMixin, + PrimaryModel +): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -686,13 +693,6 @@ class Device(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, ConfigContextMo validators=[MaxValueValidator(255)], help_text=_('Virtual chassis master election priority') ) - config_template = models.ForeignKey( - to='extras.ConfigTemplate', - on_delete=models.PROTECT, - related_name='devices', - blank=True, - null=True - ) latitude = models.DecimalField( verbose_name=_('latitude'), max_digits=8, @@ -1070,17 +1070,6 @@ def primary_ip(self): 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.role.config_template: - return self.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/models/mixins.py b/netbox/dcim/models/mixins.py index f787c8e97b..95f6d41fe9 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -4,6 +4,11 @@ from dcim.choices import * from utilities.utils import to_grams +__all__ = ( + 'RenderConfigMixin', + 'WeightMixin', +) + class WeightMixin(models.Model): weight = models.DecimalField( @@ -44,3 +49,27 @@ def clean(self): # Validate weight and weight_unit if self.weight and not self.weight_unit: raise ValidationError(_("Must specify a unit when setting a weight")) + + +class RenderConfigMixin(models.Model): + config_template = models.ForeignKey( + to='extras.ConfigTemplate', + on_delete=models.PROTECT, + related_name='%(class)ss', + blank=True, + null=True + ) + + class Meta: + abstract = True + + def get_config_template(self): + """ + Return the appropriate ConfigTemplate (if any) for this Device. + """ + if self.config_template: + return self.config_template + if self.role.config_template: + return self.role.config_template + if self.platform and self.platform.config_template: + return self.platform.config_template diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 04e038b92d..27f5ea1149 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -43,6 +43,10 @@
{{ object.tenant|linkify|placeholder }} + + {% trans "Config Template" %} + {{ object.config_template|linkify|placeholder }} + {% trans "Primary IPv4" %} diff --git a/netbox/templates/virtualization/virtualmachine/render_config.html b/netbox/templates/virtualization/virtualmachine/render_config.html new file mode 100644 index 0000000000..7b638199b9 --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine/render_config.html @@ -0,0 +1,70 @@ +{% extends 'virtualization/virtualmachine/base.html' %} +{% load static %} +{% load i18n %} + +{% block title %}{{ object }} - {% trans "Config" %}{% endblock %} + +{% block content %} +
+
+
+
{% trans "Config Template" %}
+
+ + + + + + + + + + + + + +
{% trans "Config Template" %}{{ config_template|linkify|placeholder }}
{% trans "Data Source" %}{{ config_template.data_file.source|linkify|placeholder }}
{% trans "Data File" %}{{ config_template.data_file|linkify|placeholder }}
+
+
+
+
+
+
+
+
+

+ +

+
+
+
{{ context_data|pprint }}
+
+
+
+
+
+
+
+
+
+
+
+
+ +
{% trans "Rendered Config" %}
+
+ {% if config_template %} +
{{ rendered_config }}
+ {% else %} +
{% trans "No configuration template found" %}
+ {% endif %} +
+
+
+{% endblock %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 693bb362fd..c9fa559aa7 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -5,6 +5,7 @@ NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer, ) from dcim.choices import InterfaceModeChoices +from extras.api.nested_serializers import NestedConfigTemplateSerializer from ipam.api.nested_serializers import ( NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer, ) @@ -79,6 +80,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer): primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) + config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) # Counter fields interface_count = serializers.IntegerField(read_only=True) @@ -88,7 +90,8 @@ class Meta: fields = [ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', - 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'interface_count', + 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', + 'interface_count', ] validators = [] diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index cf716ca32c..571dbe64b2 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -5,6 +5,7 @@ from dcim.filtersets import CommonInterfaceFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet +from extras.models import ConfigTemplate from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter @@ -228,6 +229,10 @@ class VirtualMachineFilterSet( method='_has_primary_ip', label=_('Has a primary IP'), ) + config_template_id = django_filters.ModelMultipleChoiceFilter( + queryset=ConfigTemplate.objects.all(), + label=_('Config template (ID)'), + ) class Meta: model = VirtualMachine diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index cc281a4f7e..a33ffac537 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -4,6 +4,7 @@ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup +from extras.models import ConfigTemplate from ipam.models import VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant @@ -174,12 +175,17 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + config_template = DynamicModelChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False + ) comments = CommentField() model = VirtualMachine fieldsets = ( (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description')), - (_('Resources'), ('vcpus', 'memory', 'disk')) + (_('Resources'), ('vcpus', 'memory', 'disk')), + ('Configuration', ('config_template',)), ) nullable_fields = ( 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments', diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 19f718f035..04fe2d7ae3 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -2,6 +2,7 @@ from dcim.choices import InterfaceModeChoices from dcim.models import Device, DeviceRole, Platform, Site +from extras.models import ConfigTemplate from ipam.models import VRF from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant @@ -123,12 +124,19 @@ class VirtualMachineImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Assigned platform') ) + config_template = CSVModelChoiceField( + queryset=ConfigTemplate.objects.all(), + to_field_name='name', + required=False, + label=_('Config template'), + help_text=_('Config template') + ) class Meta: model = VirtualMachine fields = ( 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'description', 'comments', 'tags', + 'description', 'config_template', 'comments', 'tags', ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index cd12696458..99ac0cb774 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -3,6 +3,7 @@ from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import LocalConfigContextFilterForm +from extras.models import ConfigTemplate from ipam.models import L2VPN, VRF from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm @@ -93,7 +94,7 @@ class VirtualMachineFilterForm( (None, ('q', 'filter_id', 'tag')), (_('Cluster'), ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id')), - (_('Attributes'), ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), + (_('Attributes'), ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'config_template_id', 'local_context_data')), (_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')), ) @@ -170,6 +171,11 @@ class VirtualMachineFilterForm( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + config_template_id = DynamicModelMultipleChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False, + label=_('Config template') + ) tag = TagFilterField(model) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 0c8c98f9f2..21dbc895a7 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -5,6 +5,7 @@ from dcim.forms.common import InterfaceCommonForm from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup +from extras.models import ConfigTemplate from ipam.models import IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -205,13 +206,18 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): required=False, label='' ) + config_template = DynamicModelChoiceField( + queryset=ConfigTemplate.objects.all(), + required=False, + label=_('Config template') + ) comments = CommentField() fieldsets = ( (_('Virtual Machine'), ('name', 'role', 'status', 'description', 'tags')), (_('Site/Cluster'), ('site', 'cluster', 'device')), (_('Tenancy'), ('tenant_group', 'tenant')), - (_('Management'), ('platform', 'primary_ip4', 'primary_ip6')), + (_('Management'), ('platform', 'primary_ip4', 'primary_ip6', 'config_template')), (_('Resources'), ('vcpus', 'memory', 'disk')), (_('Config Context'), ('local_context_data',)), ) @@ -221,6 +227,7 @@ class Meta: fields = [ 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags', 'local_context_data', + 'config_template', ] def __init__(self, *args, **kwargs): diff --git a/netbox/virtualization/migrations/0036_virtualmachine_config_template.py b/netbox/virtualization/migrations/0036_virtualmachine_config_template.py new file mode 100644 index 0000000000..0456eea81d --- /dev/null +++ b/netbox/virtualization/migrations/0036_virtualmachine_config_template.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.10 on 2023-08-11 17:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0098_webhook_custom_field_data_webhook_tags'), + ('virtualization', '0035_virtualmachine_interface_count'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='config_template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'), + ), + ] diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 1cacd8adc3..eb6c2a8b0d 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import BaseInterface +from dcim.models.mixins import RenderConfigMixin from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from netbox.config import get_config @@ -25,7 +26,7 @@ ) -class VirtualMachine(ContactsMixin, PrimaryModel, ConfigContextModel): +class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel): """ A virtual machine which runs inside a Cluster. """ diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index cece6f0921..f8473df1e8 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -84,13 +84,17 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) interface_count = tables.Column( verbose_name=_('Interfaces') ) + config_template = tables.Column( + verbose_name=_('Config Template'), + linkify=True + ) class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', - 'contacts', 'tags', 'created', 'last_updated', + 'config_template', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index a4474610a2..9c7748cbd1 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,11 +1,14 @@ +import traceback from collections import defaultdict from django.contrib import messages from django.db import transaction from django.db.models import Prefetch, Sum +from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.translation import gettext as _ +from jinja2.exceptions import TemplateError from dcim.filtersets import DeviceFilterSet from dcim.models import Device @@ -389,6 +392,55 @@ class VirtualMachineConfigContextView(ObjectConfigContextView): ) +@register_model_view(VirtualMachine, 'render-config') +class VirtualMachineRenderConfigView(generic.ObjectView): + queryset = VirtualMachine.objects.all() + template_name = 'virtualization/virtualmachine/render_config.html' + tab = ViewTab( + label=_('Render Config'), + permission='extras.view_configtemplate', + weight=2100 + ) + + def get(self, request, **kwargs): + instance = self.get_object(**kwargs) + context = self.get_extra_context(request, instance) + + # If a direct export has been requested, return the rendered template content as a + # downloadable file. + if request.GET.get('export'): + response = HttpResponse(context['rendered_config'], content_type='text') + filename = f"{instance.name or 'config'}.txt" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + return render(request, self.get_template_name(), { + 'object': instance, + 'tab': self.tab, + **context, + }) + + def get_extra_context(self, request, instance): + # Compile context data + context_data = instance.get_config_context() + context_data.update({'virtualmachine': instance}) + + # 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(VirtualMachine, 'edit') class VirtualMachineEditView(generic.ObjectEditView): queryset = VirtualMachine.objects.all()