Skip to content

Commit 96cdddd

Browse files
committed
Add cycle identification
Use a depth-first search to identify any cycles, to be able to report them as such to the user.
1 parent c158bfa commit 96cdddd

File tree

2 files changed

+81
-9
lines changed

2 files changed

+81
-9
lines changed

netbox/dcim/tests/test_views.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,16 +1148,68 @@ def test_import_interfacebridge_cycle(self):
11481148
model: TEST-5000
11491149
slug: test-5000
11501150
u_height: 1
1151+
interfaces:
1152+
- name: Cycle Interface 1
1153+
type: 1000base-t
1154+
bridge: Cycle Interface 2
1155+
- name: Cycle Interface 2
1156+
type: 1000base-t
1157+
bridge: Cycle Interface 3
1158+
- name: Cycle Interface 3
1159+
type: 1000base-t
1160+
bridge: Cycle Interface 1
1161+
1162+
- name: Unrelated Interface 1
1163+
type: 1000base-t
1164+
- name: Unrelated Interface 2
1165+
type: 1000base-t
1166+
bridge: Cycle Interface 1
1167+
- name: Unrelated Interface 3
1168+
type: 1000base-t
1169+
bridge: Cycle Interface 3
1170+
"""
1171+
1172+
# Add all required permissions to the test user
1173+
self.add_permissions(
1174+
'dcim.view_devicetype',
1175+
'dcim.add_devicetype',
1176+
'dcim.add_consoleporttemplate',
1177+
'dcim.add_consoleserverporttemplate',
1178+
'dcim.add_powerporttemplate',
1179+
'dcim.add_poweroutlettemplate',
1180+
'dcim.add_interfacetemplate',
1181+
'dcim.add_frontporttemplate',
1182+
'dcim.add_rearporttemplate',
1183+
'dcim.add_modulebaytemplate',
1184+
'dcim.add_devicebaytemplate',
1185+
'dcim.add_inventoryitemtemplate',
1186+
)
1187+
1188+
form_data = {
1189+
'data': IMPORT_DATA,
1190+
'format': 'yaml'
1191+
}
1192+
1193+
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
1194+
self.assertHttpStatus(response, 200)
1195+
self.assertContains(response, "interfaces: Dependency cycle [Cycle Interface 1, Cycle Interface 2, "
1196+
"Cycle Interface 3, Cycle Interface 1] detected")
1197+
1198+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
1199+
def test_import_interfacebridge_invalid(self):
1200+
IMPORT_DATA = """
1201+
manufacturer: Manufacturer 1
1202+
model: TEST-6000
1203+
slug: test-6000
1204+
u_height: 1
11511205
interfaces:
11521206
- name: Interface 1
11531207
type: 1000base-t
1154-
bridge: Interface 2
11551208
- name: Interface 2
11561209
type: 1000base-t
1157-
bridge: Interface 3
1210+
bridge: Non-existent Bridge
11581211
- name: Interface 3
11591212
type: 1000base-t
1160-
bridge: Interface 1
11611213
"""
11621214

11631215
# Add all required permissions to the test user
@@ -1183,8 +1235,8 @@ def test_import_interfacebridge_cycle(self):
11831235

11841236
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
11851237
self.assertHttpStatus(response, 200)
1186-
self.assertContains(response, "interfaces: Dependency cycle detected in subset "
1187-
"[Interface 1, Interface 2, Interface 3]")
1238+
self.assertContains(response, "interfaces[1] bridge: Select a valid choice. "
1239+
"That choice is not one of the available choices.")
11881240

11891241
def test_export_objects(self):
11901242
url = reverse('dcim:devicetype_list')

netbox/dcim/views.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,10 +1339,30 @@ def _sort_interfaces(self, interfaces):
13391339

13401340
unsatisfied_requirements = list(ifname for ifname, deps in requires.items() if deps)
13411341
if unsatisfied_requirements:
1342-
raise ValidationError(
1343-
_("Dependency cycle detected in subset [%(interfaces)s]"),
1344-
params={"interfaces": ", ".join(unsatisfied_requirements)},
1345-
)
1342+
def find_cycle(visited_interfaces):
1343+
"""Recursive depth-first search to identify cycles."""
1344+
for ifname in list(required_by.get(visited_interfaces[-1], list())):
1345+
if ifname in visited_interfaces:
1346+
# found a cycle and its start
1347+
start_index = visited_interfaces.index(ifname)
1348+
visited_interfaces.append(ifname)
1349+
return list(reversed(visited_interfaces[start_index:]))
1350+
result = find_cycle(visited_interfaces + [ifname])
1351+
if result:
1352+
return result
1353+
return None
1354+
1355+
# Check if there is a cycle
1356+
for ifname in unsatisfied_requirements:
1357+
cycle = find_cycle([ifname])
1358+
if cycle:
1359+
# stop at the first one, finding all while avoiding duplicates would be hard
1360+
raise ValidationError(
1361+
_("Dependency cycle [%(interfaces)s] detected"),
1362+
params={"interfaces": ", ".join(cycle)},
1363+
)
1364+
# no cycle, so the unsatisfied requirements must be due to requirements on non-existent interfaces,
1365+
# which will cause a validation error later when checking the individual interface objects.
13461366

13471367
# apply the topological sorting to the actual list
13481368
def get_sort_key(interface):

0 commit comments

Comments
 (0)