Преглед на файлове

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

Arthur преди 22 часа
родител
ревизия
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.
 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
 ### 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:
 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)
     rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
     poe_mode = ChoiceField(choices=InterfacePoEModeChoices, 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)
     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)
     untagged_vlan = VLANSerializer(nested=True, required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
@@ -356,14 +358,14 @@ class InterfaceSerializer(
         model = Interface
         model = Interface
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'channels',
             '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')
         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):
 class InterfacePoEModeChoices(ChoiceSet):
 
 
     MODE_PD = 'pd'
     MODE_PD = 'pd'

+ 8 - 0
netbox/dcim/filtersets.py

@@ -2500,6 +2500,14 @@ class InterfaceFilterSet(
         choices=InterfacePoETypeChoices,
         choices=InterfacePoETypeChoices,
         distinct=False,
         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(
     type = django_filters.MultipleChoiceFilter(
         choices=InterfaceTypeChoices,
         choices=InterfaceTypeChoices,
         distinct=False,
         distinct=False,

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

@@ -1938,6 +1938,18 @@ class InterfaceBulkEditForm(
         initial='',
         initial='',
         label=_('PoE type')
         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(
     mark_connected = forms.NullBooleanField(
         label=_('Mark connected'),
         label=_('Mark connected'),
         required=False,
         required=False,
@@ -2026,6 +2038,7 @@ class InterfaceBulkEditForm(
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
+        FieldSet('lag_mode', 'lag_hash_policy', name=_('LAG')),
         FieldSet(
         FieldSet(
             'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
             'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
         ),
         ),
@@ -2041,9 +2054,10 @@ class InterfaceBulkEditForm(
         ),
         ),
     )
     )
     nullable_fields = (
     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):
     def __init__(self, *args, **kwargs):

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

@@ -1208,6 +1208,18 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         required=False,
         required=False,
         help_text=_('PoE type')
         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(
     mode = CSVChoiceField(
         label=_('Mode'),
         label=_('Mode'),
         choices=InterfaceModeChoices,
         choices=InterfaceModeChoices,
@@ -1267,8 +1279,8 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         fields = (
         fields = (
             'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'channels', 'channel_id', 'speed', 'duplex',
             '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',
             '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):
     def __init__(self, data=None, *args, **kwargs):

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

@@ -2017,6 +2017,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
             name=_('Attributes')
             name=_('Attributes')
         ),
         ),
         FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
         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('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
         FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
         FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
         FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
@@ -2100,6 +2101,16 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         required=False,
         required=False,
         label=_('PoE type')
         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(
     mode = forms.MultipleChoiceField(
         choices=InterfaceModeChoices,
         choices=InterfaceModeChoices,
         required=False,
         required=False,

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

@@ -2052,6 +2052,16 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         choices=add_blank_choice(InterfacePoETypeChoices),
         choices=add_blank_choice(InterfacePoETypeChoices),
         required=False,
         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(
     mode = TypedChoiceField(
         label=_('802.1Q Mode'),
         label=_('802.1Q Mode'),
         choices=add_blank_choice(InterfaceModeChoices),
         choices=add_blank_choice(InterfaceModeChoices),
@@ -2176,6 +2186,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
         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('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet(
         FieldSet(
             'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
             'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
@@ -2191,11 +2202,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         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 = {
         widgets = {
             'speed': NumberWithOptions(
             'speed': NumberWithOptions(

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

@@ -22,6 +22,8 @@ __all__ = (
     'FluidTypeEnum',
     'FluidTypeEnum',
     'InterfaceDuplexEnum',
     'InterfaceDuplexEnum',
     'InterfaceKindEnum',
     'InterfaceKindEnum',
+    'InterfaceLAGHashPolicyEnum',
+    'InterfaceLAGModeEnum',
     'InterfaceModeEnum',
     'InterfaceModeEnum',
     'InterfacePoEModeEnum',
     'InterfacePoEModeEnum',
     'InterfacePoETypeEnum',
     'InterfacePoETypeEnum',
@@ -71,6 +73,8 @@ FluidTypeEnum = strawberry.enum(FluidTypeChoices.as_enum(prefix='fluid'))
 InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum(prefix='duplex'))
 InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum(prefix='duplex'))
 InterfaceKindEnum = strawberry.enum(InterfaceKindChoices.as_enum(prefix='kind'))
 InterfaceKindEnum = strawberry.enum(InterfaceKindChoices.as_enum(prefix='kind'))
 InterfaceModeEnum = strawberry.enum(InterfaceModeChoices.as_enum(prefix='mode'))
 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'))
 InterfacePoEModeEnum = strawberry.enum(InterfacePoEModeChoices.as_enum(prefix='mode'))
 InterfacePoETypeEnum = strawberry.enum(InterfacePoETypeChoices.as_enum())
 InterfacePoETypeEnum = strawberry.enum(InterfacePoETypeChoices.as_enum())
 InterfaceTypeEnum = strawberry.enum(InterfaceTypeChoices.as_enum(prefix='type'))
 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 = (
     poe_type: BaseFilterLookup[Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         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 = (
     rf_role: BaseFilterLookup[Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )

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

@@ -1127,6 +1127,20 @@ class Interface(
         null=True,
         null=True,
         verbose_name=_('PoE type')
         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(
     wireless_link = models.ForeignKey(
         to='wireless.WirelessLink',
         to='wireless.WirelessLink',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
@@ -1182,7 +1196,7 @@ class Interface(
     clone_fields = (
     clone_fields = (
         'device', 'module', 'parent', 'bridge', 'lag', 'type', 'channels', 'mgmt_only', 'mtu', 'mode', 'speed',
         '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',
         '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):
     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
         # Wireless validation
 
 
         # RF channel may only be set for wireless interfaces
         # 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):
     class Meta(DeviceComponentTable.Meta):
         model = models.Interface
         model = models.Interface
         fields = (
         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')
         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,
                 'parent': channelized_parent.pk,
                 'channel_id': 1,
                 '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 = {}):
     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
         # Tagged-all mode, qinq service vlan
         self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
         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):
     def test_list_objects_reuse_peer_serializers(self):
         """Link peers and connected endpoints share one nested serializer each per request."""
         """Link peers and connected endpoints share one nested serializer each per request."""
         self.add_permissions('dcim.view_interface')
         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
         # Wireless links
         WirelessLink(interface_a=interfaces[7], interface_b=interfaces[8]).save()
         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):
     def test_name(self):
         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)
@@ -5899,6 +5913,16 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLogge
         params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
         params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         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):
     def test_mode(self):
         params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
         params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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())
         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):
 class VirtualDeviceContextTestCase(TestCase):
 
 
     @classmethod
     @classmethod

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

@@ -586,6 +586,8 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
     poe_mode = attrs.ChoiceAttr('poe_mode', label=_('PoE mode'))
     poe_mode = attrs.ChoiceAttr('poe_mode', label=_('PoE mode'))
     poe_type = attrs.ChoiceAttr('poe_type', label=_('PoE type'))
     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'))
     mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
     qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
     qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
     untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))
     untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))