diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 6a6b7f78a..cd75d141b 100644 --- a/docs/release-notes/version-2.11.md +++ b/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 diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html index 88f7da1de..4d4f894a8 100644 --- a/netbox/templates/virtualization/virtualmachine/base.html +++ b/netbox/templates/virtualization/virtualmachine/base.html @@ -12,7 +12,7 @@ {% block buttons %} {% if perms.virtualization.add_vminterface %} - + Add Interfaces {% endif %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index e574e926e..7141dcff1 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -39,6 +39,16 @@ {% endif %} + + Parent + + {% if object.parent %} + {{ object.parent }} + {% else %} + None + {% endif %} + + Description {{ object.description|placeholder }} @@ -91,9 +101,14 @@ {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} -
-
- {% plugin_full_width_page object %} -
+
+
+ {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
+
+
+
+ {% plugin_full_width_page object %} +
+
{% endblock %} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index c0ad6e98c..f3ab4f9c2 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/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 %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 0afa8f796..a1428f0cd 100644 --- a/netbox/virtualization/api/serializers.py +++ b/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): diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index ea2b33e4f..5f67a7c74 100644 --- a/netbox/virtualization/api/views.py +++ b/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 diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index be9b70749..d710bcbe2 100644 --- a/netbox/virtualization/filters.py +++ b/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', ) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 014b73542..a5c671287 100644 --- a/netbox/virtualization/forms.py +++ b/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) diff --git a/netbox/virtualization/migrations/0022_vminterface_parent.py b/netbox/virtualization/migrations/0022_vminterface_parent.py new file mode 100644 index 000000000..d1249985f --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index a34d09662..ee8b3e62b 100644 --- a/netbox/virtualization/models.py +++ b/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({ diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index c30f14165..65bd2b5d1 100644 --- a/netbox/virtualization/tables.py +++ b/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): diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index e822d8763..c11423663 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/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) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 6bf9eb6dd..6b316de0e 100644 --- a/netbox/virtualization/views.py +++ b/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, }