Răsfoiți Sursa

Closes #6121: Extend parent interface assignment to VM interfaces

jeremystretch 4 ani în urmă
părinte
comite
a3721a94ce

+ 4 - 1
docs/release-notes/version-2.11.md

@@ -6,6 +6,7 @@
 
 * [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views
 * [#6109](https://github.com/netbox-community/netbox/issues/6109) - Add device counts to locations table
+* [#6121](https://github.com/netbox-community/netbox/issues/6121) - Extend parent interface assignment to VM interfaces
 * [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page
 
 ### Bug Fixes (from Beta)
@@ -44,7 +45,7 @@ NetBox now supports journaling for all primary objects. The journal is a collect
 
 #### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519))
 
-Virtual interfaces can now be assigned to a "parent" physical interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0.
+Virtual device and VM interfaces can now be assigned to a "parent" interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0.
 
 #### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451))
 
@@ -186,3 +187,5 @@ A new provider network model has been introduced to represent the boundary of a
   * Dropped the `site` foreign key field
 * virtualization.VirtualMachine
   * `vcpus` has been changed from an integer to a decimal value
+* virtualization.VMInterface
+  * Added the `parent` field

+ 1 - 1
netbox/templates/virtualization/virtualmachine/base.html

@@ -12,7 +12,7 @@
 
 {% block buttons %}
   {% if perms.virtualization.add_vminterface %}
-    <a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary">
+    <a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-primary">
       <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Interfaces
     </a>
   {% endif %}

+ 19 - 4
netbox/templates/virtualization/vminterface.html

@@ -39,6 +39,16 @@
                         {% endif %}
                     </td>
                 </tr>
+                <tr>
+                    <td>Parent</td>
+                    <td>
+                        {% if object.parent %}
+                            <a href="{{ object.parent.get_absolute_url }}">{{ object.parent }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                     <td>Description</td>
                     <td>{{ object.description|placeholder }} </td>
@@ -91,9 +101,14 @@
         {% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
     </div>
 </div>
-    <div class="row">
-        <div class="col-md-12">
-            {% plugin_full_width_page object %}
-        </div>
+<div class="row">
+    <div class="col-md-12">
+        {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
     </div>
+</div>
+<div class="row">
+    <div class="col-md-12">
+        {% plugin_full_width_page object %}
+    </div>
+</div>
 {% endblock %}

+ 1 - 0
netbox/templates/virtualization/vminterface_edit.html

@@ -17,6 +17,7 @@
             {% endif %}
             {% render_field form.name %}
             {% render_field form.enabled %}
+            {% render_field form.parent %}
             {% render_field form.mac_address %}
             {% render_field form.mtu %}
             {% render_field form.description %}

+ 3 - 2
netbox/virtualization/api/serializers.py

@@ -106,6 +106,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 class VMInterfaceSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
     virtual_machine = NestedVirtualMachineSerializer()
+    parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
@@ -118,8 +119,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer):
     class Meta:
         model = VMInterface
         fields = [
-            'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
-            'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description',
+            'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
 
     def validate(self, data):

+ 1 - 1
netbox/virtualization/api/views.py

@@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet)
 
 class VMInterfaceViewSet(ModelViewSet):
     queryset = VMInterface.objects.prefetch_related(
-        'virtual_machine', 'tags', 'tagged_vlans'
+        'virtual_machine', 'parent', 'tags', 'tagged_vlans'
     )
     serializer_class = serializers.VMInterfaceSerializer
     filterset_class = filters.VMInterfaceFilterSet

+ 5 - 0
netbox/virtualization/filters.py

@@ -264,6 +264,11 @@ class VMInterfaceFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpda
         to_field_name='name',
         label='Virtual machine',
     )
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent',
+        queryset=VMInterface.objects.all(),
+        label='Parent interface (ID)',
+    )
     mac_address = MultiValueMACAddressFilter(
         label='MAC address',
     )

+ 33 - 6
netbox/virtualization/forms.py

@@ -603,6 +603,11 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
 #
 
 class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
+    parent = DynamicModelChoiceField(
+        queryset=VMInterface.objects.all(),
+        required=False,
+        label='Parent interface'
+    )
     untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         required=False,
@@ -621,8 +626,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
     class Meta:
         model = VMInterface
         fields = [
-            'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan',
-            'tagged_vlans',
+            'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags',
+            'untagged_vlan', 'tagged_vlans',
         ]
         widgets = {
             'virtual_machine': forms.HiddenInput(),
@@ -637,9 +642,12 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+
+        # Restrict parent interface assignment by VM
+        self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
 
         # Limit VLAN choices by virtual machine
-        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
         self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
         self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
 
@@ -655,6 +663,14 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
         required=False,
         initial=True
     )
+    parent = DynamicModelChoiceField(
+        queryset=VMInterface.objects.all(),
+        required=False,
+        display_field='display_name',
+        query_params={
+            'virtualmachine_id': 'virtual_machine',
+        }
+    )
     mtu = forms.IntegerField(
         required=False,
         min_value=INTERFACE_MTU_MIN,
@@ -689,9 +705,12 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+
+        # Restrict parent interface assignment by VM
+        self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
 
         # Limit VLAN choices by virtual machine
-        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
         self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
         self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
 
@@ -730,6 +749,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         disabled=True,
         widget=forms.HiddenInput()
     )
+    parent = DynamicModelChoiceField(
+        queryset=VMInterface.objects.all(),
+        required=False,
+        display_field='display_name'
+    )
     enabled = forms.NullBooleanField(
         required=False,
         widget=BulkEditNullBooleanSelect()
@@ -760,14 +784,17 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
 
     class Meta:
         nullable_fields = [
-            'mtu', 'description',
+            'parent', 'mtu', 'description',
         ]
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+
+        # Restrict parent interface assignment by VM
+        self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
 
         # Limit VLAN choices by virtual machine
-        vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
         self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
         self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
 

+ 17 - 0
netbox/virtualization/migrations/0022_vminterface_parent.py

@@ -0,0 +1,17 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0021_virtualmachine_vcpus_decimal'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vminterface',
+            name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='virtualization.vminterface'),
+        ),
+    ]

+ 16 - 0
netbox/virtualization/models.py

@@ -395,6 +395,14 @@ class VMInterface(PrimaryModel, BaseInterface):
         max_length=200,
         blank=True
     )
+    parent = models.ForeignKey(
+        to='self',
+        on_delete=models.SET_NULL,
+        related_name='child_interfaces',
+        null=True,
+        blank=True,
+        verbose_name='Parent interface'
+    )
     untagged_vlan = models.ForeignKey(
         to='ipam.VLAN',
         on_delete=models.SET_NULL,
@@ -438,6 +446,7 @@ class VMInterface(PrimaryModel, BaseInterface):
             self.virtual_machine.name,
             self.name,
             self.enabled,
+            self.parent.name if self.parent else None,
             self.mac_address,
             self.mtu,
             self.description,
@@ -447,6 +456,13 @@ class VMInterface(PrimaryModel, BaseInterface):
     def clean(self):
         super().clean()
 
+        # An interface's parent must belong to the same virtual machine
+        if self.parent and self.parent.virtual_machine != self.virtual_machine:
+            raise ValidationError({
+                'parent': f"The selected parent interface ({self.parent}) belongs to a different virtual machine "
+                          f"({self.parent.virtual_machine})."
+            })
+
         # Validate untagged VLAN
         if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
             raise ValidationError({

+ 5 - 2
netbox/virtualization/tables.py

@@ -170,6 +170,9 @@ class VMInterfaceTable(BaseInterfaceTable):
     name = tables.Column(
         linkify=True
     )
+    parent = tables.Column(
+        linkify=True
+    )
     tags = TagColumn(
         url_name='virtualization:vminterface_list'
     )
@@ -177,10 +180,10 @@ class VMInterfaceTable(BaseInterfaceTable):
     class Meta(BaseTable.Meta):
         model = VMInterface
         fields = (
-            'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
+            'pk', 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags',
             'ip_addresses', 'untagged_vlan', 'tagged_vlans',
         )
-        default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')
+        default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'parent', 'description')
 
 
 class VirtualMachineVMInterfaceTable(VMInterfaceTable):

+ 14 - 1
netbox/virtualization/tests/test_filters.py

@@ -453,12 +453,25 @@ class VMInterfaceTestCase(TestCase):
         params = {'name': ['Interface 1', 'Interface 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_assigned_to_interface(self):
+    def test_enabled(self):
         params = {'enabled': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'enabled': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+    def test_parent(self):
+        # Create child interfaces
+        parent_interface = VMInterface.objects.first()
+        child_interfaces = (
+            VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 1', parent=parent_interface),
+            VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 2', parent=parent_interface),
+            VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 3', parent=parent_interface),
+        )
+        VMInterface.objects.bulk_create(child_interfaces)
+
+        params = {'parent_id': [parent_interface.pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
     def test_mtu(self):
         params = {'mtu': [100, 200]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 9 - 0
netbox/virtualization/views.py

@@ -421,6 +421,14 @@ class VMInterfaceView(generic.ObjectView):
             orderable=False
         )
 
+        # Get child interfaces
+        child_interfaces = VMInterface.objects.restrict(request.user, 'view').filter(parent=instance)
+        child_interfaces_tables = tables.VMInterfaceTable(
+            child_interfaces,
+            orderable=False
+        )
+        child_interfaces_tables.columns.hide('virtual_machine')
+
         # Get assigned VLANs and annotate whether each is tagged or untagged
         vlans = []
         if instance.untagged_vlan is not None:
@@ -437,6 +445,7 @@ class VMInterfaceView(generic.ObjectView):
 
         return {
             'ipaddress_table': ipaddress_table,
+            'child_interfaces_table': child_interfaces_tables,
             'vlan_table': vlan_table,
         }