Skip to content

Commit 6063144

Browse files
authored
feat: PK-based device matching via metadata.source_match.netbox_id (#158)
* feat: PK-based device matching via metadata.source_match.netbox_id When an ingested entity carries metadata.source_match.netbox_id, use direct PK lookup instead of constraint-based matching. This supports the Discovery use case where targets are known NetBox objects. - Extract metadata from entity before _ensure_snake_case strips it - In _resolve_existing_references, do PK lookup before matcher chain - Raise ChangeSetException if netbox_id points to nonexistent object - Invalid (non-numeric) netbox_id logs a warning and falls through to normal matching * fix: remove unnecessary else after continue (ruff RET507)
1 parent b769e19 commit 6063144

3 files changed

Lines changed: 281 additions & 1 deletion

File tree

netbox_diode_plugin/api/differ.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ def _generate_changeset(entity: dict, object_type: str) -> ChangeSetResult:
195195
object_type = entity.pop("_object_type")
196196
_ = entity.pop("_uuid")
197197
instance = entity.pop("_instance", None)
198+
entity.pop("_netbox_id", None)
198199
_merge_warnings(warnings, object_type, entity.pop("_warnings", None))
199200
if instance:
200201
# the prior state is another new object...

netbox_diode_plugin/api/transformer.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
CUSTOM_FIELD_OBJECT_REFERENCE_TYPE,
2525
apply_format_transformations,
2626
get_json_ref_info,
27+
get_object_type_model,
2728
get_primary_value,
2829
legal_fields,
2930
)
@@ -127,6 +128,9 @@ def _transform_proto_json_1(proto_json: dict, object_type: str, supported_models
127128
"_warnings": {},
128129
}
129130

