Przeglądaj źródła

Closes #21098: Add LAG mode and hash policy fields to Interface

Arthur 22 godzin temu
rodzic
commit
1f1c0f9020

+ 18 - 0
docs/models/dcim/interface.md

@@ -109,6 +109,24 @@ Interfaces can be bridged to other interfaces on a device in two manners: symmet
 
 Physical interfaces may be arranged into link aggregation groups (LAGs, also known as "trunks") and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
 
+### LAG Mode
+
+The link aggregation mode of a LAG interface. (This field may be set only on interfaces of type LAG.) Choices include:
+
+| Mode           | Linux bonding mode |
+|----------------|--------------------|
+| LACP (802.3ad) | `802.3ad`          |
+| Static         | `balance-xor`      |
+| Active-Backup  | `active-backup`    |
+| Balance-RR     | `balance-rr`       |
+| Balance-TLB    | `balance-tlb`      |
+| Balance-ALB    | `balance-alb`      |
+| Broadcast      | `broadcast`        |
+
+### LAG Hash Policy
+
+The transmit hash policy used to distribute traffic across the members of a LAG interface: layer 2, layer 2+3, layer 3+4, encapsulated layer 2+3, or encapsulated layer 3+4. (This field may be set only on interfaces of type LAG.)
+
 ### PoE Mode
 
 The power over Ethernet (PoE) mode for this interface. (This field must be left empty for interfaces which do not support PoE.) Choices include:

+ 10 - 8
netbox/dcim/api/serializers_/device_components.py

