Explorar el Código

Adds config template to vm model (#13450)

* adds config template to vm model #12461

* Add translation tags; collapse config data

* i18n cleanup

* Establish parity with DeviceRenderConfigView

* Move config_template field to RenderConfigMixin

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Abhimanyu Saharan hace 2 años
padre
commit
752e26c7de

+ 1 - 1
netbox/dcim/migrations/0170_configtemplate.py

@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
         migrations.AddField(
         migrations.AddField(
             model_name='device',
             model_name='device',
             name='config_template',
             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(
         migrations.AddField(
             model_name='devicerole',
             model_name='devicerole',

+ 9 - 20
netbox/dcim/models/devices.py

@@ -24,7 +24,7 @@ from utilities.choices import ColorChoices
 from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
 from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
 from .device_components import *
 from .device_components import *
-from .mixins import WeightMixin
+from .mixins import RenderConfigMixin, WeightMixin
 
 
 
 
 __all__ = (
 __all__ = (
@@ -525,7 +525,14 @@ def update_interface_bridges(device, interface_templates, module=None):
             interface.save()
             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,
     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.
     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)],
         validators=[MaxValueValidator(255)],
         help_text=_('Virtual chassis master election priority')
         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(
     latitude = models.DecimalField(
         verbose_name=_('latitude'),
         verbose_name=_('latitude'),
         max_digits=8,
         max_digits=8,
@@ -1070,17 +1070,6 @@ class Device(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, ConfigContextMo
     def interfaces_count(self):
     def interfaces_count(self):
         return self.vc_interfaces().count()
         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):
     def get_vc_master(self):
         """
         """
         If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
         If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.

+ 29 - 0
netbox/dcim/models/mixins.py

@@ -4,6 +4,11 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.choices import *
 from utilities.utils import to_grams
 from utilities.utils import to_grams
 
 
+__all__ = (
+    'RenderConfigMixin',
+    'WeightMixin',
+)
+
 
 
 class WeightMixin(models.Model):
 class WeightMixin(models.Model):
     weight = models.DecimalField(
     weight = models.DecimalField(
@@ -44,3 +49,27 @@ class WeightMixin(models.Model):
         # Validate weight and weight_unit
         # Validate weight and weight_unit
         if self.weight and not self.weight_unit:
         if self.weight and not self.weight_unit:
             raise ValidationError(_("Must specify a unit when setting a weight"))
             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

+ 4 - 0
netbox/templates/virtualization/virtualmachine.html

@@ -43,6 +43,10 @@
                             {{ object.tenant|linkify|placeholder }}
                             {{ object.tenant|linkify|placeholder }}
                         </td>
                         </td>
                     </tr>
                     </tr>
+                    <tr>
+                        <th scope="row">{% trans "Config Template" %}</th>
+                        <td>{{ object.config_template|linkify|placeholder }}</td>
+                    </tr>
                     <tr>
                     <tr>
                         <th scope="row">{% trans "Primary IPv4" %}</th>
                         <th scope="row">{% trans "Primary IPv4" %}</th>
                         <td>
                         <td>

+ 70 - 0
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 %}
+  <div class="row mb-3">
+    <div class="col-5">
+      <div class="card">
+        <h5 class="card-header">{% trans "Config Template" %}</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Config Template" %}</th>
+              <td>{{ config_template|linkify|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Data Source" %}</th>
+              <td>{{ config_template.data_file.source|linkify|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Data File" %}</th>
+              <td>{{ config_template.data_file|linkify|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+    </div>
+    <div class="col-7">
+      <div class="card">
+        <div class="accordion accordion-flush" id="renderConfig">
+          <div class="card-body">
+            <div class="accordion-item">
+              <h2 class="accordion-header" id="renderConfigHeading">
+                <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsedRenderConfig" aria-expanded="false" aria-controls="collapsedRenderConfig">
+                  {% trans "Context Data" %}
+                </button>
+              </h2>
+              <div id="collapsedRenderConfig" class="accordion-collapse collapse" aria-labelledby="renderConfigHeading" data-bs-parent="#renderConfig">
+                <div class="accordion-body">
+                  <pre class="card-body">{{ context_data|pprint }}</pre>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col">
+      <div class="card">
+        <div class="card-header">
+          <div class="float-end">
+            <a href="?export=True" class="btn btn-sm btn-primary" role="button">
+              <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
+            </a>
+          </div>
+          <h5>{% trans "Rendered Config" %}</h5>
+        </div>
+        {% if config_template %}
+          <pre class="card-body">{{ rendered_config }}</pre>
+        {% else %}
+          <div class="card-body text-muted">{% trans "No configuration template found" %}</div>
+        {% endif %}
+      </div>
+    </div>
+  </div>
+{% endblock %}

+ 4 - 1
netbox/virtualization/api/serializers.py

@@ -5,6 +5,7 @@ from dcim.api.nested_serializers import (
     NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
     NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
 )
 )
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
+from extras.api.nested_serializers import NestedConfigTemplateSerializer
 from ipam.api.nested_serializers import (
 from ipam.api.nested_serializers import (
     NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer,
     NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer,
 )
 )
@@ -79,6 +80,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip6 = 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
     # Counter fields
     interface_count = serializers.IntegerField(read_only=True)
     interface_count = serializers.IntegerField(read_only=True)
@@ -88,7 +90,8 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
             'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
             'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
             '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 = []
         validators = []
 
 

+ 5 - 0
netbox/virtualization/filtersets.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext as _
 from dcim.filtersets import CommonInterfaceFilterSet
 from dcim.filtersets import CommonInterfaceFilterSet
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
+from extras.models import ConfigTemplate
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
 from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@@ -228,6 +229,10 @@ class VirtualMachineFilterSet(
         method='_has_primary_ip',
         method='_has_primary_ip',
         label=_('Has a primary IP'),
         label=_('Has a primary IP'),
     )
     )
+    config_template_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ConfigTemplate.objects.all(),
+        label=_('Config template (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine

+ 7 - 1
netbox/virtualization/forms/bulk_edit.py

@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
+from extras.models import ConfigTemplate
 from ipam.models import VLAN, VLANGroup, VRF
 from ipam.models import VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -174,12 +175,17 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
         max_length=200,
         max_length=200,
         required=False
         required=False
     )
     )
+    config_template = DynamicModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False
+    )
     comments = CommentField()
     comments = CommentField()
 
 
     model = VirtualMachine
     model = VirtualMachine
     fieldsets = (
     fieldsets = (
         (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description')),
         (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform', 'description')),
-        (_('Resources'), ('vcpus', 'memory', 'disk'))
+        (_('Resources'), ('vcpus', 'memory', 'disk')),
+        ('Configuration', ('config_template',)),
     )
     )
     nullable_fields = (
     nullable_fields = (
         'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments',
         'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments',

+ 9 - 1
netbox/virtualization/forms/bulk_import.py

@@ -2,6 +2,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.models import Device, DeviceRole, Platform, Site
 from dcim.models import Device, DeviceRole, Platform, Site
+from extras.models import ConfigTemplate
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -123,12 +124,19 @@ class VirtualMachineImportForm(NetBoxModelImportForm):
         to_field_name='name',
         to_field_name='name',
         help_text=_('Assigned platform')
         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:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         fields = (
             'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
             'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
-            'description', 'comments', 'tags',
+            'description', 'config_template', 'comments', 'tags',
         )
         )
 
 
 
 

+ 7 - 1
netbox/virtualization/forms/filtersets.py

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.forms import LocalConfigContextFilterForm
 from extras.forms import LocalConfigContextFilterForm
+from extras.models import ConfigTemplate
 from ipam.models import L2VPN, VRF
 from ipam.models import L2VPN, VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
@@ -93,7 +94,7 @@ class VirtualMachineFilterForm(
         (None, ('q', 'filter_id', 'tag')),
         (None, ('q', 'filter_id', 'tag')),
         (_('Cluster'), ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
         (_('Cluster'), ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
         (_('Location'), ('region_id', 'site_group_id', 'site_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')),
         (_('Tenant'), ('tenant_group_id', 'tenant_id')),
         (_('Contacts'), ('contact', 'contact_role', 'contact_group')),
         (_('Contacts'), ('contact', 'contact_role', 'contact_group')),
     )
     )
@@ -170,6 +171,11 @@ class VirtualMachineFilterForm(
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
+    config_template_id = DynamicModelMultipleChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False,
+        label=_('Config template')
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 

+ 8 - 1
netbox/virtualization/forms/model_forms.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.common import InterfaceCommonForm
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 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 ipam.models import IPAddress, VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
@@ -205,13 +206,18 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
         required=False,
         required=False,
         label=''
         label=''
     )
     )
+    config_template = DynamicModelChoiceField(
+        queryset=ConfigTemplate.objects.all(),
+        required=False,
+        label=_('Config template')
+    )
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     fieldsets = (
         (_('Virtual Machine'), ('name', 'role', 'status', 'description', 'tags')),
         (_('Virtual Machine'), ('name', 'role', 'status', 'description', 'tags')),
         (_('Site/Cluster'), ('site', 'cluster', 'device')),
         (_('Site/Cluster'), ('site', 'cluster', 'device')),
         (_('Tenancy'), ('tenant_group', 'tenant')),
         (_('Tenancy'), ('tenant_group', 'tenant')),
-        (_('Management'), ('platform', 'primary_ip4', 'primary_ip6')),
+        (_('Management'), ('platform', 'primary_ip4', 'primary_ip6', 'config_template')),
         (_('Resources'), ('vcpus', 'memory', 'disk')),
         (_('Resources'), ('vcpus', 'memory', 'disk')),
         (_('Config Context'), ('local_context_data',)),
         (_('Config Context'), ('local_context_data',)),
     )
     )
@@ -221,6 +227,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
         fields = [
         fields = [
             'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
             'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
             'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags', 'local_context_data',
             'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'tags', 'local_context_data',
+            'config_template',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):

+ 20 - 0
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'),
+        ),
+    ]

+ 2 - 1
netbox/virtualization/models/virtualmachines.py

@@ -8,6 +8,7 @@ from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import BaseInterface
 from dcim.models import BaseInterface
+from dcim.models.mixins import RenderConfigMixin
 from extras.models import ConfigContextModel
 from extras.models import ConfigContextModel
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
 from netbox.config import get_config
 from netbox.config import get_config
@@ -25,7 +26,7 @@ __all__ = (
 )
 )
 
 
 
 
-class VirtualMachine(ContactsMixin, PrimaryModel, ConfigContextModel):
+class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel):
     """
     """
     A virtual machine which runs inside a Cluster.
     A virtual machine which runs inside a Cluster.
     """
     """

+ 5 - 1
netbox/virtualization/tables/virtualmachines.py

@@ -84,13 +84,17 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
     interface_count = tables.Column(
     interface_count = tables.Column(
         verbose_name=_('Interfaces')
         verbose_name=_('Interfaces')
     )
     )
+    config_template = tables.Column(
+        verbose_name=_('Config Template'),
+        linkify=True
+    )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         fields = (
             'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform',
             'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform',
             'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments',
             '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 = (
         default_columns = (
             'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
             'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',

+ 52 - 0
netbox/virtualization/views.py

@@ -1,11 +1,14 @@
+import traceback
 from collections import defaultdict
 from collections import defaultdict
 
 
 from django.contrib import messages
 from django.contrib import messages
 from django.db import transaction
 from django.db import transaction
 from django.db.models import Prefetch, Sum
 from django.db.models import Prefetch, Sum
+from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
+from jinja2.exceptions import TemplateError
 
 
 from dcim.filtersets import DeviceFilterSet
 from dcim.filtersets import DeviceFilterSet
 from dcim.models import Device
 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')
 @register_model_view(VirtualMachine, 'edit')
 class VirtualMachineEditView(generic.ObjectEditView):
 class VirtualMachineEditView(generic.ObjectEditView):
     queryset = VirtualMachine.objects.all()
     queryset = VirtualMachine.objects.all()