4867 multiple mac addresses (#17902)

* Create MACAddress model and migrations to convert existing .mac_address fields to standalone objects

* Add migrations

* All views/filtering working and documentation done; no unit tests yet

* Redo migrations following VLAN Translation

* Remove mac_address filter fields and add table columns for device/vm

* Remove unnecessary "bulk rename"

* Fix filterset tests for Device

* Fix filterset tests for Interface

* Fix tests on single-object forms

* Fix serializer tests

* Fix filterset tests for VMInterface

* Fix filterset tests for Device and VirtualMachine

* Move new field check into lookup_map iteration

* Fix general MACAddress filter tests

* Add GraphQL types/filters/schema

* Fix bulk edit/create tests (bulk editing Interfaces will be unsupported because of inheritance from ComponentBulkEditForm)

* Make mac_address read_only on InterfaceSerializer/VMInterfaceSerializer

* Undo unrelated work

* Cleanup unused IPAddress derived stuff

* API endpoints

* Add serializer objects to interface serializers

* Clean up unnecessary bulk create forms/views/routes

* Add SearchIndex and adjust indexable fields for Interface and VMInterface

* Reorganize MACAddress classes out of association with DeviceComponents

* Move MACAddressSerializer

* Enforce saving only a single is_primary MACAddress per interface/vminterface

* Perform is_primary validation on MACAddress model and just check if one already exists for the interface

* Remove form-level validation

* Fix check for current is_primary setting when reassigning

* Model cleanup

* Documentation notes and cleanup

* Simplify serializer and add ip_addresses

* Add to VMInterfaceSerializer too

* Style cleanup

* Standardize "MAC Address" instead of "MAC"

* Remove unused views

* Add is_primary field for bulk edit

* HTML cleanup and add copy-to-clipboard button

* Remove mac_address from Interface and VMInterface bulk-edit forms

* Add device and VM filtering

* Use combined assigned_object_parent in table to match structure of IPAddressTable

* Add GFK fields to MACAddressSerializer

* Reorganize "Addressing" sections to remove from proximity to "Device Components" and related groupings

* Clean up migrations

* Misc cleanup

* Add filterset test

* Remove mac_address field from interface forms

* Designate primary MAC address via a ForeignKey on the interface models

* Add serializer fields for primary_mac_address

* Update docs

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
bctiemann
2024-11-18 15:11:24 -05:00
committed by GitHub
parent b4f15092db
commit 353214098b
46 changed files with 1156 additions and 173 deletions

View File

@@ -2,6 +2,7 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.api.serializers_.devices import DeviceSerializer
from dcim.api.serializers_.device_components import MACAddressSerializer
from dcim.api.serializers_.platforms import PlatformSerializer
from dcim.api.serializers_.roles import DeviceRoleSerializer
from dcim.api.serializers_.sites import SiteSerializer
@@ -95,19 +96,18 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
count_ipaddresses = serializers.IntegerField(read_only=True)
count_fhrp_groups = serializers.IntegerField(read_only=True)
mac_address = serializers.CharField(
required=False,
default=None,
allow_null=True
)
# Maintains backward compatibility with NetBox <v4.2
mac_address = serializers.CharField(allow_null=True, read_only=True)
primary_mac_address = MACAddressSerializer(nested=True, required=False, allow_null=True)
mac_addresses = MACAddressSerializer(many=True, nested=True, read_only=True, allow_null=True)
class Meta:
model = VMInterface
fields = [
'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated',
'count_ipaddresses', 'count_fhrp_groups',
'mac_address', 'primary_mac_address', 'mac_addresses', 'description', 'mode', 'untagged_vlan',
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags',
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
]
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')

View File

@@ -2,9 +2,10 @@ import django_filters
from django.db.models import Q
from django.utils.translation import gettext as _
from dcim.filtersets import CommonInterfaceFilterSet
from dcim.base_filtersets import ScopedFilterSet
from dcim.filtersets import CommonInterfaceFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import MACAddress
from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet
@@ -191,7 +192,7 @@ class VirtualMachineFilterSet(
label=_('Platform (slug)'),
)
mac_address = MultiValueMACAddressFilter(
field_name='interfaces__mac_address',
field_name='interfaces__mac_addresses__mac_address',
label=_('MAC address'),
)
has_primary_ip = django_filters.BooleanFilter(
@@ -263,8 +264,20 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
label=_('Bridged interface (ID)'),
)
mac_address = MultiValueMACAddressFilter(
field_name='mac_addresses__mac_address',
label=_('MAC address'),
)
primary_mac_address_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_mac_address',
queryset=MACAddress.objects.all(),
label=_('Primary MAC address (ID)'),
)
primary_mac_address = django_filters.ModelMultipleChoiceFilter(
field_name='primary_mac_address__mac_address',
queryset=MACAddress.objects.all(),
to_field_name='mac_address',
label=_('Primary MAC address'),
)
class Meta:
model = VMInterface