@@ -314,6 +314,8 @@ class InterfaceSerializer(
     rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
     poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
     poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
+    lag_mode = ChoiceField(choices=InterfaceLAGModeChoices, required=False, allow_blank=True)
+    lag_hash_policy = ChoiceField(choices=InterfaceLAGHashPolicyChoices, required=False, allow_blank=True)
     untagged_vlan = VLANSerializer(nested=True, required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
@@ -356,14 +358,14 @@ class InterfaceSerializer(
         model = Interface
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'channels',
-            'channel_id', 'enabled',
-            'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
-            'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
-            'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
-            'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end',
-            'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination',
-            'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
-            'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
+            'channel_id', 'enabled', 'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address',
+            'primary_mac_address', 'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode',
+            'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'lag_mode', 'lag_hash_policy', 'rf_channel_frequency',
+            'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
+            'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans',
+            'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
+            'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
+            'count_ipaddresses', 'count_fhrp_groups', '_occupied',
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 

+ 38 - 0
netbox/dcim/choices.py

@@ -1621,6 +1621,44 @@ class InterfaceModeChoices(ChoiceSet):
     )
 
 
+class InterfaceLAGModeChoices(ChoiceSet):
+
+    MODE_LACP = 'lacp'
+    MODE_STATIC = 'static'
+    MODE_ACTIVE_BACKUP = 'active-backup'
+    MODE_BALANCE_RR = 'balance-rr'
+    MODE_BALANCE_TLB = 'balance-tlb'
+    MODE_BALANCE_ALB = 'balance-alb'
+    MODE_BROADCAST = 'broadcast'
+
+    CHOICES = (
+        Choice(MODE_LACP, _('LACP (802.3ad)'), description=_('Dynamic link aggregation negotiated via LACP')),
+        Choice(MODE_STATIC, _('Static'), description=_('Static aggregation with hash-based balancing (balance-xor)')),
+        Choice(MODE_ACTIVE_BACKUP, _('Active-Backup'), description=_('One active member; others on standby')),
+        Choice(MODE_BALANCE_RR, _('Balance-RR'), description=_('Round-robin transmission across members')),
+        Choice(MODE_BALANCE_TLB, _('Balance-TLB'), description=_('Adaptive transmit load balancing')),
+        Choice(MODE_BALANCE_ALB, _('Balance-ALB'), description=_('Adaptive transmit and receive load balancing')),
+        Choice(MODE_BROADCAST, _('Broadcast'), description=_('Transmit on all members')),
+    )
+
+
+class InterfaceLAGHashPolicyChoices(ChoiceSet):
+
+    POLICY_LAYER2 = 'layer2'
+    POLICY_LAYER2_3 = 'layer2+3'
+    POLICY_LAYER3_4 = 'layer3+4'
+    POLICY_ENCAP2_3 = 'encap2+3'
+    POLICY_ENCAP3_4 = 'encap3+4'
+
+    CHOICES = (
+        Choice(POLICY_LAYER2, _('Layer 2')),
+        Choice(POLICY_LAYER2_3, _('Layer 2+3')),
+        Choice(POLICY_LAYER3_4, _('Layer 3+4')),
+        Choice(POLICY_ENCAP2_3, _('Encapsulated Layer 2+3')),
+        Choice(POLICY_ENCAP3_4, _('Encapsulated Layer 3+4')),
+    )
+
+
 class InterfacePoEModeChoices(ChoiceSet):
 
     MODE_PD = 'pd'

+ 8 - 0
netbox/dcim/filtersets.py

@@ -2500,6 +2500,14 @@ class InterfaceFilterSet(
         choices=InterfacePoETypeChoices,
         distinct=False,
     )
+    lag_mode = django_filters.MultipleChoiceFilter(
+        choices=InterfaceLAGModeChoices,
+        distinct=False,
+    )
+    lag_hash_policy = django_filters.MultipleChoiceFilter(
+        choices=InterfaceLAGHashPolicyChoices,
+        distinct=False,
+    )
     type = django_filters.MultipleChoiceFilter(
         choices=InterfaceTypeChoices,
         distinct=False,

+ 17 - 3
netbox/dcim/forms/bulk_edit.py

@@ -1938,6 +1938,18 @@ class InterfaceBulkEditForm(
         initial='',
         label=_('PoE type')
     )
+    lag_mode = ChoiceField(
+        choices=add_blank_choice(InterfaceLAGModeChoices),
+        required=False,
+        initial='',
+        label=_('LAG mode')
+    )
+    lag_hash_policy = ChoiceField(
+        choices=add_blank_choice(InterfaceLAGHashPolicyChoices),
+        required=False,
+        initial='',
+        label=_('LAG hash policy')
+    )
     mark_connected = forms.NullBooleanField(
         label=_('Mark connected'),
         required=False,
@@ -2026,6 +2038,7 @@ class InterfaceBulkEditForm(
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
+        FieldSet('lag_mode', 'lag_hash_policy', name=_('LAG')),
         FieldSet(
             'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
         ),
@@ -2041,9 +2054,10 @@ class InterfaceBulkEditForm(
         ),
     )
     nullable_fields = (
-        'module', 'label', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs',
-        'mtu', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
-        'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
+        'module', 'label', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu',
+        'description', 'poe_mode', 'poe_type', 'lag_mode', 'lag_hash_policy', 'mode', 'rf_channel',
+        'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf',
+        'wireless_lans', 'vlan_translation_policy',
     )
 
     def __init__(self, *args, **kwargs):

+ 14 - 2
netbox/dcim/forms/bulk_import.py

@@ -1208,6 +1208,18 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         required=False,
         help_text=_('PoE type')
     )
+    lag_mode = CSVChoiceField(
+        label=_('LAG mode'),
+        choices=InterfaceLAGModeChoices,
+        required=False,
+        help_text=_('LAG mode')
+    )
+    lag_hash_policy = CSVChoiceField(
+        label=_('LAG hash policy'),
+        choices=InterfaceLAGHashPolicyChoices,
+        required=False,
+        help_text=_('LAG hash policy')
+    )
     mode = CSVChoiceField(
         label=_('Mode'),
         choices=InterfaceModeChoices,
@@ -1267,8 +1279,8 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         fields = (
             'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'channels', 'channel_id', 'speed', 'duplex',
             'enabled', 'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
-            'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
-            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
+            'lag_mode', 'lag_hash_policy', 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf',
+            'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
         )
 
     def __init__(self, data=None, *args, **kwargs):

+ 11 - 0
netbox/dcim/forms/filtersets.py

@@ -2017,6 +2017,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
             name=_('Attributes')
         ),
         FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
+        FieldSet('lag_mode', 'lag_hash_policy', name=_('LAG')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
         FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
@@ -2100,6 +2101,16 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         required=False,
         label=_('PoE type')
     )
+    lag_mode = forms.MultipleChoiceField(
+        choices=InterfaceLAGModeChoices,
+        required=False,
+        label=_('LAG mode')
+    )
+    lag_hash_policy = forms.MultipleChoiceField(
+        choices=InterfaceLAGHashPolicyChoices,
+        required=False,
+        label=_('LAG hash policy')
+    )
     mode = forms.MultipleChoiceField(
         choices=InterfaceModeChoices,
         required=False,

+ 16 - 5
netbox/dcim/forms/model_forms.py

@@ -2052,6 +2052,16 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         choices=add_blank_choice(InterfacePoETypeChoices),
         required=False,
     )
+    lag_mode = TypedChoiceField(
+        label=_('LAG mode'),
+        choices=add_blank_choice(InterfaceLAGModeChoices),
+        required=False,
+    )
+    lag_hash_policy = TypedChoiceField(
+        label=_('LAG hash policy'),
+        choices=add_blank_choice(InterfaceLAGHashPolicyChoices),
+        required=False,
+    )
     mode = TypedChoiceField(
         label=_('802.1Q Mode'),
         choices=add_blank_choice(InterfaceModeChoices),
@@ -2176,6 +2186,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
+        FieldSet('lag_mode', 'lag_hash_policy', name=_('LAG')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet(
             'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
@@ -2191,11 +2202,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     class Meta:
         model = Interface
         fields = [
-            'device', 'module', 'vdcs', 'name', 'label', 'type', 'channels', 'channel_id', 'speed', 'duplex',
-            'enabled', 'parent', 'bridge', 'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description',
-            'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
-            'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
-            'vrf', 'owner', 'tags',
+            'device', 'module', 'vdcs', 'name', 'label', 'type', 'channels', 'channel_id', 'speed', 'duplex', 'enabled',
+            'parent', 'bridge', 'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode',
+            'poe_type', 'lag_mode', 'lag_hash_policy', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+            'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
+            'vlan_translation_policy', 'vrf', 'owner', 'tags',
         ]
         widgets = {
             'speed': NumberWithOptions(

+ 4 - 0
netbox/dcim/graphql/enums.py

@@ -22,6 +22,8 @@ __all__ = (
     'FluidTypeEnum',
     'InterfaceDuplexEnum',
     'InterfaceKindEnum',
+    'InterfaceLAGHashPolicyEnum',
+    'InterfaceLAGModeEnum',
     'InterfaceModeEnum',
     'InterfacePoEModeEnum',
     'InterfacePoETypeEnum',
@@ -71,6 +73,8 @@ FluidTypeEnum = strawberry.enum(FluidTypeChoices.as_enum(prefix='fluid'))
 InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum(prefix='duplex'))
 InterfaceKindEnum = strawberry.enum(InterfaceKindChoices.as_enum(prefix='kind'))
 InterfaceModeEnum = strawberry.enum(InterfaceModeChoices.as_enum(prefix='mode'))
+InterfaceLAGModeEnum = strawberry.enum(InterfaceLAGModeChoices.as_enum(prefix='mode'))
+InterfaceLAGHashPolicyEnum = strawberry.enum(InterfaceLAGHashPolicyChoices.as_enum(prefix='policy'))
 InterfacePoEModeEnum = strawberry.enum(InterfacePoEModeChoices.as_enum(prefix='mode'))
 InterfacePoETypeEnum = strawberry.enum(InterfacePoETypeChoices.as_enum())
 InterfaceTypeEnum = strawberry.enum(InterfaceTypeChoices.as_enum(prefix='type'))

+ 6 - 0
netbox/dcim/graphql/filters.py

@@ -674,6 +674,12 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
     poe_type: BaseFilterLookup[Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
+    lag_mode: BaseFilterLookup[Annotated['InterfaceLAGModeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )
+    lag_hash_policy: (
+        BaseFilterLookup[Annotated['InterfaceLAGHashPolicyEnum', strawberry.lazy('dcim.graphql.enums')]] | None
+    ) = strawberry_django.filter_field()
     rf_role: BaseFilterLookup[Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )

+ 21 - 1
netbox/dcim/models/device_components.py

@@ -1127,6 +1127,20 @@ class Interface(
         null=True,
         verbose_name=_('PoE type')
     )
+    lag_mode = models.CharField(
+        max_length=50,
+        choices=InterfaceLAGModeChoices,
+        blank=True,
+        null=True,
+        verbose_name=_('LAG mode')
+    )
+    lag_hash_policy = models.CharField(
+        max_length=50,
+        choices=InterfaceLAGHashPolicyChoices,
+        blank=True,
+        null=True,
+        verbose_name=_('LAG hash policy')
+    )
     wireless_link = models.ForeignKey(
         to='wireless.WirelessLink',
         on_delete=models.SET_NULL,
@@ -1182,7 +1196,7 @@ class Interface(
     clone_fields = (
         'device', 'module', 'parent', 'bridge', 'lag', 'type', 'channels', 'mgmt_only', 'mtu', 'mode', 'speed',
         'duplex', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode',
-        'poe_type', 'vrf',
+        'poe_type', 'lag_mode', 'lag_hash_policy', 'vrf',
     )
 
     class Meta(ModularComponentModel.Meta):
@@ -1307,6 +1321,12 @@ class Interface(
                     )
                 })
 
+        # LAG attributes may only be set on LAG interfaces
+        if self.lag_mode and not self.is_lag:
+            raise ValidationError({'lag_mode': _("LAG mode may be set only on LAG interfaces.")})
+        if self.lag_hash_policy and not self.is_lag:
+            raise ValidationError({'lag_hash_policy': _("LAG hash policy may be set only on LAG interfaces.")})
+
         # Wireless validation
 
         # RF channel may only be set for wireless interfaces

+ 7 - 7
netbox/dcim/tables/devices.py

@@ -722,13 +722,13 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
     class Meta(DeviceComponentTable.Meta):
         model = models.Interface
         fields = (
-            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'channels',
-            'channel_id', 'mgmt_only', 'mtu', 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses',
-            'primary_mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency',
-            'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
-            'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
-            'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
-            'vlan_translation_policy',
+            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'channels', 'channel_id',
+            'mgmt_only', 'mtu', 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses', 'primary_mac_address',
+            'wwn', 'poe_mode', 'poe_type', 'lag_mode', 'lag_hash_policy', 'rf_role', 'rf_channel',
+            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
+            'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
+            'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items',
+            'created', 'last_updated', 'vlan_translation_policy',
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
 

+ 16 - 0
netbox/dcim/tests/test_api.py

@@ -4575,6 +4575,13 @@ class InterfaceTestCase(Mixins.FieldPrefetchMixin, Mixins.ComponentTraceMixin, A
                 'parent': channelized_parent.pk,
                 'channel_id': 1,
             },
+            {
+                'device': device.pk,
+                'name': 'Interface 11',
+                'type': InterfaceTypeChoices.TYPE_LAG,
+                'lag_mode': InterfaceLAGModeChoices.MODE_LACP,
+                'lag_hash_policy': InterfaceLAGHashPolicyChoices.POLICY_LAYER3_4,
+            },
         ]
 
     def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):
@@ -4759,6 +4766,15 @@ class InterfaceTestCase(Mixins.FieldPrefetchMixin, Mixins.ComponentTraceMixin, A
         # Tagged-all mode, qinq service vlan
         self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
 
+    def test_lag_attributes_on_non_lag_interface(self):
+        self.add_permissions('dcim.add_interface')
+
+        for field, value in (
+            ('lag_mode', InterfaceLAGModeChoices.MODE_LACP),
+            ('lag_hash_policy', InterfaceLAGHashPolicyChoices.POLICY_LAYER2),
+        ):
+            self._perform_interface_test_with_invalid_data(None, {field: value})
+
     def test_list_objects_reuse_peer_serializers(self):
         """Link peers and connected endpoints share one nested serializer each per request."""
         self.add_permissions('dcim.view_interface')

+ 24 - 0
netbox/dcim/tests/test_filtersets.py

@@ -5859,6 +5859,20 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLogge
         # Wireless links
         WirelessLink(interface_a=interfaces[7], interface_b=interfaces[8]).save()
 
+        # LAG attributes (set directly; validation is covered in test_models)
+        Interface.objects.filter(pk=interfaces[0].pk).update(
+            lag_mode=InterfaceLAGModeChoices.MODE_LACP,
+            lag_hash_policy=InterfaceLAGHashPolicyChoices.POLICY_LAYER2,
+        )
+        Interface.objects.filter(pk=interfaces[1].pk).update(
+            lag_mode=InterfaceLAGModeChoices.MODE_ACTIVE_BACKUP,
+            lag_hash_policy=InterfaceLAGHashPolicyChoices.POLICY_LAYER2_3,
+        )
+        Interface.objects.filter(pk=interfaces[2].pk).update(
+            lag_mode=InterfaceLAGModeChoices.MODE_BALANCE_RR,
+            lag_hash_policy=InterfaceLAGHashPolicyChoices.POLICY_LAYER3_4,
+        )
+
     def test_name(self):
         params = {'name': ['Interface 1', 'Interface 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -5899,6 +5913,16 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLogge
         params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_lag_mode(self):
+        params = {'lag_mode': [InterfaceLAGModeChoices.MODE_LACP, InterfaceLAGModeChoices.MODE_ACTIVE_BACKUP]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_lag_hash_policy(self):
+        params = {'lag_hash_policy': [
+            InterfaceLAGHashPolicyChoices.POLICY_LAYER2, InterfaceLAGHashPolicyChoices.POLICY_LAYER2_3,
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_mode(self):
         params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

+ 43 - 0
netbox/dcim/tests/test_models.py

@@ -3575,6 +3575,49 @@ class CableDisconnectChangeLoggingTestCase(TestCase):
         self.assertFalse(self._updates(self.interface1).filter(request_id=request.id).exists())
 
 
+class InterfaceLAGAttributesTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
+        cls.device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
+
+    def test_lag_attributes_on_lag_interface(self):
+        interface = Interface(
+            device=self.device,
+            name='bond0',
+            type=InterfaceTypeChoices.TYPE_LAG,
+            lag_mode=InterfaceLAGModeChoices.MODE_LACP,
+            lag_hash_policy=InterfaceLAGHashPolicyChoices.POLICY_LAYER3_4,
+        )
+        interface.full_clean()
+
+    def test_lag_mode_on_non_lag_interface(self):
+        interface = Interface(
+            device=self.device,
+            name='eth0',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+            lag_mode=InterfaceLAGModeChoices.MODE_LACP,
+        )
+        with self.assertRaises(ValidationError) as cm:
+            interface.full_clean()
+        self.assertIn('lag_mode', cm.exception.message_dict)
+
+    def test_lag_hash_policy_on_non_lag_interface(self):
+        interface = Interface(
+            device=self.device,
+            name='eth0',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+            lag_hash_policy=InterfaceLAGHashPolicyChoices.POLICY_LAYER2,
+        )
+        with self.assertRaises(ValidationError) as cm:
+            interface.full_clean()
+        self.assertIn('lag_hash_policy', cm.exception.message_dict)
+
+
 class VirtualDeviceContextTestCase(TestCase):
 
     @classmethod

+ 2 - 0
netbox/dcim/ui/panels.py

@@ -586,6 +586,8 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     description = attrs.TextAttr('description')
     poe_mode = attrs.ChoiceAttr('poe_mode', label=_('PoE mode'))
     poe_type = attrs.ChoiceAttr('poe_type', label=_('PoE type'))
+    lag_mode = attrs.ChoiceAttr('lag_mode', label=_('LAG mode'))
+    lag_hash_policy = attrs.ChoiceAttr('lag_hash_policy', label=_('LAG hash policy'))
     mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
     qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
     untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))