fix(snmp-discovery): stub the device ref inside primary-IP snapshots - #516
Conversation
|
@codex review |
|
Go test coverage
Total coverage: 88.6% |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cb4122bdc4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Vulnerability Scan: Failed — blocking vulnerabilities detectedImage:
Commit: e0abb93 |
A device could appear twice in one payload with two different
device_type.model values, and NetBox rejected the whole device changeset
because it could not pick a winner.
The primary IP is attached by shallow-copying the Device into
Device.PrimaryIp4/6, which breaks the Device -> IP -> Interface -> Device
cycle. Later stages then mutate the rich Device: the stack path replaces
device_type with the real ENTITY-MIB product model rather than the
sysObjectID family label, the annotators stamp source_match, and name
suppression clears the name. PruneNestedRefs rebuilds every other nested
device ref into a matcher-only stub and runs after all of them, but skipped
that subtree, so the snapshot kept whatever the device looked like during
mapping.
Prune it too. Four defects share the one gap:
- device_type diverges, which is the reported failure;
- emit_device_name: false is defeated, because the snapshot re-supplies
the hostname the operator asked to suppress;
- source_match is missing, so the two representations of one device can
resolve through different matcher paths;
- the snapshot keeps the master as owner while the live interface is
routed to a stack member, so one port is claimed by two devices and
two devices claim one unique_primary_ip4.
The owner is resolved from the live IPAddress entity for that exact
address, not by interface name: IP-assigned interfaces are deliberately
excluded from top-level emission, so the by-name index structurally cannot
contain the one carrying the primary. An address claimed by more than one
interface is treated as ambiguous and left alone.
Reproduced end to end against an snmpsim recording of a 3-member 2960X
stack, whose sysObjectID resolves to the same cat29xxStack family label
while its chassis rows report WS-C2960X-* product models: the payload goes
from one conflicting device identity to none, with the entity mix unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cb4122b to
316a5a4
Compare
|
@codex review |
|
Codex Review: Didn't find any major issues. Breezy! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Problem
One device appears twice in a single ingest payload with two different
device_type.modelvalues, and NetBox rejects the whole device changeset with an__all__validation error because the merge cannot pick a winner.The top-level value is the correct one:
WS-C2960X-48LPS-Lis the ENTITY-MIB product model,cat29xxStackis the CISCO-PRODUCTS-MIB label for thesysObjectID, i.e. a platform family. So the stack path is improving the model; it just fails to carry the improvement everywhere.Root cause
Attaching the primary IP shallow-copies the Device into
Device.PrimaryIp4/6.AssignedObject.(*Interface).Deviceto break theDevice → IP → Interface → Devicecycle. Later stages mutate the rich Device — the stack path replacesdevice_type, the annotators stampsource_match/run_id, name suppression clearsName.PruneNestedRefsrebuilds every other nested device ref into a matcher-only stub and runs after all of them, but skipped this one subtree:That the machinery is already correct and only its coverage was wrong is visible in the failing payload: of 122 device refs, the 121 that pass through the prune carry 4-5 fields and the correct model; the 1 it skips carries 8 fields, the stale model, and no
source_match.What this fixes
Four defects share that one gap:
device_typedivergesemit_device_name: falseis defeated — the snapshot re-supplies the hostname the operator asked to suppresssource_matchmissing, so two representations of one device resolve through different matcher pathsunique_primary_ip4(matcher #2)Design notes
The owner is resolved by address, not by interface name. IP-assigned interfaces are deliberately excluded from top-level emission, so the by-name index structurally cannot contain the interface carrying the primary — in the customer payload that interface is
Vlan12, absent from all 118 top-level interfaces. An earlier revision resolved by name; it was rejected because a member exposing a same-named interface (per-memberme0/mgmt0) could capture the lookup and bind the primary to an unrelated device. An address claimed by more than one interface is treated as ambiguous and left alone.Tagsis deliberately not carried on the interface stub. An earlier revision added it, on the reasoning that the stub becomes the only wire payload for an IP-assigned interface. Measured across all 52 captures, that framing was wrong: the primary-IP snapshot interface carries tags on 52/52 devices, but the other 80 nested IP-assigned refs carry them on 0/80. So addingTagsto the shared stub would start tagging 80 interfaces per run that are untagged today — a product behaviour change riding along in adevice_typefix. Diode applies updates withpartial=True, so omitting the field never strips tags from an interface that already has them; only first-time creation comes up untagged, which is already the behaviour for every other IP-assigned interface. The 0/80 gap is worth its own ticket.Nulling the snapshot's
device_typewas considered and rejected. "Absent-in-one reconciles" only holds for nodes that share a fingerprint. Three reachable configurations produce disjoint matchers —emit_device_name: falsewithnetbox_id, the same withdiscover_asset_tags: true, and any device with an empty sysName (common on cameras and printers) — and there the nested ref is planned independently, becoming a CREATE that NetBox rejects for a missing requireddevice_type. Since the strip would be unconditional, that fails on every device in the policy every cycle: a wider blast radius than the bug. This change never removes a create-required field.VirtualChassis.Masteris deliberately out of scope. It is also a Device ref reached from a top-level Device and is pruned nowhere, but it is not stale in practice —buildMasterRefruns after thedevice_typereplacement and aftermaster.Serial, its name is handled explicitly, and the customer payload confirms it carries the correct model, a serial and metadata. Folding it in would need a masterRef-shaped rebuilder, since it intentionally carries aSerialthatnewDeviceStubdrops.Verification
Reproduced and fixed end to end. LibreNMS'
ios_2960xrecording — which the orb-test-lab already serves at172.28.0.20— is a close analogue of the reported device: a 3-member stack whosesysObjectID(.1.3.6.1.4.1.9.1.1208) resolves to the samecat29xxStackfamily label, while its chassis rows reportWS-C2960X-48FPS-L/WS-C2960X-24PS-L. It lacks only an IP address table, which is why the conflict never surfaced there; addingipAdEntAddr/ipAdEntIfIndexrows for the target on a VLAN interface completes the shape. A dry-run crawl against that simulator:Same path, same stale label, same shape as the customer payload. The entity mix is byte-identical between the two runs, and the nested device goes from 6 rich fields to the 4-field stub.
All four symptoms were then driven end to end against that simulator, each by changing only configuration or which interface holds the address:
device_typedivergenceemit_device_name: false(+netbox_id)source_matchmatcher-path splitGi2/0/1)The last one matters most: it is the symptom the customer's own data cannot exercise, because their primary sits on a Vlan that routes to the master.
The reported error itself was then reproduced against the real plugin. Feeding the device entity from each payload through
transform_proto_jsonin a local NetBox with the diode plugin installed:Same error key, same message, and the same two
new_object:dcim.devicetyperefs the report shows._merge_nodesand_fingerprint_dedupeare byte-identical between the plugin version the reporter is on (v1.13.0) and current HEAD, and v1.13.0 walks the circular subtree the same way, so this is not version-specific.This also explains the reported symptom shape.
_generate_changesettransforms one top-level entity at a time, so only the device entity's own changeset fails — the interfaces, IPs and prefixes land, and the device row itself still gets created from the nested device refs carried on interfaces. Hence a device that exists and looks broadly right while its own diff fails on every cycle.go build,go vetclean · all 13 packages green ·-race -count=2green ·golangci-lint0 issues · repo-widelint-allpasses for every Go module.Every guard is mutation-tested — each of these makes the suite fail:
continuelen==1→len>0The v6 case and the address-keying case were added specifically because the suite was green without them: the customer capture has no IPv6 primary and no duplicate interface names, so neither CI nor the data would have caught a half-fix.
The table was re-run after
Tagswas reverted, so every row above reflects the diff as it stands.Not verified
The customer's own device cannot be replayed — their files are dry-run output, not SNMP input. The end-to-end evidence above is a simulator analogue, matched on the properties that matter (stack, same sysObjectID family label, divergent
entPhysicalModelName, primary IP on a VLAN interface) but not their exact hardware.The IPv6 twin is covered by unit tests and by the "prune v4 only" mutation, but not end to end: driving it needs an IPv6 address table in the recording and an IPv6 listener, and no capture from the reporting customer has an IPv6 primary.
The simulator recording and the local responder tweaks are local only and not part of this PR.
Follow-up, not in this PR
What should a nested interface stub carry when it is the only wire representation? IP-assigned interfaces are excluded from top-level emission, so their nested stub is all NetBox ever sees.
newInterfaceStubcarriestypeand the plain attributes for exactly that reason, but notTags(present on 52/52 primary-IP snapshots, 0/80 siblings) and notmode/untagged_vlan/tagged_vlans(0/132 —VlanMapperclassifies switchports, and an IP-carrying interface is an SVI or routed port). Both were raised on this PR and both were declined here, because deciding either one inside adevice_typefix would change tagging or switching-config behaviour for refs the ticket never touched. Worth answering once, for both fields, with a test each.resolveIfaceOwner's by-name owner rewrite is provably a false hit for IP-assigned interfaces, since they are excluded from the index it consults — so any name match there is a different interface. On develop today that already mis-routes the live cycle-closer entity; this PR does not introduce it and makes the snapshot agree with it. A real fix must suppress the by-name rewrite on both paths at once. Unreachable in the customer data: zero duplicate top-level interface names across all 52 captures.