From 98c94f5fc71bb6a4a9824326ac7d73f70ffbedc7 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Wed, 22 Jul 2026 17:43:26 +0200 Subject: [PATCH] Fixes #22720: Fix Virtual Chassis deletion with cross-chassis LAGs (#22740) --- netbox/dcim/models/devices.py | 11 ++++--- netbox/dcim/tests/test_models.py | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index e8f82d58e..8952b117b 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1229,10 +1229,13 @@ class VirtualChassis(PrimaryModel): lag__device=F('device') ) if interfaces: - raise ProtectedError(_( - "Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG " - "interfaces." - ).format(self=self, interfaces=InterfaceSpeedChoices)) + raise ProtectedError( + _( + "Unable to delete virtual chassis {virtual_chassis}. One or more member interfaces form a " + "cross-chassis LAG." + ).format(virtual_chassis=self), + set(interfaces), + ) # Clear vc_position and vc_priority on member devices BEFORE calling super().delete() # This must be done here because on_delete=SET_NULL executes before pre_delete signal diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index d450049a6..772036ecc 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,6 +1,7 @@ from decimal import Decimal from django.core.exceptions import ValidationError +from django.db.models import ProtectedError from django.db.models.signals import post_save from django.test import TestCase, tag @@ -2429,6 +2430,59 @@ class VirtualChassisTestCase(TestCase): self.assertIsNone(device2.vc_position) self.assertIsNone(device2.vc_priority) + @tag('regression') # Ref: #22720 + def test_virtualchassis_deletion_blocked_by_cross_chassis_lag(self): + """ + Deleting a VirtualChassis whose members form a cross-chassis LAG must + raise ProtectedError exposing the blocking interfaces, leaving the VC + and its member assignments unchanged. + """ + device1 = Device.objects.get(name='TestDevice1') + device2 = Device.objects.get(name='TestDevice2') + + vc = VirtualChassis.objects.create(name='Test VC', master=device1) + + device1.virtual_chassis = vc + device1.vc_position = 1 + device1.vc_priority = 10 + device1.save() + + device2.virtual_chassis = vc + device2.vc_position = 2 + device2.vc_priority = 20 + device2.save() + + lag = Interface.objects.create(device=device1, name='lag0', type=InterfaceTypeChoices.TYPE_LAG) + member_interface = Interface( + device=device2, + name='eth0', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + lag=lag, + ) + # A cross-chassis LAG member is valid while both devices share the VC + member_interface.full_clean() + member_interface.save() + + with self.assertRaises(ProtectedError) as cm: + vc.delete() + + self.assertEqual( + cm.exception.args[0], + 'Unable to delete virtual chassis Test VC. One or more member interfaces form a cross-chassis LAG.' + ) + self.assertEqual(set(cm.exception.protected_objects), {member_interface}) + + # The failed deletion must not clear the VC or its member assignments + self.assertTrue(VirtualChassis.objects.filter(pk=vc.pk).exists()) + device1.refresh_from_db() + device2.refresh_from_db() + self.assertEqual(device1.virtual_chassis, vc) + self.assertEqual(device1.vc_position, 1) + self.assertEqual(device1.vc_priority, 10) + self.assertEqual(device2.virtual_chassis, vc) + self.assertEqual(device2.vc_position, 2) + self.assertEqual(device2.vc_priority, 20) + def test_virtualchassis_duplicate_vc_position(self): """ Test that two devices cannot be assigned to the same vc_position