Skip to content

Commit 82ecf9d

Browse files
authored
feat: prevent duplicate component creation from templates (#143)
1 parent 41ff9c9 commit 82ecf9d

File tree

3 files changed

+261
-10
lines changed

3 files changed

+261
-10
lines changed

docker/docker-compose.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ services:
1313
env_file: netbox/env/netbox.env
1414
user: 'unit:root'
1515
healthcheck:
16-
start_period: 60s
16+
start_period: 180s
1717
timeout: 3s
1818
interval: 15s
1919
test: "curl -f http://localhost:8080/netbox/api/ || exit 1"

netbox_diode_plugin/api/applier.py

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,67 @@ def apply_changeset(change_set: ChangeSet, request) -> ChangeSetResult:
5454
id=change_set.id,
5555
)
5656

57+
def _is_auto_created_component(object_type: str) -> bool:
58+
"""Check if the object type is auto-created from templates."""
59+
auto_created_components = [
60+
"dcim.consoleport",
61+
"dcim.consoleserverport",
62+
"dcim.powerport",
63+
"dcim.poweroutlet",
64+
"dcim.interface",
65+
"dcim.rearport",
66+
"dcim.frontport",
67+
"dcim.modulebay",
68+
"dcim.devicebay",
69+
"dcim.inventoryitem",
70+
]
71+
return object_type in auto_created_components
72+
73+
74+
def _try_find_and_update_existing_instance(data: dict, object_type: str, serializer_class, request):
75+
"""Try to find existing auto-created instance and update it."""
76+
try:
77+
instance = find_existing_object(data, object_type)
78+
if instance:
79+
serializer = serializer_class(instance, data=data, partial=True, context={"request": request})
80+
serializer.is_valid(raise_exception=True)
81+
return serializer.save()
82+
except (ValueError, TypeError) as e:
83+
logger.debug(f"Could not find existing {object_type}: {e}")
84+
return None
85+
86+
87+
def _create_or_find_instance(data: dict, object_type: str, serializer_class, request):
88+
"""Create new instance or find existing one on conflict."""
89+
serializer = serializer_class(data=data, context={"request": request})
90+
try:
91+
serializer.is_valid(raise_exception=True)
92+
return serializer.save()
93+
except ValidationError as e:
94+
instance = find_existing_object(data, object_type)
95+
if not instance:
96+
raise e
97+
return instance
98+
99+
57100
def _apply_change(data: dict, model_class: models.Model, change: Change, created: dict, request):
58101
serializer_class = get_serializer_for_model(model_class)
59102
change_type = change.change_type
103+
60104
if change_type == ChangeType.CREATE.value:
61-
serializer = serializer_class(data=data, context={"request": request})
62-
try:
63-
serializer.is_valid(raise_exception=True)
64-
instance = serializer.save()
65-
except ValidationError as e:
66-
instance = find_existing_object(data, change.object_type)
67-
if not instance:
68-
raise e
69-
created[change.ref_id] = instance
105+
# For component types that may be auto-created from e.g. DeviceType or ModuleType templates,
106+
# try to find existing object first before attempting to create.
107+
# This prevents duplicates when components are instantiated during Device/Module save()
108+
instance = None
109+
if _is_auto_created_component(change.object_type):
110+
instance = _try_find_and_update_existing_instance(data, change.object_type, serializer_class, request)
111+
112+
if not instance:
113+
instance = _create_or_find_instance(data, change.object_type, serializer_class, request)
114+
115+
# Always add the instance to created dict so it can be referenced by subsequent changes
116+
if change.ref_id:
117+
created[change.ref_id] = instance
70118

71119
elif change_type == ChangeType.UPDATE.value:
72120
if object_id := change.object_id:

