diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 88142bf78..f586f43bb 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -7,6 +7,8 @@ * The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination. +### New Features + #### Contacts ([#1344](https://github.com/netbox-community/netbox/issues/1344)) A set of new models for tracking contact information has been introduced within the tenancy app. Users may now create individual contact objects to be associated with various models within NetBox. Each contact has a name, title, email address, etc. Contacts can be arranged in hierarchical groups for ease of management. @@ -26,6 +28,12 @@ Both types of connection include SSID and authentication attributes. Additionall * Channel - A predefined channel within a standardized band * Channel frequency & width - Customizable channel attributes (e.g. for licensed bands) +#### Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346)) + +A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. + +Multiple interfaces can be bridged to a single virtual interface to effect a bridge group. Alternatively, two physical interfaces can be bridged to one another, to effect an internal cross-connect. + ### Enhancements * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces @@ -73,6 +81,9 @@ Both types of connection include SSID and authentication attributes. Additionall * dcim.DeviceType * Added `airflow` field * dcim.Interface + * Added `bridge` field * Added `wwn` field * dcim.Location * Added `tenant` field +* virtualization.VMInterface + * Added `bridge` field diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index bc5e9b54e..1f2897a7f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -605,6 +605,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) parent = NestedInterfaceSerializer(required=False, allow_null=True) + bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) @@ -622,8 +623,8 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', + 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9cbdf7d5d..921ee3a99 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -544,7 +544,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'parent', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags' + 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e81bd5e43..c049025b7 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -975,6 +975,11 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT queryset=Interface.objects.all(), label='Parent interface (ID)', ) + bridge_id = django_filters.ModelMultipleChoiceFilter( + field_name='bridge', + queryset=Interface.objects.all(), + label='Bridged interface (ID)', + ) lag_id = django_filters.ModelMultipleChoiceFilter( field_name='lag', queryset=Interface.objects.all(), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9abdcb8ff..b1dce2281 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -939,8 +939,8 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', - 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', + 'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', + 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', ]), BootstrapMixin, AddRemoveTagsForm, @@ -964,6 +964,10 @@ class InterfaceBulkEditForm( queryset=Interface.objects.all(), required=False ) + bridge = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False + ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -991,7 +995,7 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', + 'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', ] @@ -1000,8 +1004,9 @@ class InterfaceBulkEditForm( if 'device' in self.initial: device = Device.objects.filter(pk=self.initial['device']).first() - # Restrict parent/LAG interface assignment by device + # Restrict parent/bridge/LAG interface assignment by device self.fields['parent'].widget.add_query_param('device_id', device.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.pk) self.fields['lag'].widget.add_query_param('device_id', device.pk) # Limit VLAN choices by device @@ -1029,6 +1034,8 @@ class InterfaceBulkEditForm( self.fields['parent'].choices = () self.fields['parent'].widget.attrs['disabled'] = True + self.fields['bridge'].choices = () + self.fields['bridge'].widget.attrs['disabled'] = True self.fields['lag'].choices = () self.fields['lag'].widget.attrs['disabled'] = True diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index f39e3cd7f..18bdb3d3f 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -570,6 +570,12 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Parent interface' ) + bridge = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Bridged interface' + ) lag = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -594,39 +600,11 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', - 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', + 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit LAG choices to interfaces belonging to this device (or virtual chassis) - device = None - if self.is_bound and 'device' in self.data: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - pass - if device and device.virtual_chassis: - self.fields['lag'].queryset = Interface.objects.filter( - Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis), - type=InterfaceTypeChoices.TYPE_LAG - ) - self.fields['parent'].queryset = Interface.objects.filter( - Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis) - ) - elif device: - self.fields['lag'].queryset = Interface.objects.filter( - device=device, - type=InterfaceTypeChoices.TYPE_LAG - ) - self.fields['parent'].queryset = Interface.objects.filter(device=device) - else: - self.fields['lag'].queryset = Interface.objects.none() - self.fields['parent'].queryset = Interface.objects.none() - def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data if 'enabled' not in self.data: diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index e395c67d2..a2b5e7dba 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1093,6 +1093,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): required=False, label='Parent interface' ) + bridge = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Bridged interface' + ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1143,8 +1148,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', + 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { @@ -1168,13 +1173,14 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device - # Restrict parent/LAG interface assignment by device/VC + # Restrict parent/bridge/LAG interface assignment by device/VC self.fields['parent'].widget.add_query_param('device_id', device.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.pk) + self.fields['lag'].widget.add_query_param('device_id', device.pk) if device.virtual_chassis and device.virtual_chassis.master: - # Get available LAG interfaces by VirtualChassis master + self.fields['parent'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - else: - self.fields['lag'].widget.add_query_param('device_id', device.pk) # Limit VLAN choices by device self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 547fe7e68..3beb42c8d 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -446,6 +446,13 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): 'device_id': '$device', } ) + bridge = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } + ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -497,7 +504,7 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False ) field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', + 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) diff --git a/netbox/dcim/migrations/0134_interface_wwn.py b/netbox/dcim/migrations/0134_interface_wwn.py deleted file mode 100644 index 0739edbbb..000000000 --- a/netbox/dcim/migrations/0134_interface_wwn.py +++ /dev/null @@ -1,17 +0,0 @@ -import dcim.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0133_port_colors'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='wwn', - field=dcim.fields.WWNField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0134_interface_wwn_bridge.py b/netbox/dcim/migrations/0134_interface_wwn_bridge.py new file mode 100644 index 000000000..a900ae6be --- /dev/null +++ b/netbox/dcim/migrations/0134_interface_wwn_bridge.py @@ -0,0 +1,23 @@ +import dcim.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0133_port_colors'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='wwn', + field=dcim.fields.WWNField(blank=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='bridge', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interface'), + ), + ] diff --git a/netbox/dcim/migrations/0135_tenancy_extensions.py b/netbox/dcim/migrations/0135_tenancy_extensions.py index 673b5027f..96d765eea 100644 --- a/netbox/dcim/migrations/0135_tenancy_extensions.py +++ b/netbox/dcim/migrations/0135_tenancy_extensions.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ ('tenancy', '0002_tenant_ordering'), - ('dcim', '0134_interface_wwn'), + ('dcim', '0134_interface_wwn_bridge'), ] operations = [ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index c2a37fcae..2a6adfa0c 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -462,6 +462,22 @@ class BaseInterface(models.Model): choices=InterfaceModeChoices, blank=True ) + parent = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='child_interfaces', + null=True, + blank=True, + verbose_name='Parent interface' + ) + bridge = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='bridge_interfaces', + null=True, + blank=True, + verbose_name='Bridge interface' + ) class Meta: abstract = True @@ -495,14 +511,6 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): max_length=100, blank=True ) - parent = models.ForeignKey( - to='self', - on_delete=models.SET_NULL, - related_name='child_interfaces', - null=True, - blank=True, - verbose_name='Parent interface' - ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, @@ -586,7 +594,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): related_query_name='interface' ) - clone_fields = ['device', 'parent', 'lag', 'type', 'mgmt_only'] + clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] class Meta: ordering = ('device', CollateAsChar('_name')) @@ -610,6 +618,16 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." }) + # Parent validation + + # An interface cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({'parent': "An interface cannot be its own parent."}) + + # A physical interface cannot have a parent interface + if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: + raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) + # An interface's parent must belong to the same device or virtual chassis if self.parent and self.parent.device != self.device: if self.device.virtual_chassis is None: @@ -623,13 +641,34 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): f"is not part of virtual chassis {self.device.virtual_chassis}." }) - # An interface cannot be its own parent - if self.pk and self.parent_id == self.pk: - raise ValidationError({'parent': "An interface cannot be its own parent."}) + # Bridge validation - # A physical interface cannot have a parent interface - if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: - raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) + # An interface cannot be bridged to itself + if self.pk and self.bridge_id == self.pk: + raise ValidationError({'bridge': "An interface cannot be bridged to itself."}) + + # A bridged interface belong to the same device or virtual chassis + if self.bridge and self.bridge.device != self.device: + if self.device.virtual_chassis is None: + raise ValidationError({ + 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different device " + f"({self.bridge.device})." + }) + elif self.bridge.device.virtual_chassis != self.device.virtual_chassis: + raise ValidationError({ + 'bridge': f"The selected bridge interface ({self.bridge}) belongs to {self.bridge.device}, which " + f"is not part of virtual chassis {self.device.virtual_chassis}." + }) + + # LAG validation + + # A virtual interface cannot have a parent LAG + if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None: + raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."}) + + # A LAG interface cannot be its own parent + if self.pk and self.lag_id == self.pk: + raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) # An interface's LAG must belong to the same device or virtual chassis if self.lag and self.lag.device != self.device: @@ -643,13 +682,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): f"of virtual chassis {self.device.virtual_chassis}." }) - # A virtual interface cannot have a parent LAG - if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None: - raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."}) - - # A LAG interface cannot be its own parent - if self.pk and self.lag_id == self.pk: - raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) + # Wireless validation # RF role & channel may only be set for wireless interfaces if self.rf_role and not self.is_wireless: @@ -679,11 +712,13 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): elif self.rf_channel: self.rf_channel_width = get_channel_attr(self.rf_channel, 'width') + # VLAN validation + # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: raise ValidationError({ - 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " - "device, or it must be global".format(self.untagged_vlan) + 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the " + f"interface's parent device, or it must be global." }) @property diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 06c594f6b..8ea27b8a6 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -521,8 +521,10 @@ class DeviceInterfaceTable(InterfaceTable): attrs={'td': {'class': 'text-nowrap'}} ) parent = tables.Column( - linkify=True, - verbose_name='Parent' + linkify=True + ) + bridge = tables.Column( + linkify=True ) lag = tables.Column( linkify=True, @@ -537,10 +539,10 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', - 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', - 'tagged_vlans', 'actions', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', + 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', + 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', + 'untagged_vlan', 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index e5977b760..b3f182ce7 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1206,6 +1206,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'name': 'Interface 5', 'type': '1000base-t', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'bridge': interfaces[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, @@ -1214,7 +1215,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'name': 'Interface 6', 'type': 'virtual', 'mode': InterfaceModeChoices.MODE_TAGGED, - 'parent': interfaces[0].pk, + 'parent': interfaces[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f66ceb855..51cfafaf2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2125,6 +2125,19 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'parent_id': [parent_interface.pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_bridge(self): + # Create bridged interfaces + bridge_interface = Interface.objects.first() + bridged_interfaces = ( + Interface(device=bridge_interface.device, name='Bridged 1', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=bridge_interface.device, name='Bridged 2', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=bridge_interface.device, name='Bridged 3', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(bridged_interfaces) + + params = {'bridge_id': [bridge_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_lag(self): # Create LAG members device = Device.objects.first() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index c08eb6e8a..92757f28d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1581,6 +1581,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): Interface(device=device, name='Interface 2'), Interface(device=device, name='Interface 3'), Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG), + Interface(device=device, name='_BRIDGE', type=InterfaceTypeChoices.TYPE_VIRTUAL), # Must be ordered last ) Interface.objects.bulk_create(interfaces) @@ -1596,10 +1597,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.form_data = { 'device': device.pk, - 'virtual_machine': None, 'name': 'Interface X', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, + 'bridge': interfaces[4].pk, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), @@ -1617,6 +1618,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'name_pattern': 'Interface [4-6]', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, + 'bridge': interfaces[4].pk, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 730720b42..eb47f5655 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -69,6 +69,16 @@ {% endif %} + + Bridge + + {% if object.bridge %} + {{ object.bridge }} + {% else %} + None + {% endif %} + + LAG diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index aec88d25a..2afa0a7b6 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -18,6 +18,7 @@ {% render_field form.label %} {% render_field form.type %} {% render_field form.parent %} + {% render_field form.bridge %} {% render_field form.lag %} {% render_field form.mac_address %} {% render_field form.wwn %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 1678013f2..2646686e8 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -47,6 +47,16 @@ {% endif %} + + Bridge + + {% if object.bridge %} + {{ object.bridge }} + {% else %} + None + {% endif %} + + Description {{ object.description|placeholder }} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index b4d097513..824f2bf24 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -17,6 +17,7 @@ {% render_field form.name %} {% render_field form.enabled %} {% render_field form.parent %} + {% render_field form.bridge %} {% 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 ef8c975d3..6cdc0e09a 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -107,6 +107,7 @@ class VMInterfaceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer() parent = NestedVMInterfaceSerializer(required=False, allow_null=True) + bridge = 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( @@ -120,8 +121,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer): class Meta: model = VMInterface fields = [ - 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', + 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', ] diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index e2aac9b80..dc084a67f 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -264,6 +264,11 @@ class VMInterfaceFilterSet(PrimaryModelFilterSet): queryset=VMInterface.objects.all(), label='Parent interface (ID)', ) + bridge_id = django_filters.ModelMultipleChoiceFilter( + field_name='bridge', + queryset=VMInterface.objects.all(), + label='Bridged interface (ID)', + ) mac_address = MultiValueMACAddressFilter( label='MAC address', ) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index d18d432cd..d6c190904 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -165,6 +165,10 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode queryset=VMInterface.objects.all(), required=False ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False + ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() @@ -195,7 +199,7 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode class Meta: nullable_fields = [ - 'parent', 'mtu', 'description', + 'parent', 'bridge', 'mtu', 'description', ] def __init__(self, *args, **kwargs): @@ -203,8 +207,9 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode if 'virtual_machine' in self.initial: vm_id = self.initial.get('virtual_machine') - # Restrict parent interface assignment by VM + # Restrict parent/bridge interface assignment by VM self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id) # Limit VLAN choices by virtual machine self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) @@ -231,6 +236,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + self.fields['parent'].choices = () + self.fields['parent'].widget.attrs['disabled'] = True + self.fields['bridge'].choices = () + self.fields['bridge'].widget.attrs['disabled'] = True + class VMInterfaceBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField( diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index d01418aa0..bd3279959 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -104,6 +104,18 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm): queryset=VirtualMachine.objects.all(), to_field_name='name' ) + parent = CSVModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + to_field_name='name', + help_text='Parent interface' + ) + bridge = CSVModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + to_field_name='name', + help_text='Bridged interface' + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, @@ -113,7 +125,7 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = VMInterface fields = ( - 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', ) def clean_enabled(self): diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 88ebc9e83..7fa5b0fa6 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -277,6 +277,11 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) required=False, label='Parent interface' ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + label='Bridged interface' + ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -306,8 +311,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) class Meta: model = VMInterface fields = [ - 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags', - 'untagged_vlan', 'tagged_vlans', + 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'tags', 'untagged_vlan', 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), @@ -326,6 +331,7 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) # Restrict parent interface assignment by VM self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id) # Limit VLAN choices by virtual machine self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index b58fb51f8..332334594 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -35,6 +35,13 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo 'virtual_machine_id': '$virtual_machine', } ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine', + } + ) mac_address = forms.CharField( required=False, label='MAC Address' @@ -61,7 +68,7 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo required=False ) field_order = ( - 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode', + 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) diff --git a/netbox/virtualization/migrations/0026_vminterface_bridge.py b/netbox/virtualization/migrations/0026_vminterface_bridge.py new file mode 100644 index 000000000..04909c72c --- /dev/null +++ b/netbox/virtualization/migrations/0026_vminterface_bridge.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-10-21 20:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0025_extend_tag_support'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='bridge', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='virtualization.vminterface'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index bd64f56cf..c614618c0 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -378,14 +378,6 @@ 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, @@ -423,6 +415,12 @@ class VMInterface(PrimaryModel, BaseInterface): def clean(self): super().clean() + # Parent validation + + # An interface cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({'parent': "An interface cannot be its own parent."}) + # An interface's parent must belong to the same virtual machine if self.parent and self.parent.virtual_machine != self.virtual_machine: raise ValidationError({ @@ -430,15 +428,26 @@ class VMInterface(PrimaryModel, BaseInterface): f"({self.parent.virtual_machine})." }) - # An interface cannot be its own parent - if self.pk and self.parent_id == self.pk: - raise ValidationError({'parent': "An interface cannot be its own parent."}) + # Bridge validation + + # An interface cannot be bridged to itself + if self.pk and self.bridge_id == self.pk: + raise ValidationError({'bridge': "An interface cannot be bridged to itself."}) + + # A bridged interface belong to the same virtual machine + if self.bridge and self.bridge.virtual_machine != self.virtual_machine: + raise ValidationError({ + 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different virtual machine " + f"({self.bridge.virtual_machine})." + }) + + # VLAN validation # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: raise ValidationError({ 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the " - f"interface's parent virtual machine, or it must be global" + f"interface's parent virtual machine, or it must be global." }) def to_objectchange(self, action): diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 64b376e1d..56ad88f1f 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -166,9 +166,6 @@ class VMInterfaceTable(BaseInterfaceTable): name = tables.Column( linkify=True ) - parent = tables.Column( - linkify=True - ) tags = TagColumn( url_name='virtualization:vminterface_list' ) @@ -176,13 +173,19 @@ class VMInterfaceTable(BaseInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'name', 'virtual_machine', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'pk', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) - default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'parent', 'description') + default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') class VirtualMachineVMInterfaceTable(VMInterfaceTable): + parent = tables.Column( + linkify=True + ) + bridge = tables.Column( + linkify=True + ) actions = ButtonsColumn( model=VMInterface, buttons=('edit', 'delete'), @@ -192,8 +195,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions', diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 3245fb9bf..4a9b67bf0 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -246,14 +246,15 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'virtual_machine': virtualmachine.pk, 'name': 'Interface 5', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'bridge': interfaces[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, { 'virtual_machine': virtualmachine.pk, 'name': 'Interface 6', - 'parent': interfaces[0].pk, 'mode': InterfaceModeChoices.MODE_TAGGED, + 'parent': interfaces[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 0ca6364a5..a74ccc4d9 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -452,6 +452,19 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'parent_id': [parent_interface.pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_bridge(self): + # Create bridged interfaces + bridge_interface = VMInterface.objects.first() + bridged_interfaces = ( + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 1', bridge=bridge_interface), + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 2', bridge=bridge_interface), + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 3', bridge=bridge_interface), + ) + VMInterface.objects.bulk_create(bridged_interfaces) + + params = {'bridge_id': [bridge_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/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 138b1afae..7dc5660fd 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -248,10 +248,11 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VirtualMachine.objects.bulk_create(virtualmachines) - VMInterface.objects.bulk_create([ + interfaces = VMInterface.objects.bulk_create([ VMInterface(virtual_machine=virtualmachines[0], name='Interface 1'), VMInterface(virtual_machine=virtualmachines[0], name='Interface 2'), VMInterface(virtual_machine=virtualmachines[0], name='Interface 3'), + VMInterface(virtual_machine=virtualmachines[1], name='BRIDGE'), ]) vlans = ( @@ -268,6 +269,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'virtual_machine': virtualmachines[1].pk, 'name': 'Interface X', 'enabled': False, + 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 65000, 'description': 'New description', @@ -281,6 +283,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'virtual_machine': virtualmachines[1].pk, 'name_pattern': 'Interface [4-6]', 'enabled': False, + 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 2000, 'description': 'New description',