Closes #6121: Extend parent interface assignment to VM interfaces

This commit is contained in:
jeremystretch 2021-04-09 10:53:05 -04:00
parent 7439faad34
commit a3721a94ce
13 changed files with 128 additions and 18 deletions

View File

@ -6,6 +6,7 @@
* [#6097](https://github.com/netbox-community/netbox/issues/6097) - Redirect old slug-based object views * [#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 * [#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 * [#6125](https://github.com/netbox-community/netbox/issues/6125) - Add locations count to home page
### Bug Fixes (from Beta) ### 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)) #### 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)) #### 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 * Dropped the `site` foreign key field
* virtualization.VirtualMachine * virtualization.VirtualMachine
* `vcpus` has been changed from an integer to a decimal value * `vcpus` has been changed from an integer to a decimal value
* virtualization.VMInterface
* Added the `parent` field

View File

@ -12,7 +12,7 @@
{% block buttons %} {% block buttons %}
{% if perms.virtualization.add_vminterface %} {% 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 <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Interfaces
</a> </a>
{% endif %} {% endif %}

View File

@ -39,6 +39,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </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> <tr>
<td>Description</td> <td>Description</td>
<td>{{ object.description|placeholder }} </td> <td>{{ object.description|placeholder }} </td>
@ -91,9 +101,14 @@
{% include 'panel_table.html' with table=vlan_table heading="VLANs" %} {% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% plugin_full_width_page object %} {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
</div>
</div> </div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),
),
]

View File

@ -395,6 +395,14 @@ class VMInterface(PrimaryModel, BaseInterface):
max_length=200, max_length=200,
blank=True 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( untagged_vlan = models.ForeignKey(
to='ipam.VLAN', to='ipam.VLAN',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -438,6 +446,7 @@ class VMInterface(PrimaryModel, BaseInterface):
self.virtual_machine.name, self.virtual_machine.name,
self.name, self.name,
self.enabled, self.enabled,
self.parent.name if self.parent else None,
self.mac_address, self.mac_address,
self.mtu, self.mtu,
self.description, self.description,
@ -447,6 +456,13 @@ class VMInterface(PrimaryModel, BaseInterface):
def clean(self): def clean(self):
super().clean() 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 # Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
raise ValidationError({ raise ValidationError({

View File

@ -170,6 +170,9 @@ class VMInterfaceTable(BaseInterfaceTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
parent = tables.Column(
linkify=True
)
tags = TagColumn( tags = TagColumn(
url_name='virtualization:vminterface_list' url_name='virtualization:vminterface_list'
) )
@ -177,10 +180,10 @@ class VMInterfaceTable(BaseInterfaceTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VMInterface model = VMInterface
fields = ( 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', '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): class VirtualMachineVMInterfaceTable(VMInterfaceTable):

View File

@ -453,12 +453,25 @@ class VMInterfaceTestCase(TestCase):
params = {'name': ['Interface 1', 'Interface 2']} params = {'name': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_assigned_to_interface(self): def test_enabled(self):
params = {'enabled': 'true'} params = {'enabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': 'false'} params = {'enabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) 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): def test_mtu(self):
params = {'mtu': [100, 200]} params = {'mtu': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -421,6 +421,14 @@ class VMInterfaceView(generic.ObjectView):
orderable=False 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 # Get assigned VLANs and annotate whether each is tagged or untagged
vlans = [] vlans = []
if instance.untagged_vlan is not None: if instance.untagged_vlan is not None:
@ -437,6 +445,7 @@ class VMInterfaceView(generic.ObjectView):
return { return {
'ipaddress_table': ipaddress_table, 'ipaddress_table': ipaddress_table,
'child_interfaces_table': child_interfaces_tables,
'vlan_table': vlan_table, 'vlan_table': vlan_table,
} }