Explorar o 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 %!s(int64=2) %!d(string=hai) anos
pai
achega
752e26c7de

+ 1 - 1
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',

+ 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.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 @@ class Device(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, ConfigContextMo
     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.

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

@@ -4,6 +4,11 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from utilities.utils import to_grams
 
+__all__ = (
+    'RenderConfigMixin',
+    'WeightMixin',
+)
+
 
 class WeightMixin(models.Model):
     weight = models.DecimalField(
@@ -44,3 +49,27 @@ class WeightMixin(models.Model):
         # 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

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

@@ -43,6 +43,10 @@
                             {{ object.tenant|linkify|placeholder }}
                         </td>
                     </tr>
+                    <tr>
+                        <th scope="row">{% trans "Config Template" %}</th>
+                        <td>{{ object.config_template|linkify|placeholder }}</td>
+                    </tr>
                     <tr>
                         <th scope="row">{% trans "Primary IPv4" %}</th>
                         <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,
 )
 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 VirtualMachineSerializer(NetBoxModelSerializer):
         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 = []
 

+ 5 - 0
netbox/virtualization/filtersets.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext as _
 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

+ 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.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',

+ 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.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',
         )
 
 

+ 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 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)
 
 

+ 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.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 VirtualMachineForm(TenancyForm, NetBoxModelForm):
         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):

+ 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 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 @@ __all__ = (
 )
 
 
-class VirtualMachine(ContactsMixin, PrimaryModel, ConfigContextModel):
+class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel):
     """
     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(
         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',

+ 52 - 0
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()