131+
# extract metadata before _ensure_snake_case strips it as an unknown field
132+
metadata = dict.pop(proto_json, "metadata", None)
133+
130134
# handle camelCase protoJSON if provided...
131135
proto_json = _ensure_snake_case(proto_json, object_type)
132136
apply_format_transformations(proto_json, object_type)
@@ -151,6 +155,17 @@ def _transform_proto_json_1(proto_json: dict, object_type: str, supported_models
151155
node['_refs'].update(custom_fields_refs)
152156
nodes += nested
153157

158+
# process extracted metadata for PK-based matching
159+
if metadata and isinstance(metadata, dict):
160+
source_match = metadata.get("source_match", {})
161+
if isinstance(source_match, dict):
162+
netbox_id_raw = source_match.get("netbox_id")
163+
if netbox_id_raw is not None:
164+
try:
165+
node['_netbox_id'] = int(netbox_id_raw)
166+
except (ValueError, TypeError):
167+
node['_warnings']['metadata'] = [f"Invalid netbox_id: {netbox_id_raw}"]
168+
154169
supported_fields = _supported_diode_fields(object_type, supported_models)
155170
def is_supported(field_name, ref_info):
156171
if ref_info is None:
@@ -532,6 +547,27 @@ def _resolve_existing_references(entities: list[dict]) -> list[dict]:
532547
resolved.append(data)
533548
continue
534549

550+
# PK-based lookup: if metadata provided a netbox_id, use it directly
551+
netbox_id = data.pop('_netbox_id', None)
552+
if netbox_id is not None:
553+
model_class = get_object_type_model(object_type)
554+
existing = model_class.objects.filter(pk=netbox_id).first()
555+
if existing is not None:
556+
fp = (object_type, existing.id)
557+
if fp in seen:
558+
logger.warning(f"objects resolved to the same existing id after deduplication: {seen[fp]} and {data}")
559+
else:
560+
seen[fp] = data
561+
data['id'] = existing.id
562+
data['_instance'] = existing
563+
new_refs[data['_uuid']] = existing.id
564+
resolved.append(data)
565+
continue
566+
raise ChangeSetException(
567+
f"Object not found for {object_type} with netbox_id={netbox_id}",
568+
errors={NON_FIELD_ERRORS: [f"No {object_type} found with id {netbox_id}"]}
569+
)
570+
535571
existing = find_existing_object(data, object_type)
536572
if existing is not None:
537573
fp = (object_type, existing.id)

netbox_diode_plugin/tests/v4.5.x/tests/test_api_generate_diff.py

Lines changed: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from uuid import uuid4
1010

1111
from core.models import ObjectType
12-
from dcim.models import Manufacturer, RackType, Site
12+
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, RackType, Site
1313
from extras.models import CustomField
1414
from extras.models.customfields import CustomFieldTypeChoices
1515
from rest_framework import status
@@ -419,3 +419,246 @@ def send_request(self, payload, status_code=status.HTTP_200_OK):
419419
)
420420
self.assertEqual(response.status_code, status_code)
421421
return response
422+
423+
424+
class PKBasedMatchingTestCase(APITestCase):
425+
"""Test PK-based matching via metadata.source_match.netbox_id."""
426+
427+
def setUp(self):
428+
"""Set up test fixtures."""
429+
self.url = "/netbox/api/plugins/diode/generate-diff/"
430+
431+
self.authorization_header = {"HTTP_AUTHORIZATION": "Bearer mocked_oauth_token"}
432+
self.diode_user = SimpleNamespace(
433+
user=get_diode_user(),
434+
token_scopes=["netbox:read", "netbox:write"],
435+
token_data={"scope": "netbox:read netbox:write"},
436+
)
437+
438+
self.introspect_patcher = mock.patch.object(
439+
DiodeOAuth2Authentication,
440+
"_introspect_token",
441+
return_value=self.diode_user,
442+
)
443+
self.introspect_patcher.start()
444+
445+
self.site = Site.objects.create(name="PK Test Site", slug="pk-test-site")
446+
manufacturer = Manufacturer.objects.create(name="PK Test Manufacturer", slug="pk-test-manufacturer")
447+
device_type = DeviceType.objects.create(
448+
manufacturer=manufacturer, model="PK Test Type", slug="pk-test-type"
449+
)
450+
self.role = DeviceRole.objects.create(name="PK Test Role", slug="pk-test-role", color="ff0000")
451+
self.device = Device.objects.create(
452+
name="PK Test Device",
453+
device_type=device_type,
454+
role=self.role,
455+
site=self.site,
456+
serial="ORIG-SERIAL",
457+
)
458+
self.interface = Interface.objects.create(
459+
device=self.device,
460+
name="eth0",
461+
type="virtual",
462+
)
463+
464+
def tearDown(self):
465+
"""Clean up after tests."""
466+
self.introspect_patcher.stop()
467+
super().tearDown()
468+
469+
def send_request(self, payload, status_code=status.HTTP_200_OK):
470+
"""Post the payload to the url and return the response."""
471+
response = self.client.post(
472+
self.url, data=payload, format="json", **self.authorization_header
473+
)
474+
self.assertEqual(response.status_code, status_code)
475+
return response
476+
477+
def test_pk_match_noop(self):
478+
"""PK match with identical data produces no changes."""
479+
payload = {
480+
"object_type": "dcim.device",
481+
"entity": {
482+
"device": {
483+
"name": "PK Test Device",
484+
"serial": "ORIG-SERIAL",
485+
"device_type": {"model": "PK Test Type", "manufacturer": {"name": "PK Test Manufacturer"}},
486+
"role": {"name": "PK Test Role"},
487+
"site": {"name": "PK Test Site"},
488+
"metadata": {
489+
"source_match": {"netbox_id": self.device.pk},
490+
},
491+
},
492+
},
493+
}
494+
response = self.send_request(payload)
495+
cs = response.json().get("change_set", {})
496+
changes = cs.get("changes", [])
497+
# all changes should be noop since data matches
498+
device_changes = [c for c in changes if c["object_type"] == "dcim.device"]
499+
self.assertTrue(len(device_changes) <= 1)
500+
if device_changes:
501+
self.assertEqual(device_changes[0]["change_type"], "noop")
502+
503+
def test_pk_match_update(self):
504+
"""PK match with changed attribute produces update changeset."""
505+
payload = {
506+
"object_type": "dcim.device",
507+
"entity": {
508+
"device": {
509+
"name": "PK Test Device",
510+
"serial": "NEW-SERIAL",
511+
"device_type": {"model": "PK Test Type", "manufacturer": {"name": "PK Test Manufacturer"}},
512+
"role": {"name": "PK Test Role"},
513+
"site": {"name": "PK Test Site"},
514+
"metadata": {
515+
"source_match": {"netbox_id": self.device.pk},
516+
},
517+
},
518+
},
519+
}
520+
response = self.send_request(payload)
521+
cs = response.json().get("change_set", {})
522+
changes = cs.get("changes", [])
523+
device_changes = [c for c in changes if c["object_type"] == "dcim.device"]
524+
self.assertEqual(len(device_changes), 1)
525+
change = device_changes[0]
526+
self.assertEqual(change["change_type"], "update")
527+
self.assertEqual(change["object_id"], self.device.pk)
528+
self.assertEqual(change["data"]["serial"], "NEW-SERIAL")
529+
530+
def test_pk_match_ignores_name(self):
531+
"""PK match finds the device even when the name is different — produces update, not create."""
532+
payload = {
533+
"object_type": "dcim.device",
534+
"entity": {
535+
"device": {
536+
"name": "Completely Different Name",
537+
"serial": "ORIG-SERIAL",
538+
"device_type": {"model": "PK Test Type", "manufacturer": {"name": "PK Test Manufacturer"}},
539+
"role": {"name": "PK Test Role"},
540+
"site": {"name": "PK Test Site"},
541+
"metadata": {
542+
"source_match": {"netbox_id": self.device.pk},
543+
},
544+
},
545+
},
546+
}
547+
response = self.send_request(payload)
548+
cs = response.json().get("change_set", {})
549+
changes = cs.get("changes", [])
550+
device_changes = [c for c in changes if c["object_type"] == "dcim.device"]
551+
self.assertEqual(len(device_changes), 1)
552+
change = device_changes[0]
553+
self.assertEqual(change["change_type"], "update")
554+
self.assertEqual(change["object_id"], self.device.pk)
555+
# name change should be in the diff
556+
self.assertEqual(change["data"]["name"], "Completely Different Name")
557+
558+
def test_pk_not_found_raises_error(self):
559+
"""PK that doesn't exist returns an error, not a create."""
560+
payload = {
561+
"object_type": "dcim.device",
562+
"entity": {
563+
"device": {
564+
"name": "Ghost Device",
565+
"device_type": {"model": "PK Test Type", "manufacturer": {"name": "PK Test Manufacturer"}},
566+
"role": {"name": "PK Test Role"},
567+
"site": {"name": "PK Test Site"},
568+
"metadata": {
569+
"source_match": {"netbox_id": 999999},
570+
},
571+
},
572+
},
573+
}
574+
response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST)
575+
errors = response.json().get("errors", {})
576+
self.assertTrue(len(errors) > 0)
577+
578+
def test_no_metadata_uses_normal_matching(self):
579+
"""Without metadata, normal constraint-based matching applies."""
580+
payload = {
581+
"object_type": "dcim.device",
582+
"entity": {
583+
"device": {
584+
"name": "PK Test Device",
585+
"serial": "CHANGED-SERIAL",
586+
"device_type": {"model": "PK Test Type", "manufacturer": {"name": "PK Test Manufacturer"}},
587+
"role": {"name": "PK Test Role"},
588+
"site": {"name": "PK Test Site"},
589+
},
590+
},
591+
}
592+
response = self.send_request(payload)
593+
cs = response.json().get("change_set", {})
594+
changes = cs.get("changes", [])
595+
device_changes = [c for c in changes if c["object_type"] == "dcim.device"]
596+
self.assertEqual(len(device_changes), 1)
597+
change = device_changes[0]
598+
# should still match by name+site and produce an update
599+
self.assertEqual(change["change_type"], "update")
600+
self.assertEqual(change["object_id"], self.device.pk)
601+
602+
def test_pk_match_device_via_nested_interface(self):
603+
"""Interface ingested with parent device carrying netbox_id — device matched by PK, interface by name."""
604+
payload = {
605+
"object_type": "dcim.interface",
606+
"entity": {
607+
"interface": {
608+
"name": "eth0",
609+
"type": "virtual",
610+
"mtu": 9000,
611+
"device": {
612+
"name": "Irrelevant Name",
613+
"device_type": {"model": "PK Test Type", "manufacturer": {"name": "PK Test Manufacturer"}},
614+
"role": {"name": "PK Test Role"},
615+
"site": {"name": "PK Test Site"},
616+
"metadata": {
617+
"source_match": {"netbox_id": self.device.pk},
618+
},
619+
},
620+
},
621+
},
622+
}
623+
response = self.send_request(payload)
624+
cs = response.json().get("change_set", {})
625+
changes = cs.get("changes", [])
626+
device_changes = [c for c in changes if c["object_type"] == "dcim.device"]
627+
interface_changes = [c for c in changes if c["object_type"] == "dcim.interface"]
628+
# device should be matched by PK (name differs so it's an update)
629+
self.assertEqual(len(device_changes), 1)
630+
self.assertEqual(device_changes[0]["change_type"], "update")
631+
self.assertEqual(device_changes[0]["object_id"], self.device.pk)
632+
# interface should be matched by name within the PK-resolved device
633+
self.assertEqual(len(interface_changes), 1)
634+
self.assertEqual(interface_changes[0]["change_type"], "update")
635+
self.assertEqual(interface_changes[0]["object_id"], self.interface.pk)
636+
637+
def test_invalid_netbox_id_falls_through(self):
638+
"""Invalid (non-numeric) netbox_id is ignored with a warning, falls through to normal matching."""
639+
payload = {
640+
"object_type": "dcim.device",
641+
"entity": {
642+
"device": {
643+
"name": "PK Test Device",
644+
"serial": "CHANGED-FOR-INVALID-PK-TEST",
645+
"device_type": {"model": "PK Test Type", "manufacturer": {"name": "PK Test Manufacturer"}},
646+
"role": {"name": "PK Test Role"},
647+
"site": {"name": "PK Test Site"},
648+
"metadata": {
649+
"source_match": {"netbox_id": "not-a-number"},
650+
},
651+
},
652+
},
653+
}
654+
response = self.send_request(payload)
655+
cs = response.json().get("change_set", {})
656+
changes = cs.get("changes", [])
657+
device_changes = [c for c in changes if c["object_type"] == "dcim.device"]
658+
self.assertEqual(len(device_changes), 1)
659+
# should fall through to normal matching and find the device by name
660+
self.assertEqual(device_changes[0]["change_type"], "update")
661+
self.assertEqual(device_changes[0]["object_id"], self.device.pk)
662+
# warning should be present
663+
warnings = cs.get("warnings", {})
664+
self.assertIn("metadata", warnings.get("dcim.device", {}))

0 commit comments

Comments
 (0)