Skip to content

Commit

Permalink
Closes #13428: Q-in-Q VLANs (#17822)
Browse files Browse the repository at this point in the history
* Initial work on #13428 (QinQ)

* Misc cleanup; add tests for Q-in-Q fields

* Address PR feedback
  • Loading branch information
jeremystretch authored Oct 31, 2024
1 parent a8eb455 commit 8767fd8
Show file tree
Hide file tree
Showing 40 changed files with 492 additions and 70 deletions.
4 changes: 4 additions & 0 deletions docs/models/dcim/interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ The "native" (untagged) VLAN for the interface. Valid only when one of the above

The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above.

### Q-in-Q SVLAN

The assigned service VLAN (for Q-in-Q/802.1ad interfaces).

### Wireless Role

Indicates the configured role for wireless interfaces (access point or station).
Expand Down
8 changes: 8 additions & 0 deletions docs/models/ipam/vlan.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,11 @@ The user-defined functional [role](./role.md) assigned to the VLAN.
### VLAN Group or Site

The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned.

### Q-in-Q Role

For VLANs which comprise a Q-in-Q/IEEE 802.1ad topology, this field indicates whether the VLAN is treated as a service or customer VLAN.

### Q-in-Q Service VLAN

The designated parent service VLAN for a Q-in-Q customer VLAN. This may be set only for Q-in-Q custom VLANs.
4 changes: 4 additions & 0 deletions docs/models/virtualization/vminterface.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ The "native" (untagged) VLAN for the interface. Valid only when one of the above

The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above.

### Q-in-Q SVLAN

The assigned service VLAN (for Q-in-Q/802.1ad interfaces).

### VRF

The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned.
Expand Down
9 changes: 5 additions & 4 deletions netbox/dcim/api/serializers_/device_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
required=False,
many=True
)
qinq_svlan = VLANSerializer(nested=True, required=False, allow_null=True)
vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
vrf = VRFSerializer(nested=True, required=False, allow_null=True)
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
Expand Down Expand Up @@ -223,10 +224,10 @@ class Meta:
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
'parent', 'bridge', 'lag', 'mtu', 'mac_address', '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', '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', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'vlan_translation_policy'
'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',
'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')

Expand Down
2 changes: 2 additions & 0 deletions netbox/dcim/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -1258,11 +1258,13 @@ class InterfaceModeChoices(ChoiceSet):
MODE_ACCESS = 'access'
MODE_TAGGED = 'tagged'
MODE_TAGGED_ALL = 'tagged-all'
MODE_Q_IN_Q = 'q-in-q'

CHOICES = (
(MODE_ACCESS, _('Access')),
(MODE_TAGGED, _('Tagged')),
(MODE_TAGGED_ALL, _('Tagged (All)')),
(MODE_Q_IN_Q, _('Q-in-Q (802.1ad)')),
)


Expand Down
6 changes: 4 additions & 2 deletions netbox/dcim/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1647,7 +1647,8 @@ def filter_vlan_id(self, queryset, name, value):
return queryset
return queryset.filter(
Q(untagged_vlan_id=value) |
Q(tagged_vlans=value)
Q(tagged_vlans=value) |
Q(qinq_svlan=value)
)

def filter_vlan(self, queryset, name, value):
Expand All @@ -1656,7 +1657,8 @@ def filter_vlan(self, queryset, name, value):
return queryset
return queryset.filter(
Q(untagged_vlan_id__vid=value) |
Q(tagged_vlans__vid=value)
Q(tagged_vlans__vid=value) |
Q(qinq_svlan__vid=value)
)


Expand Down
2 changes: 2 additions & 0 deletions netbox/dcim/forms/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ def __init__(self, *args, **kwargs):
del self.fields['vlan_group']
del self.fields['untagged_vlan']
del self.fields['tagged_vlans']
if interface_mode != InterfaceModeChoices.MODE_Q_IN_Q:
del self.fields['qinq_svlan']

