Skip to content

Fall back to LLDP-V2-MIB when the classic remote table yields nothing (#688) - #694

Open
roc-ops wants to merge 1 commit into
scanopy:devfrom
roc-ops:feat/lldp-v2-mib
Open

Fall back to LLDP-V2-MIB when the classic remote table yields nothing (#688)#694
roc-ops wants to merge 1 commit into
scanopy:devfrom
roc-ops:feat/lldp-v2-mib

Conversation

@roc-ops

@roc-ops roc-ops commented Aug 26, 2026

Copy link
Copy Markdown

Fixes #688.

What

Some agents implement only the 802.1AB-2009 LLDP-V2-MIB (1.3.111.2.802.1.1.13) — IP Infusion OcNOS among them — and contribute no L2 edges because only the classic 1.0.8802.1.1.2 remote table is walked. This adds the V2 MIB as a second LldpMibProfile and walks it as a fallback, never alongside the classic one.

Per the review notes on the issue:

  • Remote columns are classic+1 (.5 chassis-id-subtype … .11 sysDesc); local tables keep classic numbering.
  • Separate front-relative splitter for lldpV2RemEntry (timeMark.localIfIndex.localDestMACAddress.remIndex), requiring exactly four sub-ids. split_lldp_rem_index is untouched; a test shows what it would have done to a V2 index (every neighbour keyed on the destination-address index).
  • remap_lldp_local_ports and the lldpLocPortTable walk are skipped on the V2 path — lldpV2RemLocalIfIndex is already an ifIndex. The result carries local_port_is_if_index for that; the placement check against the interface table still runs.
  • LldpChassisId / LldpPortId / from_snmp are reused as-is; no schema change.

One deliberate deviation — the gate

The issue suggested gating on unsupported. On the device this exists for, that flag is false: OcNOS serves the lldpExtensions subtree under the classic root, so every classic remote column ends EndOfSubtree ("implemented and empty"), never EndOfMibView — the walks in the issue body show it. The gate is therefore classic walk finished with zero rows, which is wider than unsupported and is paid for by every host/printer/empty switch with an implemented-but-empty classic table: eight single-page walks that find nothing. An incomplete classic walk never falls back, and an equally empty V2 attempt keeps the classic verdict so a no-LLDP agent stays unsupported rather than gaining authority to clear another integration's neighbours. An incomplete V2 walk is likewise returned as non-authoritative rather than laundered into "no neighbours". If you'd rather gate on unsupported || zero rows with a narrower cost, it's a one-line change — but on OcNOS it has to include zero rows.

Simulator

SimLldpMib::V2 beside CLASSIC (own root, lldpv2 file suffix, shifted columns, four-sub-id rem_suffix) and switch-ocnos-01 (192.168.7.252, Purpose::Regression { issue: "#688" }), built from an snmpwalk -On of a UfiSpace S9600-32X on OcNOS 7.0.1.60 with identifiers rewritten: eth0 at ifIndex 3, ce0..ce31 at 10001..10125 step 4 with ifXTable names, the full 33-row lldpV2LocPortTable, three V2 neighbours on 3/10009/10073 (remIndex 4/6/2, time mark 0, dest-address index 1, subtypes 4 and 7/5/5, the blank " " port descriptions the box really sends), and no classic LLDP tables. Its regression test drives the real collection path and asserts the neighbours land on 10009/10073 directly. While wiring V2 through the simulator, RemoteNeighbour::wire_rows turned out to hard-code the classic chassis-subtype OID instead of mib.remote.chassis_id_subtype; fixed, and the own-MIB test now runs against both profiles with a SubtypeWrongType neighbour that would have caught it. Classic fixture output is unchanged.

Tests

  • cargo test --lib -- discovery::integration::snmp → 166 passed (new: V2-only agent keyed by ifIndex, classic agent never reaches V2, neither-MIB stays unsupported, incomplete classic does not fall back, incomplete V2 is not authoritative, splitter rejects 0/3/5 sub-ids, classic splitter mis-keys V2, man-addr index with/without length; switch-ocnos-01 device tests; sim lldp own-MIB test for both profiles)
  • cargo test --lib -- tests::snmp_sim_resolution → 7 passed
  • cargo check --features snmp-sim --bin generate-snmp-fixtures OK; clippy clean in touched files; fmt clean
  • Live: the same fallback (earlier form) has been running in our deployment against two OcNOS boxes (S9600-32X and S9510-28DC) and produces their edges.

Some agents implement only the 802.1AB-2009 revision of the MIB, rooted at
1.3.111.2.802.1.1.13 -- IP Infusion OcNOS among them. Every classic
1.0.8802.1.1.2 remote-table column on such a device walks straight into the
adjacent lldpExtensions subtree (EndOfSubtree, indistinguishable from
implemented-and-empty, so `unsupported` stays false), while the same
neighbours sit fully populated in lldpV2RemTable. The device contributed no
L2 edges at all.

The V2 MIB is a second LldpMibProfile, walked by query_lldp_neighbors as a
fallback and never alongside the classic one: only when the classic walk
finished and read nothing. An incomplete classic walk is a failed read, not
a device with nothing, and does not fall back. An equally empty V2 attempt
keeps the classic verdict, so an agent with no LLDP stays `unsupported`
rather than becoming authority to clear what the server holds.

Its remote columns sit one above their classic numbers (lldpV2RemLocalIfIndex
is inserted as column 2), and its row index is
timeMark.localIfIndex.localDestMACAddress.remIndex -- four sub-ids, read
from the front and required whole by a separate splitter. The classic
end-relative splitter, applied to that index, keys every neighbour on the
destination-address index and discards all but one as duplicates.

lldpV2RemLocalIfIndex is already an ifIndex, so the result carries
`local_port_is_if_index` and the collection skips the lldpLocPortTable walk
and remap_lldp_local_ports on that path; the placement check against the
interface table still runs. The management-address index is read with or
without its address-length sub-id (OcNOS serves none), and the device's own
chassis identity gets the same fallback so a V2-only device can resolve as
itself.

The simulated lab gains switch-ocnos-01 (192.168.7.252), built from an
snmpwalk of a UfiSpace S9600-32X running OcNOS 7.0.1 with identifiers
rewritten: eth0 at ifIndex 3, ce0-ce31 at 10001..10125 step 4, three V2
neighbours keyed by those ifIndex values, and no classic LLDP tables. Its
regression test drives the real collection path and asserts the neighbours
land on 10009 and 10073 directly. Along the way, RemoteNeighbour::wire_rows
now serves the chassis-subtype column its own MIB names rather than the
classic constant, and the own-MIB test runs against both profiles.

Fixes scanopy#688
roc-ops added a commit to roc-ops/scanopy that referenced this pull request Aug 31, 2026
Three points from the independent verification of 5af3ef0. The seam itself was
confirmed real -- the reviewer added a hypothetical third vendor differing on
every axis (non-empty origin, two-element root, different state container) and
changed nothing but the table, and it worked end to end -- so this only corrects
the record around it.

CORRECTS 5af3ef0's MESSAGE, which is the part a maintainer reads on the PR.
It said behaviour is unchanged for ArcOS, cEOS and SR Linux "and the fixtures
captured from those devices assert the same rows they did". There are no cEOS or
SR Linux fixtures. The captured devices are ArcOS 8.5 (netlab-leaf1) and
DriveNets (cDNOS 26.2 and a 72XC); the only `srl_nokia` in the tree is a
hypothetical origin string in a path-rendering test. ArcOS is genuinely
fixture-covered. The other two are unchanged BY CONSTRUCTION, which is a real
argument and a different one: for OPENCONFIG_LLDP the normalisation is provably
the identity on every input -- the root is empty so the prefix check always
holds, the guard then requires the first element to be `lldp` and returns the
input untouched otherwise, and the state-container rewrite maps `state` to
`state`. The claim was inherited from the original doc; the code no longer
asserts it anywhere, so this is the last place it lived.

RECORDS AN UNCOVERED WIRE. Nothing tests that `probe` populates
`GnmiProbeHandle.models`. Replacing that assignment with `Vec::new()` leaves the
suite green while every device becomes `NoneAdvertised` and DriveNets returns
zero neighbours -- the exact defect this line of work exists to fix. Coverage
regressed here: `collect` used to call `capabilities()` itself, so the fake's
list flowed through the production call; the new test helper read the field
directly and left `ScriptedDevice::capabilities()` dead. The helper now goes
through the transport, which is right but does NOT close it -- the assignment is
inside `probe`, and no test reaches `probe` at all because it builds a
`TonicTransport` directly with no seam to inject a fake. Making that injectable
is the real fix; until then the gap is named on the handle rather than left for
someone to assume the tests cover it.

NAMES THE SEAM'S BOUNDARY. `LldpModelProfile` said a further vendor is a static
rather than a branch. True for openconfig-SHAPED vendors; the list key names
(`interface[name]`, `neighbor[id]`) and the literal `lldp` element are still
fixed in code. Said so in the profile doc, the same courtesy `LldpMibProfile`
pays about subtype enums.

Also verified by the review and worth recording: clippy is clean, the branch is
based on `feat/gnmi-lldp-collector` off `upstream/dev` with neither scanopy#694 nor
scanopy#697 contained, so it can reach PR scanopy#696 on its own, and six of seven sabotages
were caught (the seventh is the probe wire above).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants