Skip to content

Commit

Permalink
Initial work on #13428 (QinQ)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremystretch committed Oct 21, 2024
1 parent ef1fdf0 commit ddcff55
Show file tree
Hide file tree
Showing 29 changed files with 349 additions and 57 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.
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.
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)
vrf = VRFSerializer(nested=True, required=False, allow_null=True)
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
wireless_link = NestedWirelessLinkSerializer(read_only=True, allow_null=True)
Expand All @@ -222,10 +223,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',
'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', '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 @@ -1636,7 +1636,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 @@ -1645,7 +1646,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
15 changes: 13 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, 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 @@ -1391,7 +1402,7 @@ 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', name=_('802.1Q Switching')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', name=_('802.1Q Switching')),
FieldSet(
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
name=_('Wireless')
Expand All @@ -1404,7 +1415,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',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'tags',
]
widgets = {
'speed': NumberWithOptions(
Expand Down
30 changes: 30 additions & 0 deletions netbox/dcim/migrations/0195_qinq_svlan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 5.0.9 on 2024-10-21 20:26

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dcim', '0194_charfield_null_choices'),
('ipam', '0074_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'),
),
]
45 changes: 31 additions & 14 deletions netbox/dcim/models/device_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,10 +547,41 @@ 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-inQ SVLAN')
)

class Meta:
abstract = True

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

# Virtual Interfaces cannot have a Cable attached
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 @@ -690,20 +721,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
8 changes: 8 additions & 0 deletions netbox/ipam/api/serializers_/nested.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

__all__ = (
'NestedIPAddressSerializer',
'NestedVLANSerializer',
)


Expand All @@ -16,3 +17,10 @@ class NestedIPAddressSerializer(WritableNestedSerializer):
class Meta:
model = models.IPAddress
fields = ['id', 'url', 'display_url', 'display', 'family', 'address']


class NestedVLANSerializer(WritableNestedSerializer):

class Meta:
model = models.VLAN
fields = ['id', 'url', 'display', 'vid', 'name', 'description']
7 changes: 5 additions & 2 deletions netbox/ipam/api/serializers_/vlans.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
from .nested import NestedVLANSerializer
from .roles import RoleSerializer

__all__ = (
Expand Down Expand Up @@ -62,6 +63,8 @@ class VLANSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
status = ChoiceField(choices=VLANStatusChoices, required=False)
role = RoleSerializer(nested=True, required=False, allow_null=True)
qinq_role = ChoiceField(choices=VLANQinQRoleChoices, required=False)
qinq_svlan = NestedVLANSerializer(required=False, allow_null=True)
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)

# Related object counts
Expand All @@ -71,8 +74,8 @@ class Meta:
model = VLAN
fields = [
'id', 'url', 'display_url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role',
'description', 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated',
'prefix_count',
'description', 'qinq_role', 'qinq_svlan', 'comments', 'l2vpn_termination', 'tags', 'custom_fields',
'created', 'last_updated', 'prefix_count',
]
brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')

Expand Down
11 changes: 11 additions & 0 deletions netbox/ipam/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ class VLANStatusChoices(ChoiceSet):
]


class VLANQinQRoleChoices(ChoiceSet):

ROLE_SERVICE = 's-vlan'
ROLE_CUSTOMER = 'c-vlan'

CHOICES = [
(ROLE_SERVICE, _('Service'), 'blue'),
(ROLE_CUSTOMER, _('Customer'), 'orange'),
]


#
# Services
#
Expand Down
12 changes: 12 additions & 0 deletions netbox/ipam/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,18 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
queryset=VirtualMachine.objects.all(),
method='get_for_virtualmachine'
)
qinq_role = django_filters.MultipleChoiceFilter(
choices=VLANQinQRoleChoices,
null_value=None
)
qinq_svlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(),
label=_('Q-in-Q SVLAN (ID)'),
)
qinq_svlan_vid = django_filters.NumberFilter(
field_name='qinq_svlan__vid',
label=_('Q-in-Q SVLAN number (1-4094)'),
)
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
Expand Down
8 changes: 7 additions & 1 deletion netbox/ipam/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,15 +525,21 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
qinq_role = forms.ChoiceField(
label=_('Status'),
choices=add_blank_choice(VLANQinQRoleChoices),
required=False
)
comments = CommentField()

model = VLAN
fieldsets = (
FieldSet('status', 'role', 'tenant', 'description'),
FieldSet('qinq_role', 'qinq_svlan', name=_('Q-in-Q')),
FieldSet('region', 'site_group', 'site', 'group', name=_('Site & Group')),
)
nullable_fields = (
'site', 'group', 'tenant', 'role', 'description', 'comments',
'site', 'group', 'tenant', 'role', 'description', 'qinq_role', 'qinq_svlan', 'comments',
)


Expand Down
Loading

0 comments on commit ddcff55

Please sign in to comment.