netbox_diode_plugin/tests/test_api_apply_change_set.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,3 +931,206 @@ def test_apply_two_changes_that_create_the_same_object_return_200(self):
931931
],
932932
}
933933
_ = self.send_request(payload2)
934+
935+
def test_module_bay_from_template_no_duplicate(self):
936+
"""Test that module bays created from templates are reused and updated, not duplicated."""
937+
from dcim.models import Module, ModuleBay, ModuleBayTemplate, ModuleType
938+
939+
# Create a device type with a module bay template
940+
device_type = DeviceType.objects.create(
941+
manufacturer=Manufacturer.objects.first(),
942+
model="Device with Module Bay Template",
943+
slug="device-with-module-bay-template",
944+
)
945+
946+
# Create module bay template
947+
ModuleBayTemplate.objects.create(
948+
device_type=device_type,
949+
name="Tray",
950+
)
951+
952+
# Create module type
953+
module_type = ModuleType.objects.create(
954+
manufacturer=Manufacturer.objects.first(),
955+
model="Test Module Type",
956+
)
957+
958+
# Step 1: Create a device - this will auto-create module bay "Tray" from template
959+
device_payload = {
960+
"id": str(uuid.uuid4()),
961+
"changes": [
962+
{
963+
"change_id": str(uuid.uuid4()),
964+
"change_type": "create",
965+
"object_version": None,
966+
"object_type": "dcim.device",
967+
"object_id": None,
968+
"ref_id": "device-1",
969+
"data": {
970+
"name": "Test Device with Module Bay",
971+
"device_type": device_type.id,
972+
"role": self.roles[0].id,
973+
"site": self.sites[0].id,
974+
},
975+
},
976+
],
977+
}
978+
self.send_request(device_payload)
979+
980+
# Verify device was created
981+
device = Device.objects.get(name="Test Device with Module Bay")
982+
983+
# Verify module bay was auto-created from template
984+
module_bays_before = ModuleBay.objects.filter(device=device, name="Tray")
985+
self.assertEqual(module_bays_before.count(), 1)
986+
module_bay = module_bays_before.first()
987+
self.assertIsNone(module_bay.module) # No module installed yet
988+
self.assertEqual(module_bay.description, "") # Template has no description
989+
990+
# Step 2: Create a module with the module bay - should reuse existing bay and update it
991+
module_payload = {
992+
"id": str(uuid.uuid4()),
993+
"changes": [
994+
{
995+
"change_id": str(uuid.uuid4()),
996+
"change_type": "create",
997+
"object_version": None,
998+
"object_type": "dcim.modulebay",
999+
"object_id": None,
1000+
"ref_id": "modulebay-1",
1001+
"data": {
1002+
"name": "Tray",
1003+
"device": device.id,
1004+
"description": "Ingested module bay",
1005+
},
1006+
},
1007+
{
1008+
"change_id": str(uuid.uuid4()),
1009+
"change_type": "create",
1010+
"object_version": None,
1011+
"object_type": "dcim.module",
1012+
"object_id": None,
1013+
"ref_id": "module-1",
1014+
"new_refs": ["module_bay"],
1015+
"data": {
1016+
"device": device.id,
1017+
"module_bay": "modulebay-1",
1018+
"module_type": module_type.id,
1019+
"description": "Ingested module",
1020+
},
1021+
},
1022+
],
1023+
}
1024+
self.send_request(module_payload)
1025+
1026+
# Verify NO duplicate module bays were created
1027+
module_bays_after = ModuleBay.objects.filter(device=device, name="Tray")
1028+
self.assertEqual(
1029+
module_bays_after.count(),
1030+
1,
1031+
"Module bay should be reused, not duplicated"
1032+
)
1033+
1034+
# Verify the module bay was updated with the description
1035+
module_bay.refresh_from_db()
1036+
self.assertEqual(
1037+
module_bay.description,
1038+
"Ingested module bay",
1039+
"Module bay should be updated with ingested data"
1040+
)
1041+
1042+
# Verify module was created successfully
1043+
modules = Module.objects.filter(device=device, module_bay=module_bay)
1044+
self.assertEqual(modules.count(), 1)
1045+
module = modules.first()
1046+
self.assertEqual(module.module_type, module_type)
1047+
self.assertEqual(module.description, "Ingested module")
1048+
1049+
def test_interface_from_template_no_duplicate(self):
1050+
"""Test that interfaces created from templates are reused and updated, not duplicated."""
1051+
from dcim.models import InterfaceTemplate
1052+
1053+
# Create a device type with an interface template
1054+
device_type = DeviceType.objects.create(
1055+
manufacturer=Manufacturer.objects.first(),
1056+
model="Device with Interface Template",
1057+
slug="device-with-interface-template",
1058+
)
1059+
1060+
# Create interface template
1061+
InterfaceTemplate.objects.create(
1062+
device_type=device_type,
1063+
name="eth0",
1064+
type="1000base-t",
1065+
)
1066+
1067+
# Step 1: Create a device - this will auto-create interface "eth0" from template
1068+
device_payload = {
1069+
"id": str(uuid.uuid4()),
1070+
"changes": [
1071+
{
1072+
"change_id": str(uuid.uuid4()),
1073+
"change_type": "create",
1074+
"object_version": None,
1075+
"object_type": "dcim.device",
1076+
"object_id": None,
1077+
"ref_id": "device-1",
1078+
"data": {
1079+
"name": "Test Device with Interface",
1080+
"device_type": device_type.id,
1081+
"role": self.roles[0].id,
1082+
"site": self.sites[0].id,
1083+
},
1084+
},
1085+
],
1086+
}
1087+
self.send_request(device_payload)
1088+
1089+
# Verify device was created
1090+
device = Device.objects.get(name="Test Device with Interface")
1091+
1092+
# Verify interface was auto-created from template
1093+
interfaces_before = Interface.objects.filter(device=device, name="eth0")
1094+
self.assertEqual(interfaces_before.count(), 1)
1095+
interface = interfaces_before.first()
1096+
self.assertEqual(interface.description, "") # Template has no description
1097+
1098+
# Step 2: Try to create the same interface with additional data - should reuse and update
1099+
interface_payload = {
1100+
"id": str(uuid.uuid4()),
1101+
"changes": [
1102+
{
1103+
"change_id": str(uuid.uuid4()),
1104+
"change_type": "create",
1105+
"object_version": None,
1106+
"object_type": "dcim.interface",
1107+
"object_id": None,
1108+
"ref_id": "interface-1",
1109+
"data": {
1110+
"name": "eth0",
1111+
"device": device.id,
1112+
"type": "1000base-t",
1113+
"description": "Ingested interface",
1114+
"enabled": True,
1115+
},
1116+
},
1117+
],
1118+
}
1119+
self.send_request(interface_payload)
1120+
1121+
# Verify NO duplicate interfaces were created
1122+
interfaces_after = Interface.objects.filter(device=device, name="eth0")
1123+
self.assertEqual(
1124+
interfaces_after.count(),
1125+
1,
1126+
"Interface should be reused, not duplicated"
1127+
)
1128+
1129+
# Verify the interface was updated with the description
1130+
interface.refresh_from_db()
1131+
self.assertEqual(
1132+
interface.description,
1133+
"Ingested interface",
1134+
"Interface should be updated with ingested data"
1135+
)
1136+
self.assertTrue(interface.enabled)

0 commit comments

Comments
 (0)