def clean(self):
super().clean()
Expand Down
18 changes: 16 additions & 2 deletions netbox/dcim/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from dcim.constants import *
from dcim.models import *
from extras.models import ConfigTemplate
from ipam.choices import VLANQinQRoleChoices
from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
Expand Down Expand Up @@ -1372,6 +1373,16 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
'available_on_device': '$device',
}
)
qinq_svlan = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
label=_('Q-in-Q Service VLAN'),
query_params={
'group_id': '$vlan_group',
'available_on_device': '$device',
'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE,
}
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
Expand All @@ -1396,7 +1407,10 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')),
FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
name=_('802.1Q Switching')
),
FieldSet(
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
name=_('Wireless')
Expand All @@ -1409,7 +1423,7 @@ class Meta:
'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
'mac_address', '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', 'vrf', 'tags', 'vlan_translation_policy',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags',
]
widgets = {
'speed': NumberWithOptions(
Expand Down
1 change: 1 addition & 0 deletions netbox/dcim/graphql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None

vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
Expand Down
28 changes: 28 additions & 0 deletions netbox/dcim/migrations/0196_qinq_svlan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dcim', '0195_interface_vlan_translation_policy'),
('ipam', '0075_vlan_qinq'),
]

operations = [
migrations.AddField(
model_name='interface',
name='qinq_svlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_svlan', to='ipam.vlan'),
),
migrations.AlterField(
model_name='interface',
name='tagged_vlans',
field=models.ManyToManyField(blank=True, related_name='%(class)ss_as_tagged', to='ipam.vlan'),
),
migrations.AlterField(
model_name='interface',
name='untagged_vlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_as_untagged', to='ipam.vlan'),
),
]
47 changes: 32 additions & 15 deletions netbox/dcim/models/device_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,17 +547,48 @@ class BaseInterface(models.Model):
blank=True,
verbose_name=_('bridge interface')
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
related_name='%(class)ss_as_untagged',
null=True,
blank=True,
verbose_name=_('untagged VLAN')
)
tagged_vlans = models.ManyToManyField(
to='ipam.VLAN',
related_name='%(class)ss_as_tagged',
blank=True,
verbose_name=_('tagged VLANs')
)
qinq_svlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
related_name='%(class)ss_svlan',
null=True,
blank=True,
verbose_name=_('Q-in-Q SVLAN')
)
vlan_translation_policy = models.ForeignKey(
to='ipam.VLANTranslationPolicy',
on_delete=models.PROTECT,
null=True,
blank=True,
verbose_name=_('VLAN Translation Policy'),
verbose_name=_('VLAN Translation Policy')
)

class Meta:
abstract = True

def clean(self):
super().clean()

# SVLAN can be defined only for Q-in-Q interfaces
if self.qinq_svlan and self.mode != InterfaceModeChoices.MODE_Q_IN_Q:
raise ValidationError({
'qinq_svlan': _("Only Q-in-Q interfaces may specify a service VLAN.")
})

def save(self, *args, **kwargs):

# Remove untagged VLAN assignment for non-802.1Q interfaces
Expand Down Expand Up @@ -697,20 +728,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
blank=True,
verbose_name=_('wireless LANs')
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
related_name='interfaces_as_untagged',
null=True,
blank=True,
verbose_name=_('untagged VLAN')
)
tagged_vlans = models.ManyToManyField(
to='ipam.VLAN',
related_name='interfaces_as_tagged',
blank=True,
verbose_name=_('tagged VLANs')
)
vrf = models.ForeignKey(
to='ipam.VRF',
on_delete=models.SET_NULL,
Expand Down
16 changes: 10 additions & 6 deletions netbox/dcim/tables/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,10 @@ class BaseInterfaceTable(NetBoxTable):
orderable=False,
verbose_name=_('Tagged VLANs')
)
qinq_svlan = tables.Column(
verbose_name=_('Q-in-Q SVLAN'),
linkify=True
)

def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()])
Expand Down Expand Up @@ -635,11 +639,11 @@ 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_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', 'inventory_items', 'created',
'last_updated',
'speed', 'speed_formatted', 'duplex', 'mode', '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',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

Expand Down Expand Up @@ -676,7 +680,7 @@ class Meta(DeviceComponentTable.Meta):
'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', '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', 'actions',
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
Expand Down
6 changes: 6 additions & 0 deletions netbox/dcim/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from dcim.constants import *
from dcim.models import *
from extras.models import ConfigTemplate
from ipam.choices import VLANQinQRoleChoices
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant
Expand Down Expand Up @@ -1618,6 +1619,7 @@ def setUpTestData(cls):
VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2),
VLAN(name='VLAN 3', vid=3),
VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
)
VLAN.objects.bulk_create(vlans)

Expand Down Expand Up @@ -1676,18 +1678,22 @@ def setUpTestData(cls):
'vdcs': [vdcs[1].pk],
'name': 'Interface 7',
'type': InterfaceTypeChoices.TYPE_80211A,
'mode': InterfaceModeChoices.MODE_Q_IN_Q,
'tx_power': 10,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'rf_channel': WirelessChannelChoices.CHANNEL_5G_32,
'qinq_svlan': vlans[3].pk,
},
{
'device': device.pk,
'vdcs': [vdcs[1].pk],
'name': 'Interface 8',
'type': InterfaceTypeChoices.TYPE_80211A,
'mode': InterfaceModeChoices.MODE_Q_IN_Q,
'tx_power': 10,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'rf_channel': "",
'qinq_svlan': vlans[3].pk,
},
]

Expand Down
Loading

0 comments on commit 8767fd8

Please sign in to comment.