View File

@@ -182,7 +182,7 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
class Meta:
model = VMInterface
fields = (
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode',
'vrf', 'tags'
)

View File

@@ -360,7 +360,7 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
fieldsets = (
FieldSet('virtual_machine', 'name', 'description', 'tags', name=_('Interface')),
FieldSet('vrf', 'mac_address', name=_('Addressing')),
FieldSet('vrf', 'primary_mac_address', name=_('Addressing')),
FieldSet('mtu', 'enabled', name=_('Operation')),
FieldSet('parent', 'bridge', name=_('Related Interfaces')),
FieldSet(
@@ -372,8 +372,9 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
class Meta:
model = VMInterface
fields = [
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags',
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', 'vlan_group',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'primary_mac_address',
'tags',
]
labels = {
'mode': _('802.1Q Mode'),

View File

@@ -106,12 +106,14 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
child_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
mac_addresses: List[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(

View File

@@ -0,0 +1,53 @@
import django.db.models.deletion
from django.db import migrations, models
def populate_mac_addresses(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType')
VMInterface = apps.get_model('virtualization', 'VMInterface')
MACAddress = apps.get_model('dcim', 'MACAddress')
vminterface_ct = ContentType.objects.get_for_model(VMInterface)
mac_addresses = [
MACAddress(
mac_address=vminterface.mac_address,
assigned_object_type=vminterface_ct,
assigned_object_id=vminterface.pk
)
for vminterface in VMInterface.objects.filter(mac_address__isnull=False)
]
MACAddress.objects.bulk_create(mac_addresses, batch_size=100)
# TODO: Optimize interface updates
for mac_address in mac_addresses:
VMInterface.objects.filter(pk=mac_address.assigned_object_id).update(primary_mac_address=mac_address)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0199_macaddress'),
('virtualization', '0047_natural_ordering'),
]
operations = [
migrations.AddField(
model_name='vminterface',
name='primary_mac_address',
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='dcim.macaddress'
),
),
migrations.RunPython(
code=populate_mac_addresses,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name='vminterface',
name='mac_address',
),
]

View File

@@ -348,6 +348,12 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
object_id_field='assigned_object_id',
related_query_name='vminterface',
)
mac_addresses = GenericRelation(
to='dcim.MACAddress',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='vminterface'
)
class Meta(ComponentModel.Meta):
verbose_name = _('interface')

View File

@@ -52,11 +52,10 @@ class VMInterfaceIndex(SearchIndex):
model = models.VMInterface
fields = (
('name', 100),
('mac_address', 300),
('description', 500),
('mtu', 2000),
)
display_attrs = ('virtual_machine', 'mac_address', 'description')
display_attrs = ('virtual_machine', 'description')
@register_search

View File

@@ -25,6 +25,9 @@ VMINTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %}
<li><a class="dropdown-item" href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">IP Address</a></li>
{% endif %}
{% if perms.dcim.add_macaddress %}
<li><a class="dropdown-item" href="{% url 'dcim:macaddress_add' %}?vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">MAC Address</a></li>
{% endif %}
{% if perms.vpn.add_l2vpntermination %}
<li><a class="dropdown-item" href="{% url 'vpn:l2vpntermination_add' %}?virtual_machine={{ object.pk }}&vminterface={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">L2VPN Termination</a></li>
{% endif %}
@@ -150,8 +153,8 @@ class VMInterfaceTable(BaseInterfaceTable):
model = VMInterface
fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'created', 'last_updated',
'vrf', 'primary_mac_address', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
'tagged_vlans', 'qinq_svlan', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')

View File

@@ -1,7 +1,7 @@
from django.test import TestCase
from dcim.choices import InterfaceModeChoices
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from dcim.models import Device, DeviceRole, MACAddress, Platform, Region, Site, SiteGroup
from ipam.choices import VLANQinQRoleChoices
from ipam.models import IPAddress, VLAN, VLANTranslationPolicy, VRF
from tenancy.models import Tenant, TenantGroup
@@ -366,13 +366,24 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
)
VirtualMachine.objects.bulk_create(vms)
mac_addresses = (
MACAddress(mac_address='00-00-00-00-00-01'),
MACAddress(mac_address='00-00-00-00-00-02'),
MACAddress(mac_address='00-00-00-00-00-03'),
)
MACAddress.objects.bulk_create(mac_addresses)
interfaces = (
VMInterface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
VMInterface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'),
VMInterface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'),
VMInterface(virtual_machine=vms[0], name='Interface 1'),
VMInterface(virtual_machine=vms[1], name='Interface 2'),
VMInterface(virtual_machine=vms[2], name='Interface 3'),
)
VMInterface.objects.bulk_create(interfaces)
interfaces[0].mac_addresses.set([mac_addresses[0]])
interfaces[1].mac_addresses.set([mac_addresses[1]])
interfaces[2].mac_addresses.set([mac_addresses[2]])
# Assign primary IPs for filtering
ipaddresses = (
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
@@ -579,13 +590,19 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies)
mac_addresses = (
MACAddress(mac_address='00-00-00-00-00-01'),
MACAddress(mac_address='00-00-00-00-00-02'),
MACAddress(mac_address='00-00-00-00-00-03'),
)
MACAddress.objects.bulk_create(mac_addresses)
interfaces = (
VMInterface(
virtual_machine=vms[0],
name='Interface 1',
enabled=True,
mtu=100,
mac_address='00-00-00-00-00-01',
vrf=vrfs[0],
description='foobar1',
vlan_translation_policy=vlan_translation_policies[0],
@@ -595,7 +612,6 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
name='Interface 2',
enabled=True,
mtu=200,
mac_address='00-00-00-00-00-02',
vrf=vrfs[1],
description='foobar2',
vlan_translation_policy=vlan_translation_policies[0],
@@ -605,7 +621,6 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
name='Interface 3',
enabled=False,
mtu=300,
mac_address='00-00-00-00-00-03',
vrf=vrfs[2],
description='foobar3',
mode=InterfaceModeChoices.MODE_Q_IN_Q,
@@ -614,6 +629,10 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
VMInterface.objects.bulk_create(interfaces)
interfaces[0].mac_addresses.set([mac_addresses[0]])
interfaces[1].mac_addresses.set([mac_addresses[1]])
interfaces[2].mac_addresses.set([mac_addresses[2]])
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@@ -1,7 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from netaddr import EUI
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site
@@ -331,7 +330,6 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name': 'Interface X',
'enabled': False,
'bridge': interfaces[1].pk,
'mac_address': EUI('01-02-03-04-05-06'),
'mtu': 65000,
'description': 'New description',
'mode': InterfaceModeChoices.MODE_TAGGED,
@@ -346,7 +344,6 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name': 'Interface [4-6]',
'enabled': False,
'bridge': interfaces[3].pk,
'mac_address': EUI('01-02-03-04-05-06'),
'mtu': 2000,
'description': 'New description',
'mode': InterfaceModeChoices.MODE_TAGGED,