diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 7f67e4e7a..cf5df9b88 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -28,11 +28,17 @@ An alternative physical label identifying the interface. ### Type -The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. +The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. The generic **channel** type identifies a [channelized subinterface](#channel-id) bound to a parent interface. !!! note The interface type refers to the physical termination or port on the device. Interfaces which employ a removable optic or similar transceiver should be defined to represent the type of transceiver in use, irrespective of the physical termination to that transceiver. +### Channels + +For a channelized (breakout) interface, the number of physical channels into which the interface is divided. For example, a 40GE interface broken out into four 10GE channels would have `channels` set to four. Each channel is modeled as a channel-type subinterface bound to this interface via its [channel ID](#channel-id). + +A single physical cable terminates to the channelized (parent) interface, occupying one connector shared by all of its channels; NetBox traces a distinct cable path for each channel subinterface. Only one layer of channelization is supported: an interface cannot be both channelized and itself bound to a channel. + ### Speed The operating speed, in kilobits per second (kbps). @@ -78,11 +84,18 @@ If selected, this component will be treated as if a cable has been connected. ### Parent Interface -Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. +Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. Channel-type subinterfaces are likewise bound to their [channelized](#channels) parent interface. !!! note An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned. +### Channel ID + +For a channel-type subinterface, the numeric channel on its [channelized](#channels) parent interface to which this subinterface is bound. The channel ID must fall within the range of channels provided by the parent (e.g. one through four for a parent with four channels). A channel subinterface derives its cable connection from the parent's; it cannot be cabled directly. + +!!! note "Channel IDs are one-indexed" + Channel IDs increment starting at one, even for interfaces with a zero-based identifier. This ensures that each subinterface maps cleanly to the profile of an attached cable. + ### Bridged Interface Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped. diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index 3e625a575..129c24702 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -263,7 +263,8 @@ class InterfaceSerializer( class Meta: model = Interface fields = [ - 'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', + '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', diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index 3e89db5a5..518cf6127 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -184,6 +184,10 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer): default=None ) type = ChoiceField(choices=InterfaceTypeChoices) + parent = NestedInterfaceTemplateSerializer( + required=False, + allow_null=True + ) bridge = NestedInterfaceTemplateSerializer( required=False, allow_null=True @@ -210,8 +214,9 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer): class Meta: model = InterfaceTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', - 'mgmt_only', 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', + 'enabled', 'mgmt_only', 'description', 'parent', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', + 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 7540e8744..3fa7eed45 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -914,6 +914,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_VIRTUAL = 'virtual' TYPE_BRIDGE = 'bridge' TYPE_LAG = 'lag' + TYPE_CHANNEL = 'channel' # FastEthernet TYPE_100ME_FX = '100base-fx' @@ -1185,6 +1186,7 @@ class InterfaceTypeChoices(ChoiceSet): Choice(TYPE_VIRTUAL, _('Virtual')), Choice(TYPE_BRIDGE, _('Bridge')), Choice(TYPE_LAG, _('Link Aggregation Group (LAG)')), + Choice(TYPE_CHANNEL, _('Channel')), ), ), ( diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 87f8844e0..368970b71 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -48,6 +48,12 @@ PORT_POSITION_MAX = 1024 INTERFACE_MTU_MIN = 1 INTERFACE_MTU_MAX = 65536 +# The number of channels on a channelized interface, and the channel to which a subinterface is bound. A subinterface's +# channel_id maps directly to a position on the parent interface's cable connector, so these are bounded by the maximum +# cable position. +INTERFACE_CHANNELS_MIN = CABLE_POSITION_MIN +INTERFACE_CHANNELS_MAX = CABLE_POSITION_MAX + VIRTUAL_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG, @@ -73,7 +79,10 @@ WIRELESS_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_5G, ] -NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES +NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES + [ + # Channel subinterfaces derive their cable from the (channelized) parent interface and cannot be cabled directly + InterfaceTypeChoices.TYPE_CHANNEL, +] # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 6acddc4ae..2c726ab2e 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1058,6 +1058,11 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo distinct=False, null_value=None ) + parent_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent', + queryset=InterfaceTemplate.objects.all(), + distinct=False, + ) bridge_id = django_filters.ModelMultipleChoiceFilter( field_name='bridge', queryset=InterfaceTemplate.objects.all(), @@ -1078,7 +1083,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo class Meta: model = InterfaceTemplate - fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description') + fields = ('id', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', 'description') @register_filterset @@ -2416,9 +2421,9 @@ class InterfaceFilterSet( class Meta: model = Interface fields = ( - 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', - 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', - 'cable_id', 'cable_end', 'cable_connector', + 'id', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', + 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'description', 'mark_connected', 'cable_id', 'cable_end', 'cable_connector', ) def filter_virtual_chassis_member_or_master(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 8613262f6..e5c6d6aa3 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1191,6 +1191,14 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm): choices=add_blank_choice(InterfaceTypeChoices), required=False ) + channels = forms.IntegerField( + label=_('Channels'), + required=False + ) + channel_id = forms.IntegerField( + label=_('Channel ID'), + required=False + ) enabled = forms.NullBooleanField( label=_('Enabled'), required=False, @@ -1224,7 +1232,7 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm): label=_('Wireless role') ) - nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role') + nullable_fields = ('label', 'channels', 'channel_id', 'description', 'poe_mode', 'poe_type', 'rf_role') class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm): @@ -1477,9 +1485,9 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( ComponentBulkEditForm, form_from_model(Interface, [ - 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', - 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'wireless_lans', 'vlan_translation_policy' + 'label', 'type', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', + 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'tx_power', 'wireless_lans', 'vlan_translation_policy' ]) ): enabled = forms.NullBooleanField( @@ -1627,11 +1635,11 @@ class InterfaceBulkEditForm( model = Interface fieldsets = ( - FieldSet('module', 'type', 'label', 'speed', 'duplex', 'description'), + FieldSet('module', 'type', 'channels', 'label', 'speed', 'duplex', 'description'), FieldSet('vrf', 'wwn', name=_('Addressing')), FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), FieldSet('poe_mode', 'poe_type', name=_('PoE')), - FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), + FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet( 'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching') ), @@ -1647,9 +1655,9 @@ class InterfaceBulkEditForm( ), ) nullable_fields = ( - 'module', 'label', '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', '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): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 475b5aa51..6c273fed4 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -1080,9 +1080,9 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', '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', + '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' ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 874a0436d..4bafd7128 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1687,7 +1687,10 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')), + FieldSet( + 'name', 'label', 'kind', 'type', 'channels', 'channel_id', 'speed', 'duplex', 'enabled', 'mgmt_only', + name=_('Attributes') + ), FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')), FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')), @@ -1720,6 +1723,14 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): choices=InterfaceTypeChoices, required=False ) + channels = forms.IntegerField( + label=_('Channels'), + required=False + ) + channel_id = forms.IntegerField( + label=_('Channel ID'), + required=False + ) speed = PositiveBigIntegerField( label=_('Speed'), required=False, diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index c43ab815f..a067847a8 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1391,6 +1391,15 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): choices=add_blank_choice(WirelessRoleChoices), required=False, ) + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=InterfaceTemplate.objects.all(), + required=False, + query_params={ + 'device_type_id': '$device_type', + 'module_type_id': '$module_type', + } + ) bridge = DynamicModelChoiceField( label=_('Bridge'), queryset=InterfaceTemplate.objects.all(), @@ -1407,7 +1416,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): FieldSet('device_type', name=_('Device Type')), FieldSet('module_type', name=_('Module Type')), ), - 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge', + 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', 'description', 'parent', + 'bridge', ), FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet('rf_role', name=_('Wireless')), @@ -1416,8 +1426,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', - 'poe_type', 'bridge', 'rf_role', + 'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', 'mgmt_only', 'enabled', + 'description', 'poe_mode', 'poe_type', 'parent', 'bridge', 'rf_role', ] @@ -1939,11 +1949,12 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): fieldsets = ( FieldSet( - 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface') + 'device', 'module', 'name', 'label', 'type', 'channels', 'speed', 'duplex', 'description', 'tags', + name=_('Interface') ), FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')), FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), - FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), + FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet( 'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', @@ -1959,11 +1970,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): class Meta: model = Interface fields = [ - 'device', 'module', 'vdcs', 'name', 'label', 'type', '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', '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( diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index a367d55b3..ad8934870 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -4,7 +4,12 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import * from netbox.forms import NetBoxModelForm from netbox.forms.mixins import OwnerMixin -from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField +from utilities.forms.fields import ( + DynamicModelChoiceField, + DynamicModelMultipleChoiceField, + ExpandableNameField, + ExpandableNumericField, +) from utilities.forms.rendering import FieldSet, TabbedGroups from utilities.forms.widgets import APISelect @@ -105,9 +110,14 @@ class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutlet class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm): + channel_id = ExpandableNumericField( + label=_('Channel ID'), + required=False + ) + replication_fields = ('name', 'label', 'channel_id') class Meta(model_forms.InterfaceTemplateForm.Meta): - exclude = ('name', 'label') + exclude = ('name', 'label', 'channel_id') class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm): @@ -197,9 +207,14 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm): class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm): + channel_id = ExpandableNumericField( + label=_('Channel ID'), + required=False + ) + replication_fields = ('name', 'label', 'channel_id') class Meta(model_forms.InterfaceForm.Meta): - exclude = ('name', 'label') + exclude = ('name', 'label', 'channel_id') class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 38ca11d01..0e7ef56d4 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -104,8 +104,8 @@ class InterfaceTemplateImportForm(forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode', - 'poe_type', 'rf_role' + 'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', + 'description', 'poe_mode', 'poe_type', 'rf_role' ] diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 1fc767793..197257531 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -540,6 +540,12 @@ class InterfaceFilter( type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) + channels: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + channel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() speed: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( strawberry_django.filter_field() @@ -629,8 +635,18 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( strawberry_django.filter_field() ) + channels: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + channel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) enabled: FilterLookup[bool] | None = strawberry_django.filter_field() mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() + parent: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() bridge: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 9249f7e91..fc42ce2f0 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -482,9 +482,11 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P ) class InterfaceTemplateType(ModularComponentTemplateType): _name: str + parent: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None bridge: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None bridge_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] + child_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] @strawberry_django.type( diff --git a/netbox/dcim/migrations/0246_interfacetemplate_channels.py b/netbox/dcim/migrations/0246_interfacetemplate_channels.py new file mode 100644 index 000000000..e235a5fb9 --- /dev/null +++ b/netbox/dcim/migrations/0246_interfacetemplate_channels.py @@ -0,0 +1,54 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0245_modulebaytype'), + ] + + operations = [ + migrations.AddField( + model_name='interfacetemplate', + name='parent', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.RESTRICT, + related_name='child_interfaces', + to='dcim.interfacetemplate', + ), + ), + migrations.AddField( + model_name='interfacetemplate', + name='channel_id', + field=models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddField( + model_name='interfacetemplate', + name='channels', + field=models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddConstraint( + model_name='interfacetemplate', + constraint=models.UniqueConstraint( + fields=('parent', 'channel_id'), + name='dcim_interfacetemplate_unique_parent_channel_id', + ), + ), + ] diff --git a/netbox/dcim/migrations/0247_interface_channels.py b/netbox/dcim/migrations/0247_interface_channels.py new file mode 100644 index 000000000..a9cf48697 --- /dev/null +++ b/netbox/dcim/migrations/0247_interface_channels.py @@ -0,0 +1,43 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0246_interfacetemplate_channels'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='channel_id', + field=models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddField( + model_name='interface', + name='channels', + field=models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(1024), + ], + ), + ), + migrations.AddConstraint( + model_name='interface', + constraint=models.UniqueConstraint( + fields=('parent', 'channel_id'), + name='dcim_interface_unique_parent_channel_id', + ), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 868ab0822..56a001ec4 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -636,6 +636,14 @@ class CableTermination(ChangeLoggedModel): cable_pk=existing_termination.cable.pk ) ) + # A channel subinterface derives its cable from its parent interface and cannot be cabled directly. Checked + # ahead of the generic type validation below (channel is a nonconnectable type) to surface the more specific + # guidance. + if self.termination_type.model == 'interface' and self.termination.channel_id: + raise ValidationError( + _("Cables cannot be terminated directly to a channel subinterface; cable the parent interface instead.") + ) + # Validate the interface type (if applicable) if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: raise ValidationError( @@ -971,6 +979,12 @@ class CablePath(models.Model): peer_results = cable_profile.get_peer_terminations(term_position_pairs) seen = set() for peer, new_pos in peer_results: + # If the far-end termination is a channelized interface, resolve to the specific channel + # subinterface bound to the mapped connector position (the far end is channelized on the same + # physical connector, so the peer lookup returns the parent rather than the channel). A + # channelized parent is never itself a path endpoint, so an unoccupied position yields no peer. + if new_pos is not None and getattr(peer, 'channels', None): + peer = peer.child_interfaces.filter(channel_id=new_pos).first() # Deduplicate peer terminations by model type & PK. key = None if peer is None else (peer._meta.concrete_model, peer.pk) if key not in seen: diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 30e534381..b04ff37e1 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -454,6 +454,26 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel) max_length=50, choices=InterfaceTypeChoices ) + channels = models.PositiveSmallIntegerField( + verbose_name=_('channels'), + blank=True, + null=True, + validators=( + MinValueValidator(INTERFACE_CHANNELS_MIN), + MaxValueValidator(INTERFACE_CHANNELS_MAX) + ), + help_text=_('The number of channels into which this interface is channelized') + ) + channel_id = models.PositiveSmallIntegerField( + verbose_name=_('channel ID'), + blank=True, + null=True, + validators=( + MinValueValidator(INTERFACE_CHANNELS_MIN), + MaxValueValidator(INTERFACE_CHANNELS_MAX) + ), + help_text=_('The channel on the parent interface to which this subinterface is bound') + ) enabled = models.BooleanField( verbose_name=_('enabled'), default=True @@ -462,6 +482,14 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel) default=False, verbose_name=_('management only') ) + parent = models.ForeignKey( + to='self', + on_delete=models.RESTRICT, + related_name='child_interfaces', + null=True, + blank=True, + verbose_name=_('parent interface') + ) bridge = models.ForeignKey( to='self', on_delete=models.SET_NULL, @@ -495,12 +523,41 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel) component_model = Interface class Meta(ModularComponentTemplateModel.Meta): + constraints = ( + *ModularComponentTemplateModel.Meta.constraints, + models.UniqueConstraint( + fields=('parent', 'channel_id'), + name='%(app_label)s_%(class)s_unique_parent_channel_id' + ), + ) verbose_name = _('interface template') verbose_name_plural = _('interface templates') + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Cache the original channel count for use by InterfaceValidationMixin.clean() to detect a channel-count + # reduction that would orphan a bound subinterface. + self._original_channels = self.__dict__.get('channels') + def clean(self): super().clean() + # Self-reference and interface-type restrictions are enforced by InterfaceValidationMixin + if self.parent: + if self.device_type and self.device_type != self.parent.device_type: + raise ValidationError({ + 'parent': _( + "Parent interface ({parent}) must belong to the same device type" + ).format(parent=self.parent) + }) + if self.module_type and self.module_type != self.parent.module_type: + raise ValidationError({ + 'parent': _( + "Parent interface ({parent}) must belong to the same module type" + ).format(parent=self.parent) + }) + if self.bridge: if self.device_type and self.device_type != self.bridge.device_type: raise ValidationError({ @@ -520,6 +577,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel) name=self.resolve_name(kwargs.get('module'), kwargs.get('device')), label=self.resolve_label(kwargs.get('module'), kwargs.get('device')), type=self.type, + channels=self.channels, + channel_id=self.channel_id, enabled=self.enabled, mgmt_only=self.mgmt_only, poe_mode=self.poe_mode, @@ -533,10 +592,13 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel) return { 'name': self.name, 'type': self.type, + 'channels': self.channels, + 'channel_id': self.channel_id, 'enabled': self.enabled, 'mgmt_only': self.mgmt_only, 'label': self.label, 'description': self.description, + 'parent': self.parent.name if self.parent else None, 'bridge': self.bridge.name if self.bridge else None, 'poe_mode': self.poe_mode, 'poe_type': self.poe_type, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index efa502b36..d144ae97f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -860,6 +860,26 @@ class Interface( max_length=50, choices=InterfaceTypeChoices ) + channels = models.PositiveSmallIntegerField( + verbose_name=_('channels'), + blank=True, + null=True, + validators=( + MinValueValidator(INTERFACE_CHANNELS_MIN), + MaxValueValidator(INTERFACE_CHANNELS_MAX) + ), + help_text=_('The number of channels into which this interface is channelized') + ) + channel_id = models.PositiveSmallIntegerField( + verbose_name=_('channel ID'), + blank=True, + null=True, + validators=( + MinValueValidator(INTERFACE_CHANNELS_MIN), + MaxValueValidator(INTERFACE_CHANNELS_MAX) + ), + help_text=_('The channel on the parent interface to which this subinterface is bound') + ) mgmt_only = models.BooleanField( default=False, verbose_name=_('management only'), @@ -989,14 +1009,33 @@ class Interface( ) clone_fields = ( - 'device', 'module', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'speed', 'duplex', 'rf_role', - 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf', + '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', ) class Meta(ModularComponentModel.Meta): ordering = ('device', CollateAsChar('_name')) verbose_name = _('interface') verbose_name_plural = _('interfaces') + constraints = ( + *ModularComponentModel.Meta.constraints, + models.UniqueConstraint( + fields=('parent', 'channel_id'), + name='%(app_label)s_%(class)s_unique_parent_channel_id' + ), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Cache channelization-related fields so post-save signal handlers can detect changes which require rebuilding + # cable paths (channelization does not involve modifying the Cable itself, so the cable signals do not fire). + # _original_channels is additionally used by InterfaceValidationMixin.clean() to detect a channel-count + # reduction that would orphan a bound subinterface. + self._original_channels = self.__dict__.get('channels') + self._original_channel_id = self.__dict__.get('channel_id') + self._original_parent_id = self.__dict__.get('parent_id') def clean(self): super().clean() @@ -1017,15 +1056,7 @@ class Interface( ) }) - # 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.")}) + # Parent validation (self-reference and interface-type restrictions are enforced by InterfaceValidationMixin) # An interface's parent must belong to the same device or virtual chassis if self.parent and self.parent.device != self.device: @@ -1147,7 +1178,9 @@ class Interface( @property def is_wired(self): - return not self.is_virtual and not self.is_wireless + # Excludes virtual, wireless, and channel-type interfaces (channel subinterfaces derive their cable from the + # channelized parent and cannot be cabled directly). + return self.type not in NONCONNECTABLE_IFACE_TYPES @property def is_virtual(self): @@ -1165,6 +1198,10 @@ class Interface( def is_bridge(self): return self.type == InterfaceTypeChoices.TYPE_BRIDGE + @property + def is_channel(self): + return self.type == InterfaceTypeChoices.TYPE_CHANNEL + @property def link(self): return self.cable or self.wireless_link @@ -1192,6 +1229,58 @@ class Interface( return self.virtual_circuit_termination.peer_terminations return super().connected_endpoints + def set_cable_termination(self, termination): + super().set_cable_termination(termination) + + # A channelized interface carries no path of its own; instead, its cable is mirrored onto each channel + # subinterface (occupying a single position of the shared connector) so that each channel traces independently. + if self.channels: + self.propagate_channel_cables() + + def clear_cable_termination(self, termination): + super().clear_cable_termination(termination) + + if self.channels: + self.clear_channel_cables() + + def propagate_channel_cables(self): + """ + Mirror this channelized interface's cable attributes onto each of its channel subinterfaces, restricting each + child to the single connector position identified by its channel_id. Only profiled cables map connector + positions to channels; a positionless (unprofiled) cable carries no per-channel path, so nothing is mirrored. + """ + # Only a profiled cable defines the connector positions that channels map onto; without one, clear any + # previously-mirrored attributes rather than propagate an unusable cable reference. + if not (self.cable and self.cable.profile): + self.clear_channel_cables() + return + + # Mirror via bulk_update() to issue a single UPDATE and, crucially, to bypass the post_save signal — a + # per-child save() would re-trigger update_channelized_cable_paths() and recurse indefinitely. + children = list(self.child_interfaces.filter(channel_id__isnull=False)) + for child in children: + child.cable = self.cable + child.cable_end = self.cable_end + child.cable_connector = self.cable_connector + child.cable_positions = [child.channel_id] + type(self).objects.bulk_update( + children, ['cable', 'cable_end', 'cable_connector', 'cable_positions'] + ) + + def clear_channel_cables(self): + """ + Clear the mirrored cable attributes from this channelized interface's channel subinterfaces. + """ + # A queryset update() clears every child in a single query and bypasses the post_save signal (see above). + # cable_end is cleared to '' to match the convention used elsewhere when nullifying a termination (see + # nullify_connected_endpoints() and update_channelized_cable_paths() in dcim.signals). + self.child_interfaces.filter(channel_id__isnull=False).update( + cable=None, + cable_end='', + cable_connector=None, + cable_positions=None, + ) + # # Pass-through ports diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 359a93fe7..01a59b071 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField -from dcim.utils import create_port_mappings, update_interface_bridges +from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet from netbox.choices import ColorChoices @@ -1070,7 +1070,9 @@ class Device( self._instantiate_components(self.device_type.devicebaytemplates.all()) # Disable bulk_create to accommodate MPTT self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) - # Interface bridges have to be set after interface instantiation + # Interface parents & bridges have to be set after interface instantiation. Parents are applied first so + # that channel subinterfaces validate against a populated parent. + update_interface_parents(self, self.device_type.interfacetemplates.all()) update_interface_bridges(self, self.device_type.interfacetemplates.all()) # Update Site and Rack assignment for any child Devices diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index cf1378596..91706122c 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -4,7 +4,8 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ -from dcim.constants import VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES +from dcim.choices import InterfaceTypeChoices +from dcim.constants import NONCONNECTABLE_IFACE_TYPES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES __all__ = ( 'CachedScopeMixin', @@ -130,6 +131,81 @@ class InterfaceValidationMixin: def clean(self): super().clean() + # 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.")}) + + # Only virtual and channel interfaces may have a parent interface + if self.parent_id and self.type not in (InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_CHANNEL): + raise ValidationError({ + 'parent': _("Only virtual and channel interfaces may be assigned to a parent interface.") + }) + + # Only one layer of channelization is permitted: an interface cannot be both channelized and a channel + if self.channels and self.channel_id: + raise ValidationError( + _("An interface cannot be both channelized and bound to a channel on a parent interface.") + ) + + # Only physical interfaces may be channelized + if self.channels and self.type in NONCONNECTABLE_IFACE_TYPES: + raise ValidationError({ + 'channels': _("{display_type} interfaces cannot be channelized.").format( + display_type=self.get_type_display() + ) + }) + + # The channel type and channel_id are mutually dependent. The channel_id requirement is relaxed for a + # replication base (bulk creation), where each channel_id is supplied per-instance during expansion. + is_channel = self.type == InterfaceTypeChoices.TYPE_CHANNEL + if is_channel and self.channel_id is None and not getattr(self, '_replicated_base', False): + raise ValidationError({ + 'channel_id': _("Channel interfaces must have a channel ID assigned.") + }) + if self.channel_id is not None and not is_channel: + raise ValidationError({ + 'channel_id': _("A channel ID can be assigned only to a channel-type interface.") + }) + + # A channel subinterface must be bound to a channelized parent interface + if is_channel: + if self.parent is None: + raise ValidationError({ + 'parent': _("Channel interfaces must be assigned to a parent interface.") + }) + if not self.parent.channels: + raise ValidationError({ + 'parent': _("The parent interface ({interface}) is not channelized.").format( + interface=self.parent + ) + }) + if self.channel_id and self.channel_id > self.parent.channels: + raise ValidationError({ + 'channel_id': _( + "Invalid channel ID ({channel_id}): the parent interface provides only {channels} channels." + ).format(channel_id=self.channel_id, channels=self.parent.channels) + }) + + # Reducing or clearing the channel count cannot orphan an existing channel subinterface bound to a higher + # channel (clearing channelization entirely would orphan every bound subinterface). Gated on the current or + # original channel count so the child lookup stays off the hot path for ordinary (never-channelized) interfaces. + if self.pk and (self.channels or self._original_channels): + max_child_channel_id = self.child_interfaces.filter( + channel_id__gt=self.channels or 0 + ).aggregate(models.Max('channel_id'))['channel_id__max'] + if max_child_channel_id is not None: + if self.channels: + message = _( + "Cannot set channels to {channels}: a channel subinterface is bound to channel " + "{channel_id}. Delete or reassign the affected subinterface(s) first." + ).format(channels=self.channels, channel_id=max_child_channel_id) + else: + message = _( + "Cannot remove channelization: a channel subinterface is bound to channel {channel_id}. " + "Delete or reassign the affected subinterface(s) first." + ).format(channel_id=max_child_channel_id) + raise ValidationError({'channels': message}) + # 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.")}) diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index fc9b1fbb2..e95efe92d 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _ from jsonschema.exceptions import ValidationError as JSONValidationError from dcim.choices import * -from dcim.utils import create_port_mappings, update_interface_bridges +from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents from extras.models import CustomField from netbox.models import PrimaryModel from netbox.models.features import ImageAttachmentsMixin @@ -591,7 +591,9 @@ class Module(TrackingModelMixin, PrimaryModel): # Replicate any front/rear port mappings from the ModuleType create_port_mappings(self.device, self.module_type, self) - # Interface bridges have to be set after interface instantiation + # Interface parents & bridges have to be set after interface instantiation. Parents are applied first so that + # channel subinterfaces validate against a populated parent. + update_interface_parents(self.device, self.module_type.interfacetemplates, self) update_interface_bridges(self.device, self.module_type.interfacetemplates, self) def _save_existing(self, *args, **kwargs): diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index af79c032f..99d5ccd9b 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -23,7 +23,7 @@ from .models import ( ) from .models.cables import trace_paths from .search import DeviceIndex -from .utils import create_cablepaths, rebuild_paths +from .utils import create_cablepaths, rebuild_cable_paths, rebuild_paths # # Location/rack/device assignment @@ -156,6 +156,14 @@ def nullify_connected_endpoints(instance, **kwargs): model = instance.termination_type.model_class() model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='') + # If the removed termination was a channelized interface, also clear the cable attributes mirrored onto its channel + # subinterfaces. This must happen before the retrace below so that each channel's (now dead) path is torn down + # rather than rebuilt from a stale cable reference. + if model is Interface: + Interface.objects.filter(parent_id=instance.termination_id, channel_id__isnull=False).update( + cable=None, cable_end='', cable_connector=None, cable_positions=None + ) + # If the parent Cable is being deleted in this same operation, skip the # per-termination retrace; retrace_cable_paths() will retrace each affected # path once after the Cable is deleted. @@ -171,6 +179,66 @@ def nullify_connected_endpoints(instance, **kwargs): cablepath.retrace() +@receiver(post_save, sender=Interface) +def update_channelized_cable_paths(instance, created, raw=False, **kwargs): + """ + Rebuild cable paths when an interface's channelization changes without the Cable itself being modified: a channel + subinterface is added, moved between parents, or has its channel_id changed, or channelization is toggled on an + already-cabled interface. (The cable-tracing signals only fire when a Cable is saved.) + """ + if raw: + return + + parent_ids = set() + + # A channel subinterface was added, moved between parents, or had its channel_id changed + if instance.channel_id or instance._original_channel_id: + parent_ids.update(pk for pk in (instance.parent_id, instance._original_parent_id) if pk) + + # Channelization was toggled on this interface while it carries a cable + if instance.channels != instance._original_channels and instance.cable_id: + parent_ids.add(instance.pk) + + # select_related('cable') avoids a per-parent round-trip to fetch the Cable, which both + # propagate_channel_cables() and rebuild_cable_paths() dereference. (Cable.profile is a plain field, not a + # relation, so it needs no prefetching.) + parents = Interface.objects.filter(pk__in=parent_ids, cable__isnull=False).select_related('cable') + for parent in parents: + if parent.channels: + parent.propagate_channel_cables() + rebuild_cable_paths(parent.cable) + + # A channel subinterface whose parent no longer provides a cable must not retain stale mirrored cable attributes + if instance.channel_id and instance.cable_id: + parent = instance.parent + if not (parent and parent.channels and parent.cable_id): + Interface.objects.filter(pk=instance.pk).update( + cable=None, cable_end='', cable_connector=None, cable_positions=None + ) + for cablepath in CablePath.objects.filter(_nodes__contains=instance): + if instance in cablepath.origins: + cablepath.delete() + + # Refresh the cached channelization state so that saving this same in-memory instance again compares against its + # current values rather than re-triggering propagation from a stale baseline. + instance._original_channels = instance.channels + instance._original_channel_id = instance.channel_id + instance._original_parent_id = instance.parent_id + + +@receiver(post_delete, sender=Interface) +def cleanup_channel_subinterface_paths(instance, **kwargs): + """ + When a channel subinterface is deleted, rebuild its channelized parent's cable paths so the removed channel's path + is torn down. + """ + if instance.channel_id and instance.parent_id: + parent = Interface.objects.filter(pk=instance.parent_id, cable__isnull=False).first() + if parent and parent.channels: + parent.propagate_channel_cables() + rebuild_cable_paths(parent.cable) + + @receiver(post_save, sender=Interface) @receiver(post_save, sender=VMInterface) def update_mac_address_interface(instance, created, raw, **kwargs): diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 587027d59..31db25a38 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -19,6 +19,22 @@ FANOUT_LEG_HEIGHT = 15 CABLE_HEIGHT = 5 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT +def _cable_side_nodes(term_nodes, cable_terminations): + """ + Filter a list of termination nodes to those connected to the given side of a Cable. A channel subinterface does + not terminate the cable directly; it derives its connection from its (channelized) parent interface, which carries + the actual CableTermination, so it is matched via its parent. + """ + def matches(obj): + if obj in cable_terminations: + return True + if getattr(obj, 'channel_id', None): + return obj.parent in cable_terminations + return False + + return [node for node in term_nodes if matches(node.object)] + + class Node(Hyperlink): """ Create a node to be represented in the SVG document as a rectangular box with a hyperlink. @@ -380,13 +396,14 @@ class CableTraceSVG: description.append(f"{cable.length} {cable.get_length_unit_display()}") color = cable.color or '000000' - # Collect all connected nodes to this cable - near = [term for term in near_terminations if term.object in cable.a_terminations] - far = [term for term in far_terminations if term.object in cable.b_terminations] + # Collect all connected nodes to this cable. Channel subinterfaces are matched via their + # parent interface, which carries the actual cable termination. + near = _cable_side_nodes(near_terminations, cable.a_terminations) + far = _cable_side_nodes(far_terminations, cable.b_terminations) if not (near and far): # a and b terminations may be swapped - near = [term for term in near_terminations if term.object in cable.b_terminations] - far = [term for term in far_terminations if term.object in cable.a_terminations] + near = _cable_side_nodes(near_terminations, cable.b_terminations) + far = _cable_side_nodes(far_terminations, cable.a_terminations) elif isinstance(cable, WirelessLink): labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()] if cable.ssid: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 25f4a1bd8..fe5e1410d 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -710,12 +710,12 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi class Meta(DeviceComponentTable.Meta): model = models.Interface fields = ( - 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', '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', + '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', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index ec2decfba..2459ca41b 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -244,8 +244,8 @@ class InterfaceTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = models.InterfaceTemplate fields = ( - 'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'bridge', 'poe_mode', 'poe_type', - 'rf_role', 'actions', + 'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'channels', 'channel_id', 'description', 'parent', + 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'actions', ) empty_text = "None" diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 771ea0116..d2664a9c0 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1372,9 +1372,11 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase): interface_templates = ( InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'), InterfaceTemplate(device_type=devicetype, name='Interface Template 2', type='1000base-t'), - InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t'), + # Interface Template 3 is channelized, so that channel subinterface templates may be bound to it + InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t', channels=4), ) InterfaceTemplate.objects.bulk_create(interface_templates) + channelized_parent = interface_templates[2] cls.create_data = [ { @@ -1397,6 +1399,21 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase): 'name': 'Interface Template 7', 'type': '1000base-t', }, + { + # A channelized parent template + 'device_type': devicetype.pk, + 'name': 'Interface Template 8', + 'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, + 'channels': 4, + }, + { + # A channel subinterface template bound to a channelized parent + 'device_type': devicetype.pk, + 'name': 'Interface Template 9', + 'type': InterfaceTypeChoices.TYPE_CHANNEL, + 'parent': channelized_parent.pk, + 'channel_id': 1, + }, ] @@ -2923,9 +2940,11 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest interfaces = ( Interface(device=device, name='Interface 1', type='1000base-t'), Interface(device=device, name='Interface 2', type='1000base-t'), - Interface(device=device, name='Interface 3', type='1000base-t'), + # Interface 3 is channelized, so that channel subinterfaces may be bound to it + Interface(device=device, name='Interface 3', type='1000base-t', channels=4), ) Interface.objects.bulk_create(interfaces) + channelized_parent = interfaces[2] vdcs = ( VirtualDeviceContext(name='VDC 1', identifier=1, device=device), @@ -3013,6 +3032,21 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest 'rf_channel': "", 'qinq_svlan': vlans[3].pk, }, + { + # A channelized parent interface + 'device': device.pk, + 'name': 'Interface 9', + 'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, + 'channels': 4, + }, + { + # A channel subinterface bound to a channelized parent + 'device': device.pk, + 'name': 'Interface 10', + 'type': InterfaceTypeChoices.TYPE_CHANNEL, + 'parent': channelized_parent.pk, + 'channel_id': 1, + }, ] def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}): diff --git a/netbox/dcim/tests/test_channelization.py b/netbox/dcim/tests/test_channelization.py new file mode 100644 index 000000000..143f63276 --- /dev/null +++ b/netbox/dcim/tests/test_channelization.py @@ -0,0 +1,630 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.urls import reverse + +from dcim.choices import CableProfileChoices, InterfaceTypeChoices +from dcim.models import ( + Cable, + CablePath, + Device, + DeviceRole, + DeviceType, + Interface, + InterfaceTemplate, + Manufacturer, + Site, +) +from dcim.svg import CableTraceSVG +from dcim.svg.cables import Connector +from dcim.tests.utils import BaseCablePathTestCase +from utilities.testing import TestCase as ViewTestCase + + +class ChannelizedCablePathTestCase(BaseCablePathTestCase): + """ + Test cable path tracing for channelized interfaces. A single physical cable terminates to a channelized (parent) + interface, and each of the parent's channel subinterfaces traces an independent path from the connector position + identified by its channel_id. + """ + + def _create_channelized_interface(self, name, channels, device=None): + """Create a channelized parent interface and its channel subinterfaces.""" + device = device or self.device + parent = Interface.objects.create( + device=device, name=name, type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=channels + ) + children = [ + Interface.objects.create( + device=device, + name=f'{name}:{i}', + type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=parent, + channel_id=i, + ) + for i in range(1, channels + 1) + ] + return parent, children + + def test_101_channelized_breakout_to_discrete_interfaces(self): + """ + A 4-channel parent broken out to four discrete far-end interfaces via a 1C4P:4C1P breakout cable. Each channel + subinterface traces to its corresponding far-end interface (and vice versa); the parent itself has no path. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + + # One forward and one reverse path per channel; the parent originates no path + self.assertEqual(CablePath.objects.count(), 8) + parent.refresh_from_db() + self.assertPathIsNotSet(parent) + + for i, (channel, far_iface) in enumerate(zip(channels, far), start=1): + channel.refresh_from_db() + far_iface.refresh_from_db() + + # The parent's cable is mirrored onto the channel, restricted to its single connector position + self.assertEqual(channel.cable_id, cable.pk) + self.assertEqual(channel.cable_connector, 1) + self.assertEqual(channel.cable_positions, [i]) + + forward = self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True) + reverse = self.assertPathExists((far_iface, cable, channel), is_complete=True, is_active=True) + self.assertPathIsSet(channel, forward) + self.assertPathIsSet(far_iface, reverse) + + # The trace SVG must render from both a channel subinterface and a discrete far-end interface + CableTraceSVG(channels[0]).render() + CableTraceSVG(far[0]).render() + + def test_102_channelized_to_channelized(self): + """ + Two channelized interfaces connected by a single 1C4P cable (both ends channelized on one connector). Each + near-end channel traces to the far-end channel bound to the same position. + """ + near_parent, near_channels = self._create_channelized_interface('et0', 4) + far_device = Device.objects.create( + site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device 2' + ) + far_parent, far_channels = self._create_channelized_interface('et0', 4, device=far_device) + + cable = Cable( + profile=CableProfileChoices.SINGLE_1C4P, + a_terminations=[near_parent], + b_terminations=[far_parent], + ) + cable.clean() + cable.save() + + self.assertEqual(CablePath.objects.count(), 8) + for near, far in zip(near_channels, far_channels): + near.refresh_from_db() + far.refresh_from_db() + self.assertPathExists((near, cable, far), is_complete=True, is_active=True) + self.assertPathExists((far, cable, near), is_complete=True, is_active=True) + + # The trace SVG for a channel subinterface must render, drawing the cable between the two channels. The cable + # terminates on the parent interfaces, so the connector is matched to the channels via their parents. + svg = CableTraceSVG(near_channels[0]) + svg.render() + self.assertTrue( + any(isinstance(c, Connector) for c in svg.connectors), + msg="Trace SVG did not render a cable connector for the channelized path" + ) + + def test_103_add_channel_after_cabling(self): + """ + On an already-cabled parent, deleting a channel subinterface tears down its path, and adding a channel + subinterface (re-adding one on the freed position) builds a fresh path for it in both directions. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + + # Removing the fourth channel tears down its complete path in both directions + channels[3].delete() + self.assertPathDoesNotExist((channels[3], cable, far[3])) + self.assertPathDoesNotExist((far[3], cable, channels[3])) + + # Re-adding a channel on position 4 restores the complete path in both directions + new_channel = Interface.objects.create( + device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=parent, channel_id=4 + ) + new_channel.refresh_from_db() + self.assertEqual(new_channel.cable_positions, [4]) + self.assertPathExists((new_channel, cable, far[3]), is_complete=True, is_active=True) + self.assertPathExists((far[3], cable, new_channel), is_complete=True, is_active=True) + + def test_104_change_channel_id(self): + """ + Changing a channel's channel_id re-binds it to a different connector position, in both directions. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + # Delete channels 3 and 4 so their positions are free to reassign to + channels[2].delete() + channels[3].delete() + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + + # Channel 1 initially traces to far[0] + self.assertPathExists((channels[0], cable, far[0]), is_complete=True, is_active=True) + + # Move channel 1 to position 3 + channels[0].channel_id = 3 + channels[0].save() + channels[0].refresh_from_db() + + self.assertEqual(channels[0].cable_positions, [3]) + self.assertPathDoesNotExist((channels[0], cable, far[0])) + self.assertPathExists((channels[0], cable, far[2]), is_complete=True, is_active=True) + self.assertPathExists((far[2], cable, channels[0]), is_complete=True, is_active=True) + + def test_105_incomplete_channel(self): + """ + A channel whose position has no far-end termination yields an incomplete path (rather than an error). + """ + parent, channels = self._create_channelized_interface('et0', 4) + # Only two far-end interfaces exist, on connectors 1 and 2 + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(2) + ] + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + + # Channels 1 & 2 are complete; channels 3 & 4 have no far-end termination and trace an incomplete path + channels[0].refresh_from_db() + channels[2].refresh_from_db() + self.assertPathExists((channels[0], cable, far[0]), is_complete=True) + self.assertIsNotNone(channels[2]._path_id) + self.assertFalse(channels[2].path.is_complete) + + def test_106_cable_removal_teardown(self): + """ + Removing the cable from a channelized parent tears down every channel's path and clears the mirrored cable + attributes from the channels. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + self.assertEqual(CablePath.objects.count(), 8) + + cable.delete() + + self.assertEqual(CablePath.objects.count(), 0) + for channel in channels: + channel.refresh_from_db() + self.assertIsNone(channel.cable_id) + self.assertIsNone(channel.cable_connector) + self.assertIsNone(channel.cable_positions) + self.assertPathIsNotSet(channel) + + def test_107_direct_cabling_of_channel_rejected(self): + """ + A cable cannot be terminated directly to a channel subinterface. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = Interface.objects.create( + device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + cable = Cable(a_terminations=[channels[0]], b_terminations=[far]) + with self.assertRaises(ValidationError): + cable.clean() + + def test_108_unprofiled_cable_not_propagated(self): + """ + An unprofiled cable carries no per-channel positions, so its attributes are not mirrored onto the parent's + channel subinterfaces. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = Interface.objects.create( + device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + cable = Cable(a_terminations=[parent], b_terminations=[far]) + cable.clean() + cable.save() + + # The parent itself is cabled, but no cable attributes are mirrored onto the channels + parent.refresh_from_db() + self.assertEqual(parent.cable_id, cable.pk) + for channel in channels: + channel.refresh_from_db() + self.assertIsNone(channel.cable_id) + self.assertIsNone(channel.cable_positions) + + def test_109_change_channel_count_after_cabling(self): + """ + Increasing the channel count on an already-cabled parent re-propagates the cable to its existing channel + subinterfaces and rebuilds their paths (the Cable itself is unchanged, so only the post_save signal fires). + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + self.assertEqual(CablePath.objects.count(), 8) + + # Increase the channel count; the existing channels' paths must survive + parent.refresh_from_db() + parent.channels = 8 + parent.save() + + self.assertEqual(CablePath.objects.count(), 8) + for i, (channel, far_iface) in enumerate(zip(channels, far), start=1): + channel.refresh_from_db() + self.assertEqual(channel.cable_positions, [i]) + self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True) + + def test_110_move_channel_to_uncabled_parent(self): + """ + Moving a channel subinterface from a cabled parent to a channelized-but-uncabled parent tears down the + channel's mirrored cable attributes and its (now orphaned) path. + """ + parent, channels = self._create_channelized_interface('et0', 4) + far = [ + Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS) + for i in range(4) + ] + cable = Cable( + profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, + a_terminations=[parent], + b_terminations=far, + ) + cable.clean() + cable.save() + + # A second channelized parent with no cable + uncabled_parent = Interface.objects.create( + device=self.device, name='et1', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + + # Move the first channel to the uncabled parent; its mirrored cable & path must be torn down + channel = channels[0] + channel.refresh_from_db() + self.assertEqual(channel.cable_id, cable.pk) + channel.parent = uncabled_parent + channel.save() + + channel.refresh_from_db() + self.assertIsNone(channel.cable_id) + self.assertIsNone(channel.cable_connector) + self.assertIsNone(channel.cable_positions) + self.assertPathIsNotSet(channel) + self.assertPathDoesNotExist((channel, cable, far[0])) + self.assertPathDoesNotExist((far[0], cable, channel)) + + +class ChannelizedInterfaceValidationTestCase(TestCase): + """ + Test validation of the channels and channel_id fields on Interface. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device') + role = DeviceRole.objects.create(name='Device Role', slug='device-role') + site = Site.objects.create(name='Site', slug='site') + cls.device = Device.objects.create(site=site, device_type=device_type, role=role, name='Device 1') + cls.parent = Interface.objects.create( + device=cls.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + + def test_valid_channel_subinterface(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + interface.full_clean() # Should not raise + + def test_channel_type_requires_channel_id(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channel_id_requires_channel_type(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + parent=self.parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channel_requires_parent(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, channel_id=1 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channel_requires_channelized_parent(self): + plain_parent = Interface.objects.create( + device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + interface = Interface( + device=self.device, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=plain_parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channel_id_within_parent_range(self): + interface = Interface( + device=self.device, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=5 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channels_and_channel_id_mutually_exclusive(self): + interface = Interface( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=self.parent, channel_id=1, channels=4 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_channels_not_allowed_on_virtual_type(self): + interface = Interface( + device=self.device, name='vlan10', type=InterfaceTypeChoices.TYPE_VIRTUAL, channels=4 + ) + with self.assertRaises(ValidationError): + interface.full_clean() + + def test_reduce_channels_below_bound_child_rejected(self): + # Bind a channel to the highest channel of the parent, then attempt to reduce the parent's channel count + Interface.objects.create( + device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=4 + ) + self.parent.channels = 2 + with self.assertRaises(ValidationError): + self.parent.full_clean() + + def test_clear_channels_with_bound_child_rejected(self): + # De-channelizing a parent entirely must be rejected while any channel subinterface is still bound to it + Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + self.parent.channels = None + with self.assertRaises(ValidationError): + self.parent.full_clean() + + def test_clear_channels_without_bound_child_allowed(self): + # De-channelizing is permitted once no channel subinterfaces remain bound to the parent + self.parent.channels = None + self.parent.full_clean() # Should not raise + + def test_parent_channel_id_must_be_unique(self): + Interface.objects.create( + device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + duplicate = Interface( + device=self.device, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + duplicate.full_clean() + + +class ChannelizedInterfaceTemplateTestCase(TestCase): + """ + Test that the channels, channel_id, and parent fields are replicated from InterfaceTemplate to the Interfaces + instantiated for a new Device, and that parent interfaces are populated before their channel subinterfaces. + """ + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device') + cls.role = DeviceRole.objects.create(name='Device Role', slug='device-role') + cls.site = Site.objects.create(name='Site', slug='site') + + # A channelized parent template broken out into four channel subinterface templates bound to it + parent_template = InterfaceTemplate.objects.create( + device_type=cls.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + for i in range(1, 5): + InterfaceTemplate.objects.create( + device_type=cls.device_type, + name=f'et0:{i}', + type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=parent_template, + channel_id=i, + ) + + def test_channelization_replicated_on_instantiation(self): + device = Device.objects.create( + site=self.site, device_type=self.device_type, role=self.role, name='Device 1' + ) + + # The channelized parent carries its channel count + parent = device.interfaces.get(name='et0') + self.assertEqual(parent.channels, 4) + self.assertIsNone(parent.channel_id) + + # Each channel subinterface carries its channel ID and is bound to the instantiated parent interface + for i in range(1, 5): + channel = device.interfaces.get(name=f'et0:{i}') + self.assertEqual(channel.channel_id, i) + self.assertIsNone(channel.channels) + self.assertEqual(channel.parent, parent) + + def test_parent_template_validation(self): + # A parent template must belong to the same device type + other_type = DeviceType.objects.create( + manufacturer=self.device_type.manufacturer, model='Other Device', slug='other-device' + ) + foreign_parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0') + template = InterfaceTemplate( + device_type=other_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=foreign_parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + template.full_clean() + + def test_template_parent_channel_id_must_be_unique(self): + parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0') + # Channel 1 already exists on the parent (created in setUpTestData) + duplicate = InterfaceTemplate( + device_type=self.device_type, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + duplicate.full_clean() + + def test_template_channel_id_within_parent_range(self): + # A channel_id beyond the parent's channel count is rejected at the template level + parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0') + template = InterfaceTemplate( + device_type=self.device_type, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=parent, channel_id=5 + ) + with self.assertRaises(ValidationError): + template.full_clean() + + def test_template_channel_requires_channelized_parent(self): + # A channel template bound to a non-channelized parent template is rejected + plain_parent = InterfaceTemplate.objects.create( + device_type=self.device_type, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + template = InterfaceTemplate( + device_type=self.device_type, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=plain_parent, channel_id=1 + ) + with self.assertRaises(ValidationError): + template.full_clean() + + def test_template_channel_id_requires_channel_type(self): + # A channel_id on a non-channel-type template is rejected + template = InterfaceTemplate( + device_type=self.device_type, name='xe1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + channel_id=1 + ) + with self.assertRaises(ValidationError): + template.full_clean() + + def test_template_reduce_channels_below_bound_child_rejected(self): + # Reducing a parent template's channel count below a bound child template's channel_id is rejected (channels + # 3 & 4 are bound in setUpTestData) + parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0') + parent.channels = 2 + with self.assertRaises(ValidationError): + parent.full_clean() + + def test_template_clear_channels_with_bound_child_rejected(self): + # De-channelizing a parent template entirely is rejected while a channel subinterface template is bound to it + parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0') + parent.channels = None + with self.assertRaises(ValidationError): + parent.full_clean() + + +class ChannelizedBulkCreateTestCase(ViewTestCase): + """ + Test channel_id pattern expansion when bulk-creating channel subinterfaces (and interface templates) so that each + generated object receives a distinct channel_id. + """ + + def setUp(self): + super().setUp() + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + self.device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device', slug='test-device' + ) + role = DeviceRole.objects.create(name='Device Role', slug='device-role') + site = Site.objects.create(name='Site', slug='site') + self.device = Device.objects.create( + site=site, device_type=self.device_type, role=role, name='Device 1' + ) + + def test_bulk_create_channel_subinterfaces(self): + parent = Interface.objects.create( + device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + self.add_permissions('dcim.add_interface', 'dcim.view_interface') + + request_data = { + 'device': self.device.pk, + 'name': 'et0:[1-4]', + 'type': InterfaceTypeChoices.TYPE_CHANNEL, + 'parent': parent.pk, + 'channel_id': '[1-4]', + } + response = self.client.post(reverse('dcim:interface_add'), request_data) + self.assertHttpStatus(response, 302) + + # Four channel subinterfaces are created, each bound to a distinct channel on the parent + channels = Interface.objects.filter(parent=parent).order_by('channel_id') + self.assertEqual(channels.count(), 4) + for i, channel in enumerate(channels, start=1): + self.assertEqual(channel.name, f'et0:{i}') + self.assertEqual(channel.channel_id, i) + + def test_bulk_create_channel_subinterface_templates(self): + parent = InterfaceTemplate.objects.create( + device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4 + ) + self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate') + + request_data = { + 'device_type': self.device_type.pk, + 'name': 'et0:[1-4]', + 'type': InterfaceTypeChoices.TYPE_CHANNEL, + 'parent': parent.pk, + 'channel_id': '[1-4]', + } + response = self.client.post(reverse('dcim:interfacetemplate_add'), request_data) + self.assertHttpStatus(response, 302) + + templates = InterfaceTemplate.objects.filter(parent=parent).order_by('channel_id') + self.assertEqual(templates.count(), 4) + for i, template in enumerate(templates, start=1): + self.assertEqual(template.name, f'et0:{i}') + self.assertEqual(template.channel_id, i) diff --git a/netbox/dcim/ui/panels.py b/netbox/dcim/ui/panels.py index 595776463..8b2ff929b 100644 --- a/netbox/dcim/ui/panels.py +++ b/netbox/dcim/ui/panels.py @@ -523,6 +523,7 @@ class InterfacePanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') label = attrs.TextAttr('label') type = attrs.ChoiceAttr('type') + channels = attrs.NumericAttr('channels') speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed')) duplex = attrs.ChoiceAttr('duplex') mtu = attrs.NumericAttr('mtu', label=_('MTU')) @@ -543,6 +544,7 @@ class RelatedInterfacesPanel(panels.ObjectAttributesPanel): title = _('Related Interfaces') parent = attrs.RelatedObjectAttr('parent', linkify=True) + channel_id = attrs.NumericAttr('channel_id', label=_('Channel ID')) bridge = attrs.RelatedObjectAttr('bridge', linkify=True) lag = attrs.RelatedObjectAttr('lag', linkify=True, label=_('LAG')) diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 546ead916..097774d7a 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -131,12 +131,29 @@ def create_cablepaths(objects): :param objects: Iterable of cabled objects (e.g. Interfaces) """ - from dcim.models import CablePath + from dcim.models import CablePath, Interface - # Arrange objects by cable connector. All objects with a null connector are grouped together. - origins = defaultdict(list) + # Expand any channelized interface into its channel subinterfaces. A channelized parent originates no path of its + # own; instead, each channel subinterface traces independently from the single connector position it occupies. + # Plain (non-channelized) origins pass through unchanged, keeping this expansion re-entrant so that + # rebuild_paths() -> create_cablepaths(cp.origins) does not re-expand the channel subinterfaces it already holds. + expanded = [] for obj in objects: - origins[obj.cable_connector].append(obj) + if isinstance(obj, Interface) and obj.channels: + expanded.extend(obj.child_interfaces.filter(channel_id__isnull=False, cable__isnull=False)) + else: + expanded.append(obj) + + # Arrange objects by cable connector. All objects with a null connector are grouped together. Channel + # subinterfaces must each originate their own path, as sharing a connector would otherwise collapse a group of + # siblings into a single malformed path. + origins = defaultdict(list) + for obj in expanded: + if isinstance(obj, Interface) and obj.channel_id: + if cp := CablePath.from_origin([obj]): + cp.save() + else: + origins[obj.cable_connector].append(obj) for connector, objects in origins.items(): if cp := CablePath.from_origin(objects): @@ -158,6 +175,54 @@ def rebuild_paths(terminations): create_cablepaths(cp.origins) +def rebuild_cable_paths(cable): + """ + Delete and rebuild every CablePath traversing the given Cable, tracing freshly from the Cable's current + terminations in both directions. Used when the channelization of a terminated interface changes (e.g. a channel + subinterface is added, moved, or removed) without the Cable itself being modified. + """ + from dcim.choices import CableEndChoices + from dcim.models import CablePath, CableTermination, PathEndpoint + + with transaction.atomic(using=router.db_for_write(CablePath)): + # Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints. + for cp in CablePath.objects.filter(_nodes__contains=cable): + cp.delete() + + a_terminations, b_terminations = [], [] + for ct in CableTermination.objects.filter(cable=cable): + if ct.cable_end == CableEndChoices.SIDE_A: + a_terminations.append(ct.termination) + else: + b_terminations.append(ct.termination) + + for nodes in (a_terminations, b_terminations): + if not nodes: + continue + if isinstance(nodes[0], PathEndpoint): + create_cablepaths(nodes) + else: + rebuild_paths(nodes) + + +def update_interface_parents(device, interface_templates, module=None): + """ + Used for device and module instantiation. Iterates all InterfaceTemplates with a parent assigned and applies it to + the actual interfaces. Must run after all interfaces have been instantiated (so that every parent interface exists) + and before update_interface_bridges() (so that channel subinterfaces validate against a populated parent). + """ + Interface = apps.get_model('dcim', 'Interface') + + for interface_template in interface_templates.exclude(parent=None): + interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module)) + interface.parent = Interface.objects.get( + device=device, + name=interface_template.parent.resolve_name(module=module) + ) + interface.full_clean() + interface.save() + + def update_interface_bridges(device, interface_templates, module=None): """ Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned diff --git a/netbox/utilities/forms/fields/expandable.py b/netbox/utilities/forms/fields/expandable.py index 2bc59c7ad..a17d74b96 100644 --- a/netbox/utilities/forms/fields/expandable.py +++ b/netbox/utilities/forms/fields/expandable.py @@ -10,6 +10,7 @@ from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipnetwork_ __all__ = ( 'ExpandableIPNetworkField', 'ExpandableNameField', + 'ExpandableNumericField', ) @@ -35,6 +36,19 @@ class ExpandableNameField(forms.CharField): return [value] +class ExpandableNumericField(ExpandableNameField): + """ + An ExpandableNameField intended for numeric values, yielding integer-compatible strings suitable for bulk creation. + Example: '[1-3]' => ['1', '2', '3'] + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Replace the inherited alphanumeric default with numeric-specific guidance (unless one was supplied) + if not kwargs.get('help_text'): + self.help_text = _("Numeric ranges are supported for bulk creation (example: [1-24]).") + + class ExpandableIPNetworkField(forms.CharField): """ A CharField that expands numeric range patterns in IPv4/IPv6 CIDR notation into multiple entries.