@cryptotaxi247 / netdata / commits / ebd373c8f

feat(topology/snmp): SNMP L2/L3 topology engine and collector (#22109)

Co-authored-by: ilyam8 <ilya@netdata.cloud>

Costa Tsaousis committed Apr 21, 2026 at 20:04 UTC ebd373c8f07615b264fbb30bd36e26c312b8bf1b
272 files changed +44783 -127
.codacy.yml
+4
@@ -13,3 +13,7 @@ exclude_paths:
13 - build_external/**
14 - packaging/**
15 - src/aclk/mqtt_websockets/**
16 + - TODO-netflow-code-organization.md
17 + - TODO-netflow-plugin-refactor.md
18 + - src/go/pkg/topology/engine/parity/README.md
19 + - src/go/pkg/topology/engine/parity/evidence/**
.github/workflows/snmp-sim-tests.yml new
+96
@@ -0,0 +1,96 @@
1 +---
2 +name: SNMP Simulator Tests
3 +on:
4 + push:
5 + branches:
6 + - master
7 + paths:
8 + - '.github/workflows/snmp-sim-tests.yml'
9 + - 'src/go/plugin/go.d/collector/snmp/**'
10 + - 'src/go/plugin/go.d/collector/snmp_topology/**'
11 + - 'src/go/plugin/go.d/config/go.d/snmp.profiles/**'
12 + - 'src/go/pkg/topology/**'
13 + pull_request:
14 + paths:
15 + - '.github/workflows/snmp-sim-tests.yml'
16 + - 'src/go/plugin/go.d/collector/snmp/**'
17 + - 'src/go/plugin/go.d/collector/snmp_topology/**'
18 + - 'src/go/plugin/go.d/config/go.d/snmp.profiles/**'
19 + - 'src/go/pkg/topology/**'
20 +
21 +jobs:
22 + simulator-tests:
23 + runs-on: ubuntu-latest
24 + steps:
25 + - name: Checkout
26 + uses: actions/checkout@v6
27 + with:
28 + submodules: recursive
29 +
30 + - name: Install Go
31 + uses: actions/setup-go@v6
32 + with:
33 + go-version-file: src/go/go.mod
34 +
35 + - name: Prepare snmpsim data
36 + shell: bash
37 + run: |
38 + set -euo pipefail
39 + mkdir -p /tmp/snmpsim-data
40 + cp src/go/plugin/go.d/collector/snmp_topology/testdata/snmprec/arubaos-cx_10.10.snmprec /tmp/snmpsim-data/lldp1.snmprec
41 + cp src/go/plugin/go.d/collector/snmp_topology/testdata/snmprec/aos6.snmprec /tmp/snmpsim-data/lldp2.snmprec
42 + cp src/go/plugin/go.d/collector/snmp_topology/testdata/snmprec/ciscosb_sg350x-24p.snmprec /tmp/snmpsim-data/cdp1.snmprec
43 +
44 + - name: Start snmpsim
45 + shell: bash
46 + run: |
47 + set -euo pipefail
48 + sudo apt-get update
49 + sudo apt-get install -y snmp
50 +
51 + docker run -d --name snmpsim-v2 -p 1161:161/udp -v /tmp/snmpsim-data:/usr/local/snmpsim/data tandrup/snmpsim:v0.4@sha256:a080b493042d91c4cec9282a83403e334660ed1c2c71e7e3afa920c3f1c42d7e
52 + docker run -d --name snmpsim-v3 -p 1162:161/udp -e EXTRA_FLAGS='--v3-only --v3-user=testuser --v3-auth-key=authpass1 --v3-auth-proto=MD5 --v3-priv-key=privpass1 --v3-priv-proto=AES' -v /tmp/snmpsim-data:/usr/local/snmpsim/data tandrup/snmpsim:v0.4@sha256:a080b493042d91c4cec9282a83403e334660ed1c2c71e7e3afa920c3f1c42d7e
53 +
54 + wait_for_simulator() {
55 + local name=$1
56 + shift
57 +
58 + for _ in $(seq 1 30); do
59 + if "$@" >/dev/null 2>&1; then
60 + return 0
61 + fi
62 + sleep 1
63 + done
64 +
65 + echo "${name} did not become ready within 30 seconds"
66 + docker logs "${name}" || true
67 + return 1
68 + }
69 +
70 + wait_for_simulator snmpsim-v2 \
71 + snmpget -v2c -c lldp1 127.0.0.1:1161 1.3.6.1.2.1.1.1.0
72 +
73 + wait_for_simulator snmpsim-v3 \
74 + snmpget -v3 -l authPriv -u testuser -a MD5 -A authpass1 -x AES -X privpass1 -n lldp1 127.0.0.1:1162 1.3.6.1.2.1.1.1.0
75 +
76 + - name: Run integration tests
77 + env:
78 + NETDATA_SNMPSIM_ENDPOINT: 127.0.0.1:1161
79 + NETDATA_SNMPSIM_COMMUNITIES: lldp1,lldp2,cdp1
80 + NETDATA_SNMPSIM_V3_ENDPOINT: 127.0.0.1:1162
81 + NETDATA_SNMPSIM_V3_CONTEXTS: lldp1,lldp2,cdp1
82 + NETDATA_SNMPSIM_V3_USER: testuser
83 + NETDATA_SNMPSIM_V3_SECURITY_LEVEL: authPriv
84 + NETDATA_SNMPSIM_V3_AUTH_PROTO: md5
85 + NETDATA_SNMPSIM_V3_AUTH_KEY: authpass1
86 + NETDATA_SNMPSIM_V3_PRIV_PROTO: aes
87 + NETDATA_SNMPSIM_V3_PRIV_KEY: privpass1
88 + run: |
89 + cd src/go
90 + go test -tags=integration ./plugin/go.d/collector/snmp_topology
91 +
92 + - name: Stop snmpsim
93 + if: always()
94 + run: |
95 + docker rm -f snmpsim-v2 || true
96 + docker rm -f snmpsim-v3 || true
src/go/pkg/funcapi/response.go
+29 -5
@@ -4,12 +4,35 @@ package funcapi
4
5 // MethodConfig describes a function method provided by a module.
6 type MethodConfig struct {
7 - ID string // Method ID (e.g., "top-queries")
8 - Name string // Display name (e.g., "Top Queries")
9 - UpdateEvery int // Default UI refresh interval
10 - Help string // Description for UI
11 - RequireCloud bool // Indicates whether the method requires cloud connection
7 + ID string // Method ID (e.g., "top-queries")
8 + // FIXME: funcctl currently honors aliases only for module/static methods.
9 + // Job method registration still publishes only the canonical module:method name.
10 + Aliases []string // Additional function names to register for this method
11 + Name string // Display name (e.g., "Top Queries")
12 + UpdateEvery int // Default UI refresh interval
13 + Help string // Description for UI
14 + RequireCloud bool // Indicates whether the method requires cloud connection
15 + ResponseType string // Response schema type; empty defaults to "table" when dispatched
16 + // FIXME: AgentWide currently removes __job from the public API, but funcctl still
17 + // dispatches through the first running job for the module instead of a true
18 + // agent-level execution path.
19 + AgentWide bool // Method is agent-wide (does not require __job selector)
20 RequiredParams []ParamConfig // Required parameters for this method (including __sort if used)
21 + // FIXME: Presentation is intentionally untyped here, while the shared UI schema
22 + // currently defines only topology-specific presentation payloads.
23 + presentation any
24 +}
25 +
26 +// WithPresentation returns an updated copy with optional presentation metadata attached.
27 +// This uses builder-style value semantics so it can be chained from composite literals.
28 +func (cfg MethodConfig) WithPresentation(v any) MethodConfig {
29 + cfg.presentation = v
30 + return cfg
31 +}
32 +
33 +// Presentation returns optional presentation metadata for the method info response.
34 +func (cfg MethodConfig) Presentation() any {
35 + return cfg.presentation
36 }
37
38 // FunctionResponse is the response from a module's HandleMethod.
@@ -17,6 +40,7 @@ type FunctionResponse struct {
40 Status int // HTTP-like status code (200, 400, 403, 500, 503)
41 Message string // Error message (if Status != 200)
42 Help string // Help text for this response
43 + ResponseType string // Override response schema type (defaults to MethodConfig.ResponseType)
44 Columns map[string]any // Column definitions for the table
45 Data any // Row data: [][]any (array of arrays, ordered by column index)
46 DefaultSortColumn string // Default sort column ID
src/go/pkg/funcapi/response_test.go new
+18
@@ -0,0 +1,18 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package funcapi
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/assert"
9 +)
10 +
11 +func TestMethodConfig_WithPresentationReturnsUpdatedCopy(t *testing.T) {
12 + cfg := MethodConfig{ID: "topology"}
13 +
14 + updated := cfg.WithPresentation(map[string]any{"mode": "graph"})
15 +
16 + assert.Nil(t, cfg.Presentation())
17 + assert.Equal(t, map[string]any{"mode": "graph"}, updated.Presentation())
18 +}
src/go/pkg/topology/engine/bridge_domain_model.go new
+389
@@ -0,0 +1,389 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strconv"
8 + "strings"
9 +)
10 +
11 +// bridgeDomainModel is the persisted bridge-domain assembly equivalent that
12 +// topology projection can consume directly.
13 +type bridgeDomainModel struct {
14 + domains []*bridgeBroadcastDomain
15 +}
16 +
17 +type bridgeBroadcastDomain struct {
18 + bridges map[string]*bridgeDomainBridge
19 + segments []*bridgeDomainSegment
20 +}
21 +
22 +type bridgeDomainBridge struct {
23 + nodeID string
24 + root bool
25 +}
26 +
27 +type bridgeDomainSegment struct {
28 + designatedPort bridgePortRef
29 + ports map[string]bridgePortRef
30 + endpointIDs map[string]struct{}
31 + methods map[string]struct{}
32 +}
33 +
34 +type bridgeBridgeLinkRecord struct {
35 + port bridgePortRef
36 + designatedPort bridgePortRef
37 + method string
38 +}
39 +
40 +type bridgeMacLinkRecord struct {
41 + port bridgePortRef
42 + endpointID string
43 + method string
44 +}
45 +
46 +type bridgeNodeSet map[string]struct{}
47 +
48 +func (s bridgeNodeSet) add(v string) {
49 + v = strings.TrimSpace(v)
50 + if v == "" {
51 + return
52 + }
53 + s[v] = struct{}{}
54 +}
55 +
56 +func buildBridgeDomainModel(
57 + bridgeLinks []bridgeBridgeLinkRecord,
58 + macLinks []bridgeMacLinkRecord,
59 +) bridgeDomainModel {
60 + model := bridgeDomainModel{domains: make([]*bridgeBroadcastDomain, 0)}
61 + if len(bridgeLinks) == 0 && len(macLinks) == 0 {
62 + return model
63 + }
64 +
65 + bblSegments := make([]*bridgeDomainSegment, 0)
66 + rootToNodes := make(map[string]bridgeNodeSet)
67 +
68 + for _, link := range bridgeLinks {
69 + designatedNodeID := strings.TrimSpace(link.designatedPort.deviceID)
70 + nodeID := strings.TrimSpace(link.port.deviceID)
71 + if designatedNodeID == "" || nodeID == "" {
72 + continue
73 + }
74 +
75 + added := false
76 + for _, segment := range bblSegments {
77 + if segment.containsPort(link.designatedPort) {
78 + segment.addPort(link.port)
79 + added = true
80 + break
81 + }
82 + }
83 + if !added {
84 + segment := newBridgeDomainSegment(link.designatedPort)
85 + segment.addPort(link.port)
86 + bblSegments = append(bblSegments, segment)
87 + }
88 +
89 + mergeRootDomainSets(rootToNodes, designatedNodeID, nodeID)
90 + }
91 +
92 + bmlSegments := make([]*bridgeDomainSegment, 0)
93 + for _, link := range macLinks {
94 + if strings.TrimSpace(link.port.deviceID) == "" || strings.TrimSpace(link.endpointID) == "" {
95 + continue
96 + }
97 +
98 + added := false
99 + for _, segment := range bblSegments {
100 + if segment.containsPort(link.port) {
101 + segment.addEndpoint(link.endpointID, link.method)
102 + added = true
103 + break
104 + }
105 + }
106 + if added {
107 + continue
108 + }
109 + for _, segment := range bmlSegments {
110 + if segment.containsPort(link.port) {
111 + segment.addEndpoint(link.endpointID, link.method)
112 + added = true
113 + break
114 + }
115 + }
116 + if added {
117 + continue
118 + }
119 +
120 + segment := newBridgeDomainSegment(link.port)
121 + segment.addEndpoint(link.endpointID, link.method)
122 + bmlSegments = append(bmlSegments, segment)
123 + }
124 +
125 + rootIDs := sortedStringKeys(rootToNodes)
126 + for _, rootID := range rootIDs {
127 + domain := &bridgeBroadcastDomain{
128 + bridges: make(map[string]*bridgeDomainBridge),
129 + segments: make([]*bridgeDomainSegment, 0),
130 + }
131 + domain.bridges[rootID] = &bridgeDomainBridge{nodeID: rootID, root: true}
132 + for nodeID := range rootToNodes[rootID] {
133 + domain.bridges[nodeID] = &bridgeDomainBridge{nodeID: nodeID, root: false}
134 + }
135 + model.domains = append(model.domains, domain)
136 + }
137 +
138 + for _, segment := range bblSegments {
139 + for _, domain := range model.domains {
140 + if domain.loadSegment(segment) {
141 + break
142 + }
143 + }
144 + }
145 +
146 + for _, segment := range bmlSegments {
147 + inserted := false
148 + for _, domain := range model.domains {
149 + if domain.loadSegment(segment) {
150 + inserted = true
151 + break
152 + }
153 + }
154 + if inserted {
155 + continue
156 + }
157 +
158 + rootID := strings.TrimSpace(segment.designatedPort.deviceID)
159 + if rootID == "" {
160 + continue
161 + }
162 + domain := &bridgeBroadcastDomain{
163 + bridges: map[string]*bridgeDomainBridge{
164 + rootID: {nodeID: rootID, root: true},
165 + },
166 + segments: make([]*bridgeDomainSegment, 0, 1),
167 + }
168 + domain.loadSegment(segment)
169 + model.domains = append(model.domains, domain)
170 + }
171 +
172 + sort.SliceStable(model.domains, func(i, j int) bool {
173 + return model.domains[i].sortKey() < model.domains[j].sortKey()
174 + })
175 + for _, domain := range model.domains {
176 + domain.sortSegments()
177 + }
178 +
179 + return model
180 +}
181 +
182 +func mergeRootDomainSets(rootToNodes map[string]bridgeNodeSet, designatedNodeID, nodeID string) {
183 + designatedNodeID = strings.TrimSpace(designatedNodeID)
184 + nodeID = strings.TrimSpace(nodeID)
185 + if designatedNodeID == "" || nodeID == "" {
186 + return
187 + }
188 +
189 + targetRoot := findRootForNode(rootToNodes, designatedNodeID)
190 + if targetRoot == "" {
191 + targetRoot = designatedNodeID
192 + }
193 + targetSet := rootToNodes[targetRoot]
194 + if targetSet == nil {
195 + targetSet = make(bridgeNodeSet)
196 + }
197 + if designatedNodeID != targetRoot {
198 + targetSet.add(designatedNodeID)
199 + }
200 +
201 + sourceRoot := findRootForNode(rootToNodes, nodeID)
202 + if sourceRoot != "" && sourceRoot != targetRoot {
203 + if sourceSet, ok := rootToNodes[sourceRoot]; ok {
204 + for id := range sourceSet {
205 + targetSet.add(id)
206 + }
207 + delete(rootToNodes, sourceRoot)
208 + }
209 + targetSet.add(sourceRoot)
210 + }
211 + if nodeID != targetRoot {
212 + targetSet.add(nodeID)
213 + }
214 +
215 + rootToNodes[targetRoot] = targetSet
216 +}
217 +
218 +func findRootForNode(rootToNodes map[string]bridgeNodeSet, nodeID string) string {
219 + nodeID = strings.TrimSpace(nodeID)
220 + if nodeID == "" {
221 + return ""
222 + }
223 + if _, ok := rootToNodes[nodeID]; ok {
224 + return nodeID
225 + }
226 + if rootID := findRootContaining(rootToNodes, nodeID); rootID != "" {
227 + return rootID
228 + }
229 + return ""
230 +}
231 +
232 +func findRootContaining(rootToNodes map[string]bridgeNodeSet, nodeID string) string {
233 + for rootID, set := range rootToNodes {
234 + if _, ok := set[nodeID]; ok {
235 + return rootID
236 + }
237 + }
238 + return ""
239 +}
240 +
241 +func newBridgeDomainSegment(designatedPort bridgePortRef) *bridgeDomainSegment {
242 + seg := &bridgeDomainSegment{
243 + designatedPort: designatedPort,
244 + ports: make(map[string]bridgePortRef),
245 + endpointIDs: make(map[string]struct{}),
246 + methods: make(map[string]struct{}),
247 + }
248 + seg.addPort(designatedPort)
249 + return seg
250 +}
251 +
252 +func (s *bridgeDomainSegment) containsPort(port bridgePortRef) bool {
253 + if s == nil {
254 + return false
255 + }
256 + _, ok := s.ports[s.portIdentityKey(port)]
257 + return ok
258 +}
259 +
260 +func (s *bridgeDomainSegment) addPort(port bridgePortRef) {
261 + if s == nil {
262 + return
263 + }
264 + key := s.portIdentityKey(port)
265 + if key == "" {
266 + return
267 + }
268 + if existing, ok := s.ports[key]; ok {
269 + if existing.ifName == "" {
270 + existing.ifName = port.ifName
271 + }
272 + if existing.ifIndex == 0 && port.ifIndex > 0 {
273 + existing.ifIndex = port.ifIndex
274 + }
275 + if existing.bridgePort == "" {
276 + existing.bridgePort = port.bridgePort
277 + }
278 + if existing.vlanID == "" {
279 + existing.vlanID = port.vlanID
280 + }
281 + port = existing
282 + }
283 + s.ports[key] = port
284 +}
285 +
286 +func (s *bridgeDomainSegment) addEndpoint(endpointID, method string) {
287 + if s == nil {
288 + return
289 + }
290 + endpointID = strings.TrimSpace(endpointID)
291 + if endpointID == "" {
292 + return
293 + }
294 + s.endpointIDs[endpointID] = struct{}{}
295 + method = strings.ToLower(strings.TrimSpace(method))
296 + if method == "" {
297 + method = "fdb"
298 + }
299 + s.methods[method] = struct{}{}
300 +}
301 +
302 +func (s *bridgeDomainSegment) portIdentityKey(port bridgePortRef) string {
303 + nodeID := strings.TrimSpace(port.deviceID)
304 + bridgePort := strings.TrimSpace(port.bridgePort)
305 + if bridgePort == "" {
306 + if port.ifIndex > 0 {
307 + bridgePort = strconvItoa(port.ifIndex)
308 + } else {
309 + bridgePort = strings.TrimSpace(port.ifName)
310 + }
311 + }
312 + if nodeID == "" || bridgePort == "" {
313 + return ""
314 + }
315 + return nodeID + keySep + strings.ToLower(bridgePort)
316 +}
317 +
318 +func (s *bridgeDomainSegment) sortKey() string {
319 + return portSortKey(s.designatedPort) + keySep + strings.Join(sortedBridgePortSet(s.ports), ",")
320 +}
321 +
322 +func (d *bridgeBroadcastDomain) loadSegment(segment *bridgeDomainSegment) bool {
323 + if d == nil || segment == nil {
324 + return false
325 + }
326 + for _, port := range segment.ports {
327 + if _, ok := d.bridges[strings.TrimSpace(port.deviceID)]; ok {
328 + d.segments = append(d.segments, segment)
329 + return true
330 + }
331 + }
332 + return false
333 +}
334 +
335 +func (d *bridgeBroadcastDomain) sortKey() string {
336 + if d == nil {
337 + return ""
338 + }
339 + ids := make([]string, 0, len(d.bridges))
340 + for id := range d.bridges {
341 + ids = append(ids, id)
342 + }
343 + sort.Strings(ids)
344 + return strings.Join(ids, ",")
345 +}
346 +
347 +func (d *bridgeBroadcastDomain) sortSegments() {
348 + if d == nil {
349 + return
350 + }
351 + sort.SliceStable(d.segments, func(i, j int) bool {
352 + return d.segments[i].sortKey() < d.segments[j].sortKey()
353 + })
354 +}
355 +
356 +func sortedBridgePortSet(m map[string]bridgePortRef) []string {
357 + out := make([]string, 0, len(m))
358 + for _, port := range m {
359 + out = append(out, portSortKey(port))
360 + }
361 + sort.Strings(out)
362 + return out
363 +}
364 +
365 +func portSortKey(port bridgePortRef) string {
366 + return strings.Join([]string{
367 + strings.TrimSpace(port.deviceID),
368 + strings.ToLower(strings.TrimSpace(port.bridgePort)),
369 + strings.TrimSpace(port.ifName),
370 + strconvItoa(port.ifIndex),
371 + strings.TrimSpace(port.vlanID),
372 + }, keySep)
373 +}
374 +
375 +func strconvItoa(v int) string {
376 + if v <= 0 {
377 + return ""
378 + }
379 + return strconv.Itoa(v)
380 +}
381 +
382 +func sortedStringKeys[T any](m map[string]T) []string {
383 + keys := make([]string, 0, len(m))
384 + for k := range m {
385 + keys = append(keys, k)
386 + }
387 + sort.Strings(keys)
388 + return keys
389 +}
src/go/pkg/topology/engine/bridge_domain_model_test.go new
+230
@@ -0,0 +1,230 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestBuildBridgeDomainModel_MergesRootSetsLikeEnlinkdGetAllPersisted(t *testing.T) {
12 + model := buildBridgeDomainModel(
13 + []bridgeBridgeLinkRecord{
14 + {
15 + port: bridgePortRef{deviceID: "node-a", bridgePort: "10", ifIndex: 10},
16 + designatedPort: bridgePortRef{deviceID: "node-b", bridgePort: "1", ifIndex: 1},
17 + },
18 + {
19 + port: bridgePortRef{deviceID: "node-b", bridgePort: "2", ifIndex: 2},
20 + designatedPort: bridgePortRef{deviceID: "node-c", bridgePort: "1", ifIndex: 1},
21 + },
22 + },
23 + nil,
24 + )
25 +
26 + require.Len(t, model.domains, 1)
27 + domain := model.domains[0]
28 + require.Len(t, domain.bridges, 3)
29 + require.True(t, domain.bridges["node-c"].root)
30 + require.False(t, domain.bridges["node-a"].root)
31 + require.False(t, domain.bridges["node-b"].root)
32 + require.Len(t, domain.segments, 2)
33 +}
34 +
35 +func TestBuildBridgeDomainModel_AttachesMacsToMatchingBridgeSegments(t *testing.T) {
36 + model := buildBridgeDomainModel(
37 + []bridgeBridgeLinkRecord{
38 + {
39 + port: bridgePortRef{deviceID: "leaf", bridgePort: "2", ifIndex: 2, ifName: "Gi0/2"},
40 + designatedPort: bridgePortRef{deviceID: "root", bridgePort: "1", ifIndex: 1, ifName: "Gi0/1"},
41 + },
42 + },
43 + []bridgeMacLinkRecord{
44 + {port: bridgePortRef{deviceID: "leaf", bridgePort: "2", ifIndex: 2, ifName: "Gi0/2"}, endpointID: "mac:00:11:22:33:44:55", method: "fdb"},
45 + {port: bridgePortRef{deviceID: "leaf", bridgePort: "9", ifIndex: 9, ifName: "Gi0/9"}, endpointID: "mac:aa:bb:cc:dd:ee:ff", method: "fdb"},
46 + },
47 + )
48 +
49 + require.Len(t, model.domains, 1)
50 + domain := model.domains[0]
51 + require.Len(t, domain.segments, 2)
52 +
53 + var shared *bridgeDomainSegment
54 + var standalone *bridgeDomainSegment
55 + for _, segment := range domain.segments {
56 + if segment == nil {
57 + continue
58 + }
59 + if len(segment.ports) == 2 {
60 + shared = segment
61 + }
62 + if len(segment.ports) == 1 {
63 + standalone = segment
64 + }
65 + }
66 +
67 + require.NotNil(t, shared)
68 + require.Contains(t, shared.endpointIDs, "mac:00:11:22:33:44:55")
69 + require.NotNil(t, standalone)
70 + require.Contains(t, standalone.endpointIDs, "mac:aa:bb:cc:dd:ee:ff")
71 +}
72 +
73 +func TestMergeRootDomainSets_MergesNodeMemberIntoExistingDesignatedRoot(t *testing.T) {
74 + rootToNodes := map[string]bridgeNodeSet{
75 + "designated-root": {"leaf-a": {}},
76 + "other-root": {"node-x": {}, "leaf-b": {}},
77 + }
78 +
79 + mergeRootDomainSets(rootToNodes, "designated-root", "node-x")
80 +
81 + require.Len(t, rootToNodes, 1)
82 + require.Contains(t, rootToNodes, "designated-root")
83 + require.Equal(t, bridgeNodeSet{
84 + "leaf-a": {},
85 + "other-root": {},
86 + "node-x": {},
87 + "leaf-b": {},
88 + }, rootToNodes["designated-root"])
89 +}
90 +
91 +func TestMergeRootDomainSets_MergesTwoNonRootMembersAcrossDomains(t *testing.T) {
92 + rootToNodes := map[string]bridgeNodeSet{
93 + "root-a": {"designated-member": {}, "leaf-a": {}},
94 + "root-b": {"node-member": {}, "leaf-b": {}},
95 + }
96 +
97 + mergeRootDomainSets(rootToNodes, "designated-member", "node-member")
98 +
99 + require.Len(t, rootToNodes, 1)
100 + require.Contains(t, rootToNodes, "root-a")
101 + require.Equal(t, bridgeNodeSet{
102 + "designated-member": {},
103 + "leaf-a": {},
104 + "root-b": {},
105 + "node-member": {},
106 + "leaf-b": {},
107 + }, rootToNodes["root-a"])
108 +}
109 +
110 +func TestCollectBridgeLinkRecords_DeduplicatesUndirectedAdjacencies(t *testing.T) {
111 + records := collectBridgeLinkRecords([]Adjacency{
112 + {
113 + Protocol: "lldp",
114 + SourceID: "a",
115 + SourcePort: "Gi0/1",
116 + TargetID: "b",
117 + TargetPort: "Gi0/2",
118 + },
119 + {
120 + Protocol: "lldp",
121 + SourceID: "b",
122 + SourcePort: "Gi0/2",
123 + TargetID: "a",
124 + TargetPort: "Gi0/1",
125 + },
126 + }, map[string]int{
127 + deviceIfNameKey("a", "Gi0/1"): 1,
128 + deviceIfNameKey("b", "Gi0/2"): 2,
129 + }, topologyInferenceStrategyConfigFor(topologyInferenceStrategyFDBMinimumKnowledge))
130 +
131 + require.Len(t, records, 1)
132 + require.Equal(t, "a", records[0].designatedPort.deviceID)
133 + require.Equal(t, "b", records[0].port.deviceID)
134 +}
135 +
136 +func TestCollectBridgeLinkRecords_SkipsAdjacencyWithoutRemotePort(t *testing.T) {
137 + records := collectBridgeLinkRecords([]Adjacency{
138 + {
139 + Protocol: "lldp",
140 + SourceID: "a",
141 + SourcePort: "Gi0/1",
142 + TargetID: "b",
143 + TargetPort: "",
144 + },
145 + }, map[string]int{
146 + deviceIfNameKey("a", "Gi0/1"): 1,
147 + }, topologyInferenceStrategyConfigFor(topologyInferenceStrategyFDBMinimumKnowledge))
148 +
149 + require.Empty(t, records)
150 +}
151 +
152 +func TestCollectBridgeLinkRecords_STPParentTreeUsesDesignatedTargetPort(t *testing.T) {
153 + records := collectBridgeLinkRecords([]Adjacency{
154 + {
155 + Protocol: "stp",
156 + SourceID: "child",
157 + SourcePort: "Gi0/10",
158 + TargetID: "root",
159 + TargetPort: "Gi0/1",
160 + },
161 + }, map[string]int{
162 + deviceIfNameKey("child", "Gi0/10"): 10,
163 + deviceIfNameKey("root", "Gi0/1"): 1,
164 + }, topologyInferenceStrategyConfigFor(topologyInferenceStrategySTPParentTree))
165 +
166 + require.Len(t, records, 1)
167 + require.Equal(t, "root", records[0].designatedPort.deviceID)
168 + require.Equal(t, "child", records[0].port.deviceID)
169 + require.Equal(t, "stp", records[0].method)
170 +}
171 +
172 +func TestCollectBridgeLinkRecords_CDPHybridSkipsLLDPAdjacencies(t *testing.T) {
173 + records := collectBridgeLinkRecords([]Adjacency{
174 + {
175 + Protocol: "lldp",
176 + SourceID: "a",
177 + SourcePort: "Gi0/1",
178 + TargetID: "b",
179 + TargetPort: "Gi0/2",
180 + },
181 + {
182 + Protocol: "cdp",
183 + SourceID: "a",
184 + SourcePort: "Gi0/3",
185 + TargetID: "c",
186 + TargetPort: "Gi0/4",
187 + },
188 + }, map[string]int{
189 + deviceIfNameKey("a", "Gi0/1"): 1,
190 + deviceIfNameKey("b", "Gi0/2"): 2,
191 + deviceIfNameKey("a", "Gi0/3"): 3,
192 + deviceIfNameKey("c", "Gi0/4"): 4,
193 + }, topologyInferenceStrategyConfigFor(topologyInferenceStrategyCDPFDBHybrid))
194 +
195 + require.Len(t, records, 1)
196 + require.Equal(t, "cdp", records[0].method)
197 + require.Equal(t, "a", records[0].designatedPort.deviceID)
198 + require.Equal(t, "c", records[0].port.deviceID)
199 +}
200 +
201 +func TestInferFDBPairwiseBridgeLinks_ReciprocalUniquePortPerSide(t *testing.T) {
202 + attachments := []Attachment{
203 + {
204 + DeviceID: "sw-a",
205 + IfIndex: 1,
206 + EndpointID: "mac:bb:bb:bb:bb:bb:bb",
207 + Method: "fdb",
208 + },
209 + {
210 + DeviceID: "sw-b",
211 + IfIndex: 2,
212 + EndpointID: "mac:aa:aa:aa:aa:aa:aa",
213 + Method: "fdb",
214 + },
215 + }
216 + ifaceByDeviceIndex := map[string]Interface{
217 + deviceIfIndexKey("sw-a", 1): {DeviceID: "sw-a", IfIndex: 1, IfName: "Gi0/1"},
218 + deviceIfIndexKey("sw-b", 2): {DeviceID: "sw-b", IfIndex: 2, IfName: "Gi0/2"},
219 + }
220 + reporterAliases := map[string][]string{
221 + "sw-a": {"mac:aa:aa:aa:aa:aa:aa"},
222 + "sw-b": {"mac:bb:bb:bb:bb:bb:bb"},
223 + }
224 +
225 + records := inferFDBPairwiseBridgeLinks(attachments, ifaceByDeviceIndex, reporterAliases)
226 + require.Len(t, records, 1)
227 + require.Equal(t, "fdb_pairwise", records[0].method)
228 + require.Equal(t, "sw-a", records[0].designatedPort.deviceID)
229 + require.Equal(t, "sw-b", records[0].port.deviceID)
230 +}
src/go/pkg/topology/engine/doc.go new
+5
@@ -0,0 +1,5 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +// Package engine provides a reusable topology discovery interface that can be
4 +// shared by multiple collectors and services.
5 +package engine
src/go/pkg/topology/engine/engine.go new
+19
@@ -0,0 +1,19 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "context"
7 + "errors"
8 +)
9 +
10 +var (
11 + ErrNotImplemented = errors.New("topology engine not implemented")
12 + ErrInvalidRequest = errors.New("invalid topology discovery request")
13 +)
14 +
15 +// Engine executes topology discovery from different seed inputs.
16 +type Engine interface {
17 + DiscoverByCIDRs(ctx context.Context, req CIDRRequest) (Result, error)
18 + DiscoverByDevices(ctx context.Context, req DeviceRequest) (Result, error)
19 +}
src/go/pkg/topology/engine/engine_test.go new
+22
@@ -0,0 +1,22 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "context"
7 + "testing"
8 +
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestNoopEngine_DiscoverByCIDRs(t *testing.T) {
13 + eng := NoopEngine{}
14 + _, err := eng.DiscoverByCIDRs(context.Background(), CIDRRequest{})
15 + require.ErrorIs(t, err, ErrNotImplemented)
16 +}
17 +
18 +func TestNoopEngine_DiscoverByDevices(t *testing.T) {
19 + eng := NoopEngine{}
20 + _, err := eng.DiscoverByDevices(context.Background(), DeviceRequest{})
21 + require.ErrorIs(t, err, ErrNotImplemented)
22 +}
src/go/pkg/topology/engine/l2_pipeline.go new
+49
@@ -0,0 +1,49 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "fmt"
6 +
7 +const (
8 + fdbStatusLearned = "learned"
9 + fdbStatusSelf = "self"
10 + fdbStatusIgnored = "ignored"
11 +)
12 +
13 +// BuildL2ResultFromObservations converts normalized L2 observations into a
14 +// deterministic engine result. Callers that need a stable timestamp should set
15 +// DiscoverOptions.CollectedAt explicitly.
16 +func BuildL2ResultFromObservations(observations []L2Observation, opts DiscoverOptions) (Result, error) {
17 + if len(observations) == 0 {
18 + return Result{}, fmt.Errorf("%w: at least one observation is required", ErrInvalidRequest)
19 + }
20 + if !opts.EnableLLDP && !opts.EnableCDP && !opts.EnableBridge && !opts.EnableARP && !opts.EnableSTP {
21 + opts.EnableLLDP = true
22 + opts.EnableCDP = true
23 + }
24 +
25 + state := newL2BuildState(len(observations))
26 + if err := state.registerObservations(observations); err != nil {
27 + return Result{}, err
28 + }
29 + if opts.EnableLLDP {
30 + state.applyLLDP(observations)
31 + }
32 + if opts.EnableCDP {
33 + state.applyCDP(observations)
34 + }
35 + if opts.EnableSTP {
36 + state.applySTP(observations)
37 + }
38 + if opts.EnableBridge {
39 + state.applyBridge(observations)
40 + }
41 + if opts.EnableARP {
42 + state.applyARP(observations)
43 + }
44 +
45 + identityAliasStats := reconcileDeviceIdentityAliases(state.devices, state.interfaces, state.enrichments)
46 + state.markManagedDevices()
47 +
48 + return state.buildResult(identityAliasStats, opts.CollectedAt), nil
49 +}
src/go/pkg/topology/engine/l2_pipeline_address_normalization.go new
+188
@@ -0,0 +1,188 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "encoding/hex"
7 + "fmt"
8 + "net/netip"
9 + "strconv"
10 + "strings"
11 +)
12 +
13 +func canonicalBridgeAddr(value, fallback string) string {
14 + if mac := normalizeMAC(value); mac != "" && mac != "00:00:00:00:00:00" {
15 + return mac
16 + }
17 + if mac := normalizeMAC(fallback); mac != "" && mac != "00:00:00:00:00:00" {
18 + return mac
19 + }
20 + return ""
21 +}
22 +
23 +func primaryL2MACIdentity(chassisID, baseBridgeAddress string) string {
24 + for _, candidate := range []string{chassisID, baseBridgeAddress} {
25 + if mac := normalizeMAC(candidate); mac != "" && mac != "00:00:00:00:00:00" {
26 + return mac
27 + }
28 + }
29 + return ""
30 +}
31 +
32 +func canonicalIP(v string) string {
33 + if ip := parseAddr(v); ip.IsValid() {
34 + return ip.String()
35 + }
36 + if ip := parseAddr(decodeHexIP(v)); ip.IsValid() {
37 + return ip.String()
38 + }
39 + return ""
40 +}
41 +
42 +func parseAddr(v string) netip.Addr {
43 + addr, err := netip.ParseAddr(strings.TrimSpace(v))
44 + if err != nil {
45 + return netip.Addr{}
46 + }
47 + return addr.Unmap()
48 +}
49 +
50 +func decodeHexIP(v string) string {
51 + bs := decodeHexBytes(v)
52 + if len(bs) == 4 {
53 + addr, ok := netip.AddrFromSlice(bs)
54 + if ok {
55 + return addr.Unmap().String()
56 + }
57 + }
58 + if len(bs) == 16 {
59 + addr, ok := netip.AddrFromSlice(bs)
60 + if ok {
61 + return addr.String()
62 + }
63 + }
64 + return ""
65 +}
66 +
67 +func decodeHexBytes(v string) []byte {
68 + clean := strings.ToLower(strings.TrimSpace(v))
69 + clean = strings.TrimPrefix(clean, "0x")
70 + if clean == "" {
71 + return nil
72 + }
73 +
74 + if strings.ContainsAny(clean, ":-. \t") {
75 + parts := strings.FieldsFunc(clean, func(r rune) bool {
76 + return r == ':' || r == '-' || r == '.' || r == ' ' || r == '\t'
77 + })
78 + if len(parts) == 0 {
79 + return nil
80 + }
81 + if bs := decodeGroupedHexParts(parts); len(bs) != 0 {
82 + return bs
83 + }
84 +
85 + out := make([]byte, 0, len(parts))
86 + for _, part := range parts {
87 + part = strings.TrimSpace(part)
88 + if part == "" {
89 + continue
90 + }
91 + if len(part) > 2 {
92 + return nil
93 + }
94 + if len(part) == 1 {
95 + part = "0" + part
96 + }
97 + b, err := hex.DecodeString(part)
98 + if err != nil || len(b) != 1 {
99 + return nil
100 + }
101 + out = append(out, b[0])
102 + }
103 + if len(out) == 0 {
104 + return nil
105 + }
106 + return out
107 + }
108 +
109 + if len(clean)%2 == 1 {
110 + clean = "0" + clean
111 + }
112 + bs, err := hex.DecodeString(clean)
113 + if err != nil {
114 + return nil
115 + }
116 + return bs
117 +}
118 +
119 +func decodeGroupedHexParts(parts []string) []byte {
120 + var joined strings.Builder
121 + anyWidePart := false
122 +
123 + for _, part := range parts {
124 + part = strings.TrimSpace(part)
125 + if part == "" {
126 + continue
127 + }
128 + if len(part)%2 != 0 {
129 + return nil
130 + }
131 + if len(part) > 2 {
132 + anyWidePart = true
133 + }
134 + joined.WriteString(part)
135 + }
136 +
137 + if !anyWidePart || joined.Len() == 0 {
138 + return nil
139 + }
140 +
141 + bs, err := hex.DecodeString(joined.String())
142 + if err != nil || len(bs) == 0 {
143 + return nil
144 + }
145 + return bs
146 +}
147 +
148 +func normalizeMAC(v string) string {
149 + v = strings.TrimSpace(v)
150 + if v == "" {
151 + return ""
152 + }
153 +
154 + if bs := parseDottedDecimalBytes(v); len(bs) == 6 {
155 + return formatMAC(bs)
156 + }
157 + if bs := decodeHexBytes(v); len(bs) == 6 {
158 + return formatMAC(bs)
159 + }
160 + return ""
161 +}
162 +
163 +func parseDottedDecimalBytes(v string) []byte {
164 + parts := strings.Split(strings.TrimSpace(v), ".")
165 + if len(parts) != 6 {
166 + return nil
167 + }
168 + out := make([]byte, 0, 6)
169 + for _, part := range parts {
170 + n, err := strconv.Atoi(strings.TrimSpace(part))
171 + if err != nil || n < 0 || n > 255 {
172 + return nil
173 + }
174 + out = append(out, byte(n))
175 + }
176 + return out
177 +}
178 +
179 +func formatMAC(bs []byte) string {
180 + if len(bs) != 6 {
181 + return ""
182 + }
183 + parts := make([]string, 0, 6)
184 + for _, b := range bs {
185 + parts = append(parts, fmt.Sprintf("%02x", b))
186 + }
187 + return strings.Join(parts, ":")
188 +}
src/go/pkg/topology/engine/l2_pipeline_adjacencies.go new
+148
@@ -0,0 +1,148 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "strings"
6 +
7 +func (s *l2BuildState) applyLLDP(observations []L2Observation) {
8 + lldpLinks := buildLLDPMatchLinks(observations)
9 + annotateLLDPLinkMatchIdentities(lldpLinks, s.hostToID, s.chassisToID, s.ipToID)
10 + lldpPairs := matchLLDPLinksEnlinkdPassOrder(lldpLinks)
11 + lldpTargetOverrides := buildLLDPTargetOverrides(lldpLinks, lldpPairs)
12 + lldpPairMetadata := buildLLDPPairMetadata(lldpLinks, lldpPairs)
13 +
14 + for _, link := range lldpLinks {
15 + targetID := strings.TrimSpace(lldpTargetOverrides[link.index])
16 + if targetID == "" {
17 + targetID = s.resolveRemote(link.remoteSysName, link.remoteChassisID, link.remoteManagement, link.remoteFallbackID)
18 + }
19 +
20 + adj := Adjacency{
21 + Protocol: "lldp",
22 + SourceID: link.sourceDeviceID,
23 + SourcePort: link.sourcePort,
24 + TargetID: targetID,
25 + TargetPort: link.targetPort,
26 + }
27 + applyAdjacencyPairMetadata(&adj, lldpPairMetadata[link.index])
28 + if addAdjacency(s.adjacencies, adj) {
29 + s.linksLLDP++
30 + }
31 + }
32 +}
33 +
34 +func (s *l2BuildState) applyCDP(observations []L2Observation) {
35 + cdpLinks := buildCDPMatchLinks(observations)
36 + cdpPairs := matchCDPLinksEnlinkdPassOrder(cdpLinks)
37 + cdpTargetOverrides := buildCDPTargetOverrides(cdpLinks, cdpPairs)
38 + cdpPairMetadata := buildCDPPairMetadata(cdpLinks, cdpPairs)
39 +
40 + for _, link := range cdpLinks {
41 + rawAddress := strings.TrimSpace(link.remoteAddressRaw)
42 + targetID := strings.TrimSpace(cdpTargetOverrides[link.index])
43 + if targetID == "" {
44 + targetIP := canonicalIP(rawAddress)
45 + targetID = s.resolveRemoteEnforcingHostnameMACGuard(link.remoteHost, link.remoteDeviceID, targetIP, link.remoteDeviceID)
46 + }
47 +
48 + adj := Adjacency{
49 + Protocol: "cdp",
50 + SourceID: link.sourceDeviceID,
51 + SourcePort: link.localInterfaceName,
52 + TargetID: targetID,
53 + TargetPort: link.remoteDevicePort,
54 + }
55 + if rawAddress != "" {
56 + adj.Labels = map[string]string{
57 + "remote_address_raw": strings.ToLower(rawAddress),
58 + }
59 + }
60 + applyAdjacencyPairMetadata(&adj, cdpPairMetadata[link.index])
61 + if addAdjacency(s.adjacencies, adj) {
62 + s.linksCDP++
63 + }
64 + }
65 +}
66 +
67 +func (s *l2BuildState) applySTP(observations []L2Observation) {
68 + for _, obs := range observations {
69 + sourceID := strings.TrimSpace(obs.DeviceID)
70 + if sourceID == "" {
71 + continue
72 + }
73 +
74 + localBridgeAddr := canonicalBridgeAddr(obs.BaseBridgeAddress, obs.ChassisID)
75 + bridgePortToIfIndex := make(map[string]int, len(obs.BridgePorts))
76 + for _, bridgePort := range sortedBridgePorts(obs.BridgePorts) {
77 + basePort := strings.TrimSpace(bridgePort.BasePort)
78 + if basePort == "" || bridgePort.IfIndex <= 0 {
79 + continue
80 + }
81 + bridgePortToIfIndex[basePort] = bridgePort.IfIndex
82 + }
83 +
84 + for _, entry := range sortedSTPPortEntries(obs.STPPorts) {
85 + remoteBridgeAddr := canonicalBridgeAddr(entry.DesignatedBridge, "")
86 + if remoteBridgeAddr == "" {
87 + continue
88 + }
89 + if localBridgeAddr != "" && localBridgeAddr == remoteBridgeAddr {
90 + continue
91 + }
92 +
93 + targetID := strings.TrimSpace(s.bridgeAddrToID[remoteBridgeAddr])
94 + if targetID == "" || targetID == sourceID {
95 + continue
96 + }
97 +
98 + ifIndex := entry.IfIndex
99 + if ifIndex <= 0 {
100 + ifIndex = bridgePortToIfIndex[strings.TrimSpace(entry.Port)]
101 + }
102 + sourcePort := strings.TrimSpace(entry.IfName)
103 + if sourcePort == "" && ifIndex > 0 {
104 + sourcePort = strings.TrimSpace(s.ifNameByDeviceIfIndex[deviceIfIndexKey(sourceID, ifIndex)])
105 + }
106 + if sourcePort == "" {
107 + sourcePort = strings.TrimSpace(entry.Port)
108 + }
109 +
110 + adj := Adjacency{
111 + Protocol: "stp",
112 + SourceID: sourceID,
113 + SourcePort: sourcePort,
114 + TargetID: targetID,
115 + TargetPort: strings.TrimSpace(entry.DesignatedPort),
116 + }
117 + labels := make(map[string]string)
118 + if v := strings.TrimSpace(entry.Port); v != "" {
119 + labels["stp_port"] = v
120 + }
121 + if v := strings.TrimSpace(entry.State); v != "" {
122 + labels["stp_state"] = v
123 + }
124 + if v := strings.TrimSpace(entry.Enable); v != "" {
125 + labels["stp_enable"] = v
126 + }
127 + if v := strings.TrimSpace(entry.PathCost); v != "" {
128 + labels["stp_path_cost"] = v
129 + }
130 + if v := strings.TrimSpace(entry.DesignatedRoot); v != "" {
131 + labels["stp_designated_root"] = v
132 + }
133 + if v := strings.TrimSpace(entry.VLANID); v != "" {
134 + labels["vlan_id"] = v
135 + labels["vlan"] = v
136 + }
137 + if v := strings.TrimSpace(entry.VLANName); v != "" {
138 + labels["vlan_name"] = v
139 + }
140 + if len(labels) > 0 {
141 + adj.Labels = labels
142 + }
143 + if addAdjacency(s.adjacencies, adj) {
144 + s.linksSTP++
145 + }
146 + }
147 + }
148 +}
src/go/pkg/topology/engine/l2_pipeline_builder.go new
+122
@@ -0,0 +1,122 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strings"
7 + "time"
8 +)
9 +
10 +type l2BuildState struct {
11 + devices map[string]Device
12 + managedObservationByDeviceID map[string]bool
13 + interfaces map[string]Interface
14 + adjacencies map[string]Adjacency
15 + attachments map[string]Attachment
16 + enrichments map[string]*enrichmentAccumulator
17 + ifNameByDeviceIfIndex map[string]string
18 +
19 + hostToID map[string]string
20 + ipToID map[string]string
21 + chassisToID map[string]string
22 + macToID map[string]string
23 + bridgeAddrToID map[string]string
24 +
25 + linksLLDP int
26 + linksCDP int
27 + linksSTP int
28 + attachmentsFDB int
29 + enrichmentsARPND int
30 +
31 + bridgeDomains map[string]struct{}
32 + endpointIDs map[string]struct{}
33 +}
34 +
35 +func newL2BuildState(observationCount int) *l2BuildState {
36 + return &l2BuildState{
37 + devices: make(map[string]Device, observationCount),
38 + managedObservationByDeviceID: make(map[string]bool, observationCount),
39 + interfaces: make(map[string]Interface),
40 + adjacencies: make(map[string]Adjacency),
41 + attachments: make(map[string]Attachment),
42 + enrichments: make(map[string]*enrichmentAccumulator),
43 + ifNameByDeviceIfIndex: make(map[string]string),
44 + hostToID: make(map[string]string, observationCount),
45 + ipToID: make(map[string]string, observationCount),
46 + chassisToID: make(map[string]string, observationCount),
47 + macToID: make(map[string]string, observationCount),
48 + bridgeAddrToID: make(map[string]string, observationCount),
49 + bridgeDomains: make(map[string]struct{}),
50 + endpointIDs: make(map[string]struct{}),
51 + }
52 +}
53 +
54 +func (s *l2BuildState) refreshEndpointIndex() {
55 + for endpointID := range s.endpointIDs {
56 + delete(s.endpointIDs, endpointID)
57 + }
58 + for _, attachment := range s.attachments {
59 + endpointID := strings.TrimSpace(attachment.EndpointID)
60 + if endpointID == "" {
61 + continue
62 + }
63 + s.endpointIDs[endpointID] = struct{}{}
64 + }
65 + for _, enrichment := range s.enrichments {
66 + if enrichment == nil {
67 + continue
68 + }
69 + endpointID := strings.TrimSpace(enrichment.EndpointID)
70 + if endpointID == "" {
71 + continue
72 + }
73 + s.endpointIDs[endpointID] = struct{}{}
74 + }
75 +}
76 +
77 +func (s *l2BuildState) markManagedDevices() {
78 + for id, dev := range s.devices {
79 + if dev.Labels == nil {
80 + dev.Labels = make(map[string]string)
81 + }
82 + if s.managedObservationByDeviceID[id] {
83 + dev.Labels["inferred"] = "false"
84 + } else {
85 + dev.Labels["inferred"] = "true"
86 + }
87 + s.devices[id] = dev
88 + }
89 +}
90 +
91 +func (s *l2BuildState) buildResult(identityAliasStats identityAliasReconcileStats, collectedAt time.Time) Result {
92 + if !collectedAt.IsZero() {
93 + collectedAt = collectedAt.UTC()
94 + }
95 +
96 + stats := newL2ResultStats()
97 + stats["devices_total"] = len(s.devices)
98 + stats["links_total"] = len(s.adjacencies)
99 + stats["links_lldp"] = s.linksLLDP
100 + stats["links_cdp"] = s.linksCDP
101 + stats["links_stp"] = s.linksSTP
102 + stats["attachments_total"] = len(s.attachments)
103 + stats["attachments_fdb"] = s.attachmentsFDB
104 + stats["enrichments_total"] = len(s.enrichments)
105 + stats["enrichments_arp_nd"] = s.enrichmentsARPND
106 + stats["bridge_domains_total"] = len(s.bridgeDomains)
107 + stats["endpoints_total"] = len(s.endpointIDs)
108 + stats["identity_alias_endpoints_mapped"] = identityAliasStats.endpointsMapped
109 + stats["identity_alias_endpoints_ambiguous_mac"] = identityAliasStats.endpointsAmbiguousMAC
110 + stats["identity_alias_ips_merged"] = identityAliasStats.ipsMerged
111 + stats["identity_alias_ips_conflict_skipped"] = identityAliasStats.ipsConflictSkipped
112 +
113 + return Result{
114 + CollectedAt: collectedAt,
115 + Devices: sortedDevices(s.devices),
116 + Interfaces: sortedInterfaces(s.interfaces),
117 + Adjacencies: sortedAdjacencies(s.adjacencies),
118 + Attachments: sortedAttachments(s.attachments),
119 + Enrichments: sortedEnrichments(s.enrichments),
120 + Stats: stats,
121 + }
122 +}
src/go/pkg/topology/engine/l2_pipeline_collections.go new
+89
@@ -0,0 +1,89 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "net/netip"
7 + "sort"
8 + "strings"
9 +)
10 +
11 +func sortedAddrValues(in map[string]netip.Addr) []netip.Addr {
12 + if len(in) == 0 {
13 + return nil
14 + }
15 + keys := make([]string, 0, len(in))
16 + for key := range in {
17 + keys = append(keys, key)
18 + }
19 + sort.Strings(keys)
20 + out := make([]netip.Addr, 0, len(keys))
21 + for _, key := range keys {
22 + if addr, ok := in[key]; ok && addr.IsValid() {
23 + out = append(out, addr)
24 + }
25 + }
26 + return out
27 +}
28 +
29 +func setToCSV(in map[string]struct{}) string {
30 + if len(in) == 0 {
31 + return ""
32 + }
33 + out := make([]string, 0, len(in))
34 + for value := range in {
35 + value = strings.TrimSpace(value)
36 + if value == "" {
37 + continue
38 + }
39 + out = append(out, value)
40 + }
41 + if len(out) == 0 {
42 + return ""
43 + }
44 + sort.Strings(out)
45 + return strings.Join(out, ",")
46 +}
47 +
48 +func csvToTopologySet(value string) map[string]struct{} {
49 + out := make(map[string]struct{})
50 + for token := range strings.SplitSeq(strings.TrimSpace(value), ",") {
51 + token = strings.TrimSpace(strings.ToLower(token))
52 + if token == "" {
53 + continue
54 + }
55 + out[token] = struct{}{}
56 + }
57 + return out
58 +}
59 +
60 +func observationProtocolsUsed(obs L2Observation) map[string]struct{} {
61 + out := make(map[string]struct{}, 6)
62 + if len(obs.LLDPRemotes) > 0 {
63 + out["lldp"] = struct{}{}
64 + }
65 + if len(obs.CDPRemotes) > 0 {
66 + out["cdp"] = struct{}{}
67 + }
68 + if len(obs.BridgePorts) > 0 {
69 + out["bridge"] = struct{}{}
70 + }
71 + if len(obs.FDBEntries) > 0 {
72 + out["fdb"] = struct{}{}
73 + }
74 + if len(obs.STPPorts) > 0 {
75 + out["stp"] = struct{}{}
76 + }
77 + if len(obs.ARPNDEntries) > 0 {
78 + out["arp"] = struct{}{}
79 + }
80 + return out
81 +}
82 +
83 +func pruneEmptyLabels(labels map[string]string) {
84 + for key, value := range labels {
85 + if strings.TrimSpace(value) == "" {
86 + delete(labels, key)
87 + }
88 + }
89 +}
src/go/pkg/topology/engine/l2_pipeline_endpoints.go new
+120
@@ -0,0 +1,120 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 +)
9 +
10 +func (s *l2BuildState) applyBridge(observations []L2Observation) {
11 + for _, obs := range observations {
12 + sourceID := strings.TrimSpace(obs.DeviceID)
13 + if sourceID == "" {
14 + continue
15 + }
16 +
17 + bridgePortToIfIndex := make(map[string]int, len(obs.BridgePorts))
18 + for _, bridgePort := range sortedBridgePorts(obs.BridgePorts) {
19 + basePort := strings.TrimSpace(bridgePort.BasePort)
20 + if basePort == "" || bridgePort.IfIndex <= 0 {
21 + continue
22 + }
23 + bridgePortToIfIndex[basePort] = bridgePort.IfIndex
24 + }
25 +
26 + for _, candidate := range buildFDBCandidates(obs.FDBEntries, bridgePortToIfIndex) {
27 + endpointID := "mac:" + candidate.mac
28 + attachment := Attachment{
29 + DeviceID: sourceID,
30 + IfIndex: candidate.ifIndex,
31 + EndpointID: endpointID,
32 + Method: "fdb",
33 + }
34 +
35 + labels := make(map[string]string)
36 + if candidate.bridgePort != "" {
37 + labels["bridge_port"] = candidate.bridgePort
38 + }
39 + if status := strings.TrimSpace(candidate.statusRaw); status != "" {
40 + labels["fdb_status"] = status
41 + }
42 + if candidate.ifIndex > 0 {
43 + ifName := strings.TrimSpace(s.ifNameByDeviceIfIndex[deviceIfIndexKey(sourceID, candidate.ifIndex)])
44 + if ifName != "" {
45 + labels["if_name"] = ifName
46 + }
47 + labels["bridge_domain"] = deriveBridgeDomainFromIfIndex(sourceID, candidate.ifIndex)
48 + } else if candidate.bridgePort != "" {
49 + labels["bridge_domain"] = deriveBridgeDomainFromBridgePort(sourceID, candidate.bridgePort)
50 + }
51 + if candidate.vlanID != "" {
52 + labels["vlan_id"] = candidate.vlanID
53 + labels["vlan"] = candidate.vlanID
54 + }
55 + if candidate.vlanName != "" {
56 + labels["vlan_name"] = candidate.vlanName
57 + }
58 + if len(labels) > 0 {
59 + attachment.Labels = labels
60 + }
61 +
62 + if addAttachment(s.attachments, attachment) {
63 + s.attachmentsFDB++
64 + s.endpointIDs[endpointID] = struct{}{}
65 + if domain := attachmentDomain(attachment); domain != "" {
66 + s.bridgeDomains[domain] = struct{}{}
67 + }
68 + }
69 + }
70 + }
71 +}
72 +
73 +func (s *l2BuildState) applyARP(observations []L2Observation) {
74 + for _, obs := range observations {
75 + sourceID := strings.TrimSpace(obs.DeviceID)
76 + if sourceID == "" {
77 + continue
78 + }
79 + for _, entry := range sortedARPNDEntries(obs.ARPNDEntries) {
80 + mac := normalizeMAC(entry.MAC)
81 + ip := canonicalIP(entry.IP)
82 + if mac == "" {
83 + continue
84 + }
85 +
86 + endpointID := "mac:" + mac
87 + acc := ensureEnrichmentAccumulator(s.enrichments, endpointID)
88 + acc.EndpointID = endpointID
89 + acc.MAC = mac
90 + if ip != "" {
91 + addr := parseAddr(ip)
92 + if addr.IsValid() {
93 + acc.IPs[addr.String()] = addr
94 + }
95 + }
96 +
97 + protocol := canonicalARPProtocol(entry.Protocol)
98 + acc.Protocols[protocol] = struct{}{}
99 + acc.DeviceIDs[sourceID] = struct{}{}
100 + if entry.IfIndex > 0 {
101 + acc.IfIndexes[strconv.Itoa(entry.IfIndex)] = struct{}{}
102 + }
103 + ifName := strings.TrimSpace(entry.IfName)
104 + if ifName == "" && entry.IfIndex > 0 {
105 + ifName = strings.TrimSpace(s.ifNameByDeviceIfIndex[deviceIfIndexKey(sourceID, entry.IfIndex)])
106 + }
107 + if ifName != "" {
108 + acc.IfNames[ifName] = struct{}{}
109 + }
110 + if state := strings.TrimSpace(entry.State); state != "" {
111 + acc.States[state] = struct{}{}
112 + }
113 + if addrType := canonicalAddrType(entry.AddrType, ip); addrType != "" {
114 + acc.AddrTypes[addrType] = struct{}{}
115 + }
116 + }
117 + }
118 + s.refreshEndpointIndex()
119 + s.enrichmentsARPND = len(s.enrichments)
120 +}
src/go/pkg/topology/engine/l2_pipeline_enrichment.go new
+257
@@ -0,0 +1,257 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "maps"
7 + "net/netip"
8 + "sort"
9 + "strings"
10 +)
11 +
12 +type enrichmentAccumulator struct {
13 + EndpointID string
14 + MAC string
15 + IPs map[string]netip.Addr
16 + Protocols map[string]struct{}
17 + DeviceIDs map[string]struct{}
18 + IfIndexes map[string]struct{}
19 + IfNames map[string]struct{}
20 + States map[string]struct{}
21 + AddrTypes map[string]struct{}
22 +}
23 +
24 +func ensureEnrichmentAccumulator(enrichments map[string]*enrichmentAccumulator, endpointID string) *enrichmentAccumulator {
25 + acc := enrichments[endpointID]
26 + if acc != nil {
27 + return acc
28 + }
29 + acc = &enrichmentAccumulator{
30 + EndpointID: endpointID,
31 + IPs: make(map[string]netip.Addr),
32 + Protocols: make(map[string]struct{}),
33 + DeviceIDs: make(map[string]struct{}),
34 + IfIndexes: make(map[string]struct{}),
35 + IfNames: make(map[string]struct{}),
36 + States: make(map[string]struct{}),
37 + AddrTypes: make(map[string]struct{}),
38 + }
39 + enrichments[endpointID] = acc
40 + return acc
41 +}
42 +
43 +func mergeEnrichmentAccumulator(target, source *enrichmentAccumulator) {
44 + if target == nil || source == nil || target == source {
45 + return
46 + }
47 + if target.MAC == "" {
48 + target.MAC = source.MAC
49 + }
50 + maps.Copy(target.IPs, source.IPs)
51 + for key := range source.Protocols {
52 + target.Protocols[key] = struct{}{}
53 + }
54 + for key := range source.DeviceIDs {
55 + target.DeviceIDs[key] = struct{}{}
56 + }
57 + for key := range source.IfIndexes {
58 + target.IfIndexes[key] = struct{}{}
59 + }
60 + for key := range source.IfNames {
61 + target.IfNames[key] = struct{}{}
62 + }
63 + for key := range source.States {
64 + target.States[key] = struct{}{}
65 + }
66 + for key := range source.AddrTypes {
67 + target.AddrTypes[key] = struct{}{}
68 + }
69 +}
70 +
71 +type identityAliasReconcileStats struct {
72 + endpointsMapped int
73 + endpointsAmbiguousMAC int
74 + ipsMerged int
75 + ipsConflictSkipped int
76 +}
77 +
78 +func reconcileDeviceIdentityAliases(
79 + devices map[string]Device,
80 + interfaces map[string]Interface,
81 + enrichments map[string]*enrichmentAccumulator,
82 +) identityAliasReconcileStats {
83 + stats := identityAliasReconcileStats{}
84 + if len(devices) == 0 || len(enrichments) == 0 {
85 + return stats
86 + }
87 +
88 + uniqueMACToDeviceID, ambiguousMACs := buildUniqueMACToDeviceIndex(devices, interfaces)
89 + if len(uniqueMACToDeviceID) == 0 {
90 + return stats
91 + }
92 +
93 + ipToMACs := make(map[string]map[string]struct{})
94 + enrichmentKeys := make([]string, 0, len(enrichments))
95 + for endpointID := range enrichments {
96 + enrichmentKeys = append(enrichmentKeys, endpointID)
97 + }
98 + sort.Strings(enrichmentKeys)
99 +
100 + for _, endpointID := range enrichmentKeys {
101 + acc := enrichments[endpointID]
102 + if acc == nil {
103 + continue
104 + }
105 + mac := normalizeMAC(acc.MAC)
106 + if mac == "" {
107 + continue
108 + }
109 + for _, ipKey := range sortedIPKeys(acc.IPs) {
110 + addr, ok := acc.IPs[ipKey]
111 + if !ok || !isUsableAliasIPAddress(addr) {
112 + continue
113 + }
114 + owners := ipToMACs[ipKey]
115 + if owners == nil {
116 + owners = make(map[string]struct{})
117 + ipToMACs[ipKey] = owners
118 + }
119 + owners[mac] = struct{}{}
120 + }
121 + }
122 +
123 + aliasIPsByDevice := make(map[string]map[string]netip.Addr)
124 + for _, endpointID := range enrichmentKeys {
125 + acc := enrichments[endpointID]
126 + if acc == nil {
127 + continue
128 + }
129 + mac := normalizeMAC(acc.MAC)
130 + if mac == "" {
131 + continue
132 + }
133 + if _, ambiguous := ambiguousMACs[mac]; ambiguous {
134 + stats.endpointsAmbiguousMAC++
135 + continue
136 + }
137 +
138 + deviceID := strings.TrimSpace(uniqueMACToDeviceID[mac])
139 + if deviceID == "" {
140 + continue
141 + }
142 + stats.endpointsMapped++
143 +
144 + if aliasIPsByDevice[deviceID] == nil {
145 + aliasIPsByDevice[deviceID] = make(map[string]netip.Addr)
146 + }
147 + for _, ipKey := range sortedIPKeys(acc.IPs) {
148 + addr, ok := acc.IPs[ipKey]
149 + if !ok || !isUsableAliasIPAddress(addr) {
150 + continue
151 + }
152 + if len(ipToMACs[ipKey]) > 1 {
153 + stats.ipsConflictSkipped++
154 + continue
155 + }
156 + aliasIPsByDevice[deviceID][addr.String()] = addr.Unmap()
157 + }
158 + }
159 +
160 + for deviceID, aliasIPs := range aliasIPsByDevice {
161 + device, ok := devices[deviceID]
162 + if !ok || len(aliasIPs) == 0 {
163 + continue
164 + }
165 +
166 + merged := make(map[string]netip.Addr, len(device.Addresses)+len(aliasIPs))
167 + for _, addr := range device.Addresses {
168 + if !isUsableAliasIPAddress(addr) {
169 + continue
170 + }
171 + normalized := addr.Unmap()
172 + merged[normalized.String()] = normalized
173 + }
174 + before := len(merged)
175 + maps.Copy(merged, aliasIPs)
176 + added := len(merged) - before
177 + if added <= 0 {
178 + continue
179 + }
180 + stats.ipsMerged += added
181 +
182 + keys := make([]string, 0, len(merged))
183 + for key := range merged {
184 + keys = append(keys, key)
185 + }
186 + sort.Strings(keys)
187 +
188 + addresses := make([]netip.Addr, 0, len(keys))
189 + for _, key := range keys {
190 + addresses = append(addresses, merged[key])
191 + }
192 + device.Addresses = addresses
193 + devices[deviceID] = device
194 + }
195 +
196 + return stats
197 +}
198 +
199 +func buildUniqueMACToDeviceIndex(
200 + devices map[string]Device,
201 + interfaces map[string]Interface,
202 +) (map[string]string, map[string]struct{}) {
203 + ownersByMAC := make(map[string]map[string]struct{})
204 + addOwner := func(mac, deviceID string) {
205 + mac = normalizeMAC(mac)
206 + deviceID = strings.TrimSpace(deviceID)
207 + if mac == "" || deviceID == "" {
208 + return
209 + }
210 + owners := ownersByMAC[mac]
211 + if owners == nil {
212 + owners = make(map[string]struct{})
213 + ownersByMAC[mac] = owners
214 + }
215 + owners[deviceID] = struct{}{}
216 + }
217 +
218 + for _, device := range devices {
219 + addOwner(primaryL2MACIdentity(device.ChassisID, ""), device.ID)
220 + }
221 + for _, iface := range interfaces {
222 + addOwner(iface.MAC, iface.DeviceID)
223 + }
224 +
225 + unique := make(map[string]string, len(ownersByMAC))
226 + ambiguous := make(map[string]struct{})
227 + for mac, owners := range ownersByMAC {
228 + if len(owners) == 1 {
229 + for deviceID := range owners {
230 + unique[mac] = deviceID
231 + }
232 + continue
233 + }
234 + ambiguous[mac] = struct{}{}
235 + }
236 + return unique, ambiguous
237 +}
238 +
239 +func isUsableAliasIPAddress(addr netip.Addr) bool {
240 + addr = addr.Unmap()
241 + if !addr.IsValid() {
242 + return false
243 + }
244 + return !addr.IsUnspecified()
245 +}
246 +
247 +func sortedIPKeys(in map[string]netip.Addr) []string {
248 + if len(in) == 0 {
249 + return nil
250 + }
251 + keys := make([]string, 0, len(in))
252 + for key := range in {
253 + keys = append(keys, key)
254 + }
255 + sort.Strings(keys)
256 + return keys
257 +}
src/go/pkg/topology/engine/l2_pipeline_fdb.go new
+142
@@ -0,0 +1,142 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +type fdbCandidate struct {
11 + mac string
12 + bridgePort string
13 + ifIndex int
14 + statusRaw string
15 + vlanID string
16 + vlanName string
17 +}
18 +
19 +func buildFDBCandidates(entries []FDBObservation, bridgePortToIfIndex map[string]int) []fdbCandidate {
20 + if len(entries) == 0 {
21 + return nil
22 + }
23 +
24 + sorted := sortedFDBEntries(entries)
25 + selfMACs := make(map[string]struct{}, len(sorted))
26 + for _, entry := range sorted {
27 + if canonicalFDBStatus(entry.Status) != fdbStatusSelf {
28 + continue
29 + }
30 + mac := normalizeMAC(entry.MAC)
31 + if mac == "" {
32 + continue
33 + }
34 + selfMACs[mac] = struct{}{}
35 + }
36 +
37 + candidatesByEndpoint := make(map[string]fdbCandidate, len(sorted))
38 + duplicates := make(map[string]struct{})
39 + for _, entry := range sorted {
40 + mac := normalizeMAC(entry.MAC)
41 + if mac == "" {
42 + continue
43 + }
44 + if _, isSelf := selfMACs[mac]; isSelf {
45 + continue
46 + }
47 + if canonicalFDBStatus(entry.Status) != fdbStatusLearned {
48 + continue
49 + }
50 +
51 + bridgePort := strings.TrimSpace(entry.BridgePort)
52 + ifIndex := entry.IfIndex
53 + if ifIndex <= 0 && bridgePort != "" {
54 + if mappedIfIndex, ok := bridgePortToIfIndex[bridgePort]; ok {
55 + ifIndex = mappedIfIndex
56 + }
57 + }
58 +
59 + candidate := fdbCandidate{
60 + mac: mac,
61 + bridgePort: bridgePort,
62 + ifIndex: ifIndex,
63 + statusRaw: strings.TrimSpace(entry.Status),
64 + vlanID: strings.TrimSpace(entry.VLANID),
65 + vlanName: strings.TrimSpace(entry.VLANName),
66 + }
67 + candidateKey := opaqueCompositeKey(mac)
68 + if candidate.vlanID != "" {
69 + candidateKey = opaqueCompositeKey(mac, "vlan:"+strings.ToLower(candidate.vlanID))
70 + }
71 + if _, duplicated := duplicates[candidateKey]; duplicated {
72 + continue
73 + }
74 +
75 + existing, exists := candidatesByEndpoint[candidateKey]
76 + if !exists {
77 + candidatesByEndpoint[candidateKey] = candidate
78 + continue
79 + }
80 +
81 + if sameFDBDestination(existing, candidate) {
82 + updated := existing
83 + if candidate.statusRaw != "" {
84 + updated.statusRaw = candidate.statusRaw
85 + }
86 + if updated.vlanName == "" && candidate.vlanName != "" {
87 + updated.vlanName = candidate.vlanName
88 + }
89 + candidatesByEndpoint[candidateKey] = updated
90 + continue
91 + }
92 +
93 + delete(candidatesByEndpoint, candidateKey)
94 + duplicates[candidateKey] = struct{}{}
95 + }
96 +
97 + out := make([]fdbCandidate, 0, len(candidatesByEndpoint))
98 + for _, candidate := range candidatesByEndpoint {
99 + out = append(out, candidate)
100 + }
101 + sort.Slice(out, func(i, j int) bool {
102 + if out[i].mac != out[j].mac {
103 + return out[i].mac < out[j].mac
104 + }
105 + if out[i].vlanID != out[j].vlanID {
106 + return out[i].vlanID < out[j].vlanID
107 + }
108 + if out[i].ifIndex != out[j].ifIndex {
109 + return out[i].ifIndex < out[j].ifIndex
110 + }
111 + return out[i].bridgePort < out[j].bridgePort
112 + })
113 + return out
114 +}
115 +
116 +func canonicalFDBStatus(status string) string {
117 + normalized := strings.ToLower(strings.TrimSpace(status))
118 + switch normalized {
119 + case "", "3", "learned", "dot1d_tp_fdb_status_learned", "dot1dtpfdbstatuslearned":
120 + return fdbStatusLearned
121 + case "4", "self", "dot1d_tp_fdb_status_self", "dot1dtpfdbstatusself":
122 + return fdbStatusSelf
123 + default:
124 + if strings.Contains(normalized, "learned") {
125 + return fdbStatusLearned
126 + }
127 + if strings.Contains(normalized, "self") {
128 + return fdbStatusSelf
129 + }
130 + return fdbStatusIgnored
131 + }
132 +}
133 +
134 +func sameFDBDestination(left, right fdbCandidate) bool {
135 + if strings.TrimSpace(left.vlanID) != strings.TrimSpace(right.vlanID) {
136 + return false
137 + }
138 + if left.ifIndex > 0 && right.ifIndex > 0 {
139 + return left.ifIndex == right.ifIndex
140 + }
141 + return left.bridgePort != "" && left.bridgePort == right.bridgePort
142 +}
src/go/pkg/topology/engine/l2_pipeline_fdb_test.go new
+56
@@ -0,0 +1,56 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestBuildFDBCandidates_KeepsDistinctVLANScopedCandidates(t *testing.T) {
12 + candidates := buildFDBCandidates([]FDBObservation{
13 + {
14 + MAC: "70:49:a2:65:72:cd",
15 + BridgePort: "7",
16 + Status: "learned",
17 + VLANID: "100",
18 + },
19 + {
20 + MAC: "70:49:a2:65:72:cd",
21 + BridgePort: "7",
22 + Status: "learned",
23 + VLANID: "100\x00vlan:200",
24 + },
25 + }, nil)
26 +
27 + require.Len(t, candidates, 2)
28 + require.Equal(t, "100", candidates[0].vlanID)
29 + require.Equal(t, "100\x00vlan:200", candidates[1].vlanID)
30 +}
31 +
32 +func TestBuildFDBCandidates_DoesNotReintroduceDuplicateEndpoint(t *testing.T) {
33 + candidates := buildFDBCandidates([]FDBObservation{
34 + {
35 + MAC: "70:49:a2:65:72:cd",
36 + BridgePort: "1",
37 + Status: "learned",
38 + },
39 + {
40 + MAC: "70:49:a2:65:72:cd",
41 + BridgePort: "2",
42 + Status: "learned",
43 + },
44 + {
45 + MAC: "70:49:a2:65:72:cd",
46 + BridgePort: "3",
47 + Status: "learned",
48 + },
49 + }, map[string]int{
50 + "1": 1,
51 + "2": 2,
52 + "3": 3,
53 + })
54 +
55 + require.Empty(t, candidates)
56 +}
src/go/pkg/topology/engine/l2_pipeline_keys.go new
+84
@@ -0,0 +1,84 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "fmt"
7 + "strconv"
8 + "strings"
9 +)
10 +
11 +// keySep separates delimiter-based identifiers that some topology paths still split later.
12 +// Opaque keys built from observation data should use opaqueCompositeKey() instead.
13 +const keySep = "\x00"
14 +
15 +func opaqueCompositeKey(parts ...string) string {
16 + if len(parts) == 0 {
17 + return ""
18 + }
19 +
20 + var b strings.Builder
21 + for _, part := range parts {
22 + b.WriteString(strconv.Itoa(len(part)))
23 + b.WriteByte(':')
24 + b.WriteString(part)
25 + }
26 + return b.String()
27 +}
28 +
29 +func topologyMatchCompositeKey(parts ...string) string {
30 + return opaqueCompositeKey(parts...)
31 +}
32 +
33 +func adjacencyKey(adj Adjacency) string {
34 + protocol := strings.ToLower(strings.TrimSpace(adj.Protocol))
35 + sourceID := strings.TrimSpace(adj.SourceID)
36 + sourcePort := strings.TrimSpace(adj.SourcePort)
37 + targetID := strings.TrimSpace(adj.TargetID)
38 + targetPort := strings.TrimSpace(adj.TargetPort)
39 +
40 + return opaqueCompositeKey(protocol, sourceID, sourcePort, targetID, targetPort)
41 +}
42 +
43 +func attachmentKey(attachment Attachment) string {
44 + deviceID := strings.TrimSpace(attachment.DeviceID)
45 + endpointID := strings.TrimSpace(attachment.EndpointID)
46 + method := strings.ToLower(strings.TrimSpace(attachment.Method))
47 + vlanID := ""
48 + if len(attachment.Labels) > 0 {
49 + vlanID = strings.TrimSpace(attachment.Labels["vlan_id"])
50 + if vlanID == "" {
51 + vlanID = strings.TrimSpace(attachment.Labels["vlan"])
52 + }
53 + }
54 + return opaqueCompositeKey(
55 + deviceID,
56 + strconv.Itoa(attachment.IfIndex),
57 + endpointID,
58 + method,
59 + strings.ToLower(vlanID),
60 + )
61 +}
62 +
63 +func ifaceKey(iface Interface) string {
64 + return opaqueCompositeKey(iface.DeviceID, strconv.Itoa(iface.IfIndex), iface.IfName)
65 +}
66 +
67 +func deviceIfIndexKey(deviceID string, ifIndex int) string {
68 + return opaqueCompositeKey(deviceID, strconv.Itoa(ifIndex))
69 +}
70 +
71 +func deriveBridgeDomainFromIfIndex(deviceID string, ifIndex int) string {
72 + return fmt.Sprintf("bridge-domain:%s:if:%d", deviceID, ifIndex)
73 +}
74 +
75 +func deriveBridgeDomainFromBridgePort(deviceID, bridgePort string) string {
76 + return fmt.Sprintf("bridge-domain:%s:bp:%s", deviceID, bridgePort)
77 +}
78 +
79 +func attachmentDomain(attachment Attachment) string {
80 + if len(attachment.Labels) == 0 {
81 + return ""
82 + }
83 + return strings.TrimSpace(attachment.Labels["bridge_domain"])
84 +}
src/go/pkg/topology/engine/l2_pipeline_keys_test.go new
+87
@@ -0,0 +1,87 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestAdjacencyKey_NormalizesProtocolAndEndpointWhitespace(t *testing.T) {
12 + raw := Adjacency{
13 + Protocol: " LLDP ",
14 + SourceID: " sw1 ",
15 + SourcePort: " Gi0/1 ",
16 + TargetID: " sw2 ",
17 + TargetPort: " Gi0/2 ",
18 + }
19 +
20 + normalized := Adjacency{
21 + Protocol: "lldp",
22 + SourceID: "sw1",
23 + SourcePort: "Gi0/1",
24 + TargetID: "sw2",
25 + TargetPort: "Gi0/2",
26 + }
27 +
28 + require.Equal(t, adjacencyKey(normalized), adjacencyKey(raw))
29 +}
30 +
31 +func TestAttachmentKey_NormalizesIDsAndMethod(t *testing.T) {
32 + raw := Attachment{
33 + DeviceID: " sw1 ",
34 + IfIndex: 10,
35 + EndpointID: " endpoint-1 ",
36 + Method: " FDB ",
37 + Labels: map[string]string{
38 + "vlan_id": " 100 ",
39 + },
40 + }
41 +
42 + normalized := Attachment{
43 + DeviceID: "sw1",
44 + IfIndex: 10,
45 + EndpointID: "endpoint-1",
46 + Method: "fdb",
47 + Labels: map[string]string{
48 + "vlan_id": "100",
49 + },
50 + }
51 +
52 + require.Equal(t, attachmentKey(normalized), attachmentKey(raw))
53 +}
54 +
55 +func TestAdjacencyKey_DistinguishesEmbeddedSeparators(t *testing.T) {
56 + first := Adjacency{
57 + Protocol: "lldp",
58 + SourceID: "node-a",
59 + SourcePort: "Gi0/1\x00Gi0/2",
60 + TargetID: "node-b",
61 + TargetPort: "Eth1",
62 + }
63 + second := Adjacency{
64 + Protocol: "lldp",
65 + SourceID: "node-a\x00Gi0/1",
66 + SourcePort: "Gi0/2",
67 + TargetID: "node-b",
68 + TargetPort: "Eth1",
69 + }
70 +
71 + require.NotEqual(t, adjacencyKey(first), adjacencyKey(second))
72 +}
73 +
74 +func TestIfaceKey_DistinguishesEmbeddedSeparators(t *testing.T) {
75 + first := Interface{
76 + DeviceID: "sw-a",
77 + IfIndex: 1,
78 + IfName: "2\x00uplink",
79 + }
80 + second := Interface{
81 + DeviceID: "sw-a\x001",
82 + IfIndex: 2,
83 + IfName: "uplink",
84 + }
85 +
86 + require.NotEqual(t, ifaceKey(first), ifaceKey(second))
87 +}
src/go/pkg/topology/engine/l2_pipeline_lldp_normalization.go new
+70
@@ -0,0 +1,70 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "encoding/hex"
7 + "strings"
8 +)
9 +
10 +func normalizeLLDPPortIDForMatch(portID, subtype string) string {
11 + portID = strings.TrimSpace(portID)
12 + if portID == "" {
13 + return ""
14 + }
15 +
16 + switch normalizeLLDPPortSubtypeForMatch(subtype) {
17 + case "mac":
18 + if mac := canonicalLLDPMACToken(portID); mac != "" {
19 + return mac
20 + }
21 + case "network":
22 + if ip := canonicalIP(portID); ip != "" {
23 + return ip
24 + }
25 + }
26 +
27 + return portID
28 +}
29 +
30 +func normalizeLLDPPortSubtypeForMatch(subtype string) string {
31 + switch strings.ToLower(strings.TrimSpace(subtype)) {
32 + case "3", "macaddress":
33 + return "mac"
34 + case "4", "networkaddress":
35 + return "network"
36 + default:
37 + return strings.ToLower(strings.TrimSpace(subtype))
38 + }
39 +}
40 +
41 +func normalizeLLDPChassisForMatch(v string) string {
42 + v = strings.TrimSpace(v)
43 + if v == "" {
44 + return ""
45 + }
46 + if ip := canonicalIP(v); ip != "" {
47 + return ip
48 + }
49 + if mac := canonicalLLDPMACToken(v); mac != "" {
50 + return mac
51 + }
52 + return v
53 +}
54 +
55 +func canonicalLLDPMACToken(v string) string {
56 + v = strings.TrimSpace(strings.ToLower(v))
57 + if v == "" {
58 + return ""
59 + }
60 +
61 + clean := strings.TrimPrefix(v, "0x")
62 + clean = strings.NewReplacer(":", "", "-", "", ".", "", " ", "").Replace(clean)
63 + if len(clean) != 12 {
64 + return ""
65 + }
66 + if _, err := hex.DecodeString(clean); err != nil {
67 + return ""
68 + }
69 + return clean
70 +}
src/go/pkg/topology/engine/l2_pipeline_match_cdp.go new
+202
@@ -0,0 +1,202 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 +)
9 +
10 +const cdpMatchPassDefault = "default"
11 +
12 +type cdpMatchLink struct {
13 + index int
14 +
15 + sourceDeviceID string
16 + sourceGlobalID string
17 +
18 + localInterfaceName string
19 +
20 + remoteDeviceID string
21 + remoteDevicePort string
22 + remoteHost string
23 + remoteAddressRaw string
24 +}
25 +
26 +type cdpMatchedPair struct {
27 + sourceIndex int
28 + targetIndex int
29 + pass string
30 +}
31 +
32 +func buildCDPMatchLinks(observations []L2Observation) []cdpMatchLink {
33 + links := make([]cdpMatchLink, 0)
34 + for _, obs := range observations {
35 + sourceID := strings.TrimSpace(obs.DeviceID)
36 + if sourceID == "" {
37 + continue
38 + }
39 +
40 + sourceGlobalID := strings.TrimSpace(obs.Hostname)
41 + if sourceGlobalID == "" {
42 + sourceGlobalID = sourceID
43 + }
44 +
45 + remotes := sortedCDPRemotes(obs.CDPRemotes)
46 + for _, remote := range remotes {
47 + localInterfaceName := strings.TrimSpace(remote.LocalIfName)
48 + if localInterfaceName == "" && remote.LocalIfIndex > 0 {
49 + localInterfaceName = strconv.Itoa(remote.LocalIfIndex)
50 + }
51 +
52 + remoteDeviceID := strings.TrimSpace(remote.DeviceID)
53 + remoteHost := strings.TrimSpace(remote.SysName)
54 + if remoteHost == "" {
55 + remoteHost = remoteDeviceID
56 + }
57 + if remoteDeviceID == "" {
58 + remoteDeviceID = remoteHost
59 + }
60 +
61 + links = append(links, cdpMatchLink{
62 + index: len(links),
63 + sourceDeviceID: sourceID,
64 + sourceGlobalID: sourceGlobalID,
65 + localInterfaceName: localInterfaceName,
66 + remoteDeviceID: remoteDeviceID,
67 + remoteDevicePort: strings.TrimSpace(remote.DevicePort),
68 + remoteHost: remoteHost,
69 + remoteAddressRaw: strings.TrimSpace(remote.Address),
70 + })
71 + }
72 + }
73 + return links
74 +}
75 +
76 +func buildCDPLookupMap(links []cdpMatchLink) map[string]int {
77 + lookup := make(map[string]int, len(links))
78 + for _, link := range links {
79 + key := topologyMatchCompositeKey(
80 + link.remoteDevicePort,
81 + link.localInterfaceName,
82 + link.sourceGlobalID,
83 + link.remoteDeviceID,
84 + )
85 + if _, ok := lookup[key]; ok {
86 + continue
87 + }
88 + lookup[key] = link.index
89 + }
90 + return lookup
91 +}
92 +
93 +func matchCDPLinksEnlinkdPassOrder(links []cdpMatchLink) []cdpMatchedPair {
94 + if len(links) == 0 {
95 + return nil
96 + }
97 +
98 + lookup := buildCDPLookupMap(links)
99 + parsed := make(map[int]struct{}, len(links))
100 + pairs := make([]cdpMatchedPair, 0, len(links)/2)
101 +
102 + for _, source := range links {
103 + if _, ok := parsed[source.index]; ok {
104 + continue
105 + }
106 +
107 + key := topologyMatchCompositeKey(
108 + source.localInterfaceName,
109 + source.remoteDevicePort,
110 + source.remoteDeviceID,
111 + source.sourceGlobalID,
112 + )
113 + targetIndex, ok := lookup[key]
114 + if !ok {
115 + continue
116 + }
117 +
118 + if source.index == targetIndex {
119 + continue
120 + }
121 + if _, targetParsed := parsed[targetIndex]; targetParsed {
122 + continue
123 + }
124 +
125 + parsed[source.index] = struct{}{}
126 + parsed[targetIndex] = struct{}{}
127 + pairs = append(pairs, cdpMatchedPair{
128 + sourceIndex: source.index,
129 + targetIndex: targetIndex,
130 + pass: cdpMatchPassDefault,
131 + })
132 + }
133 +
134 + return pairs
135 +}
136 +
137 +func buildCDPTargetOverrides(links []cdpMatchLink, pairs []cdpMatchedPair) map[int]string {
138 + if len(pairs) == 0 {
139 + return nil
140 + }
141 +
142 + indexToLink := make(map[int]cdpMatchLink, len(links))
143 + for _, link := range links {
144 + indexToLink[link.index] = link
145 + }
146 +
147 + overrides := make(map[int]string, len(pairs)*2)
148 + for _, pair := range pairs {
149 + source, sourceOK := indexToLink[pair.sourceIndex]
150 + target, targetOK := indexToLink[pair.targetIndex]
151 + if !sourceOK || !targetOK {
152 + continue
153 + }
154 +
155 + overrides[source.index] = target.sourceDeviceID
156 + overrides[target.index] = source.sourceDeviceID
157 + }
158 +
159 + return overrides
160 +}
161 +
162 +func buildCDPPairMetadata(links []cdpMatchLink, pairs []cdpMatchedPair) map[int]matchedPairMetadata {
163 + if len(pairs) == 0 {
164 + return nil
165 + }
166 +
167 + indexToLink := make(map[int]cdpMatchLink, len(links))
168 + for _, link := range links {
169 + indexToLink[link.index] = link
170 + }
171 +
172 + metadata := make(map[int]matchedPairMetadata, len(pairs)*2)
173 + for _, pair := range pairs {
174 + sourceLink, sourceOK := indexToLink[pair.sourceIndex]
175 + targetLink, targetOK := indexToLink[pair.targetIndex]
176 + if !sourceOK || !targetOK {
177 + continue
178 + }
179 +
180 + pairID := canonicalAdjacencyPairID(
181 + "cdp",
182 + sourceLink.sourceDeviceID,
183 + sourceLink.localInterfaceName,
184 + targetLink.sourceDeviceID,
185 + targetLink.localInterfaceName,
186 + )
187 + if pairID == "" {
188 + continue
189 + }
190 +
191 + metadata[sourceLink.index] = matchedPairMetadata{
192 + id: pairID,
193 + pass: pair.pass,
194 + }
195 + metadata[targetLink.index] = matchedPairMetadata{
196 + id: pairID,
197 + pass: pair.pass,
198 + }
199 + }
200 +
201 + return metadata
202 +}
src/go/pkg/topology/engine/l2_pipeline_match_lldp.go new
+445
@@ -0,0 +1,445 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "strings"
6 +
7 +const (
8 + lldpMatchPassDefault = "default"
9 + lldpMatchPassPortDesc = "port_description"
10 + lldpMatchPassSysName = "sysname"
11 + lldpMatchPassChassisPort = "chassis_port_id_subtype"
12 + lldpMatchPassChassisDescr = "chassis_port_descr"
13 + lldpMatchPassChassis = "chassis"
14 +)
15 +
16 +type lldpMatchLink struct {
17 + index int
18 +
19 + sourceDeviceID string
20 + localChassisID string
21 + localSysName string
22 + localMatchID string
23 +
24 + localPortID string
25 + localPortIDSubtype string
26 + localPortDescr string
27 +
28 + remoteChassisID string
29 + remoteSysName string
30 + remoteMatchID string
31 + remotePortID string
32 + remotePortIDSubtype string
33 + remotePortDescr string
34 +
35 + sourcePort string
36 + targetPort string
37 + remoteManagement string
38 + remoteFallbackID string
39 +}
40 +
41 +type lldpMatchedPair struct {
42 + sourceIndex int
43 + targetIndex int
44 + pass string
45 +}
46 +
47 +func buildLLDPMatchLinks(observations []L2Observation) []lldpMatchLink {
48 + links := make([]lldpMatchLink, 0)
49 + for _, obs := range observations {
50 + sourceID := strings.TrimSpace(obs.DeviceID)
51 + if sourceID == "" {
52 + continue
53 + }
54 + localChassisID := strings.TrimSpace(obs.ChassisID)
55 + localSysName := strings.TrimSpace(obs.Hostname)
56 +
57 + remotes := sortedLLDPRemotes(obs.LLDPRemotes)
58 + for _, remote := range remotes {
59 + localPortID := strings.TrimSpace(remote.LocalPortID)
60 + localPortDescr := strings.TrimSpace(remote.LocalPortDesc)
61 + sourcePort := localPortID
62 + if sourcePort == "" {
63 + sourcePort = localPortDescr
64 + }
65 + if sourcePort == "" {
66 + sourcePort = strings.TrimSpace(remote.LocalPortNum)
67 + }
68 +
69 + targetPort := strings.TrimSpace(remote.PortID)
70 + if targetPort == "" {
71 + targetPort = strings.TrimSpace(remote.PortDesc)
72 + }
73 +
74 + links = append(links, lldpMatchLink{
75 + index: len(links),
76 +
77 + sourceDeviceID: sourceID,
78 + localChassisID: localChassisID,
79 + localSysName: localSysName,
80 + localMatchID: sourceID,
81 +
82 + localPortID: localPortID,
83 + localPortIDSubtype: strings.TrimSpace(remote.LocalPortIDSubtype),
84 + localPortDescr: localPortDescr,
85 +
86 + remoteChassisID: strings.TrimSpace(remote.ChassisID),
87 + remoteSysName: strings.TrimSpace(remote.SysName),
88 + remoteMatchID: "",
89 + remotePortID: strings.TrimSpace(remote.PortID),
90 + remotePortIDSubtype: strings.TrimSpace(remote.PortIDSubtype),
91 + remotePortDescr: strings.TrimSpace(remote.PortDesc),
92 +
93 + sourcePort: sourcePort,
94 + targetPort: targetPort,
95 + remoteManagement: strings.TrimSpace(remote.ManagementIP),
96 + remoteFallbackID: strings.TrimSpace(remote.SysName),
97 + })
98 + }
99 + }
100 + return links
101 +}
102 +
103 +func annotateLLDPLinkMatchIdentities(
104 + links []lldpMatchLink,
105 + hostToID map[string]string,
106 + chassisToID map[string]string,
107 + ipToID map[string]string,
108 +) {
109 + for i := range links {
110 + link := &links[i]
111 + if strings.TrimSpace(link.localMatchID) == "" {
112 + link.localMatchID = strings.TrimSpace(link.sourceDeviceID)
113 + }
114 + if strings.TrimSpace(link.remoteMatchID) != "" {
115 + continue
116 + }
117 + link.remoteMatchID = resolveKnownDeviceID(hostToID, chassisToID, ipToID, link.remoteSysName, link.remoteChassisID, link.remoteManagement)
118 + }
119 +}
120 +
121 +func resolveKnownDeviceID(
122 + hostToID map[string]string,
123 + chassisToID map[string]string,
124 + ipToID map[string]string,
125 + hostname, chassisID, managementIP string,
126 +) string {
127 + if id := hostToID[canonicalHost(hostname)]; strings.TrimSpace(id) != "" {
128 + return strings.TrimSpace(id)
129 + }
130 + if id := chassisToID[canonicalToken(chassisID)]; strings.TrimSpace(id) != "" {
131 + return strings.TrimSpace(id)
132 + }
133 + if id := ipToID[canonicalIP(managementIP)]; strings.TrimSpace(id) != "" {
134 + return strings.TrimSpace(id)
135 + }
136 + return ""
137 +}
138 +
139 +func lldpIdentityTokenForMatch(matchID, chassisID string) string {
140 + matchID = strings.TrimSpace(matchID)
141 + if matchID != "" {
142 + return "device:" + matchID
143 + }
144 + return normalizeLLDPChassisForMatch(chassisID)
145 +}
146 +
147 +func buildLLDPLookupMap(links []lldpMatchLink) map[string]int {
148 + lookup := make(map[string]int, len(links)*6)
149 + for _, link := range links {
150 + defaultKey := lldpCompositeKey(
151 + lldpIdentityTokenForMatch(link.remoteMatchID, link.remoteChassisID),
152 + lldpIdentityTokenForMatch(link.localMatchID, link.localChassisID),
153 + normalizeLLDPPortIDForMatch(link.localPortID, link.localPortIDSubtype),
154 + normalizeLLDPPortSubtypeForMatch(link.localPortIDSubtype),
155 + normalizeLLDPPortIDForMatch(link.remotePortID, link.remotePortIDSubtype),
156 + normalizeLLDPPortSubtypeForMatch(link.remotePortIDSubtype),
157 + )
158 + lookup[defaultKey] = link.index
159 +
160 + descrKey := lldpCompositeKey(
161 + lldpIdentityTokenForMatch(link.remoteMatchID, link.remoteChassisID),
162 + lldpIdentityTokenForMatch(link.localMatchID, link.localChassisID),
163 + link.localPortDescr,
164 + link.remotePortDescr,
165 + )
166 + lookup[descrKey] = link.index
167 +
168 + sysNameKey := lldpCompositeKey(
169 + link.remoteSysName,
170 + link.localSysName,
171 + normalizeLLDPPortIDForMatch(link.localPortID, link.localPortIDSubtype),
172 + normalizeLLDPPortSubtypeForMatch(link.localPortIDSubtype),
173 + normalizeLLDPPortIDForMatch(link.remotePortID, link.remotePortIDSubtype),
174 + normalizeLLDPPortSubtypeForMatch(link.remotePortIDSubtype),
175 + )
176 + lookup[sysNameKey] = link.index
177 +
178 + elementaryAKey := lldpCompositeKey(
179 + lldpIdentityTokenForMatch(link.remoteMatchID, link.remoteChassisID),
180 + lldpIdentityTokenForMatch(link.localMatchID, link.localChassisID),
181 + normalizeLLDPPortIDForMatch(link.remotePortID, link.remotePortIDSubtype),
182 + normalizeLLDPPortSubtypeForMatch(link.remotePortIDSubtype),
183 + )
184 + lookup[elementaryAKey] = link.index
185 +
186 + elementaryBKey := lldpCompositeKey(
187 + lldpIdentityTokenForMatch(link.remoteMatchID, link.remoteChassisID),
188 + lldpIdentityTokenForMatch(link.localMatchID, link.localChassisID),
189 + link.remotePortDescr,
190 + )
191 + lookup[elementaryBKey] = link.index
192 +
193 + elementaryCKey := lldpCompositeKey(
194 + lldpIdentityTokenForMatch(link.remoteMatchID, link.remoteChassisID),
195 + lldpIdentityTokenForMatch(link.localMatchID, link.localChassisID),
196 + )
197 + lookup[elementaryCKey] = link.index
198 + }
199 + return lookup
200 +}
201 +
202 +func lldpCompositeKey(parts ...string) string {
203 + return topologyMatchCompositeKey(parts...)
204 +}
205 +
206 +func matchLLDPLinksEnlinkdPassOrder(links []lldpMatchLink) []lldpMatchedPair {
207 + if len(links) == 0 {
208 + return nil
209 + }
210 +
211 + lookup := buildLLDPLookupMap(links)
212 + parsed := make(map[int]struct{}, len(links))
213 + pairs := make([]lldpMatchedPair, 0, len(links)/2)
214 +
215 + addPair := func(sourceIndex, targetIndex int, pass string) {
216 + parsed[sourceIndex] = struct{}{}
217 + parsed[targetIndex] = struct{}{}
218 + pairs = append(pairs, lldpMatchedPair{
219 + sourceIndex: sourceIndex,
220 + targetIndex: targetIndex,
221 + pass: pass,
222 + })
223 + }
224 +
225 + for _, source := range links {
226 + if _, ok := parsed[source.index]; ok {
227 + continue
228 + }
229 + if lldpIdentityTokenForMatch(source.localMatchID, source.localChassisID) == lldpIdentityTokenForMatch(source.remoteMatchID, source.remoteChassisID) ||
230 + (source.localSysName != "" && source.localSysName == source.remoteSysName) {
231 + parsed[source.index] = struct{}{}
232 + continue
233 + }
234 +
235 + key := lldpCompositeKey(
236 + lldpIdentityTokenForMatch(source.localMatchID, source.localChassisID),
237 + lldpIdentityTokenForMatch(source.remoteMatchID, source.remoteChassisID),
238 + normalizeLLDPPortIDForMatch(source.remotePortID, source.remotePortIDSubtype),
239 + normalizeLLDPPortSubtypeForMatch(source.remotePortIDSubtype),
240 + normalizeLLDPPortIDForMatch(source.localPortID, source.localPortIDSubtype),
241 + normalizeLLDPPortSubtypeForMatch(source.localPortIDSubtype),
242 + )
243 + targetIndex, ok := lookup[key]
244 + if !ok {
245 + continue
246 + }
247 + if source.index == targetIndex {
248 + continue
249 + }
250 + if _, targetParsed := parsed[targetIndex]; targetParsed {
251 + continue
252 + }
253 + addPair(source.index, targetIndex, lldpMatchPassDefault)
254 + }
255 +
256 + for _, source := range links {
257 + if _, ok := parsed[source.index]; ok {
258 + continue
259 + }
260 + if strings.TrimSpace(source.remotePortDescr) == "" || strings.TrimSpace(source.localPortDescr) == "" {
261 + continue
262 + }
263 + key := lldpCompositeKey(
264 + lldpIdentityTokenForMatch(source.localMatchID, source.localChassisID),
265 + lldpIdentityTokenForMatch(source.remoteMatchID, source.remoteChassisID),
266 + source.remotePortDescr,
267 + source.localPortDescr,
268 + )
269 + targetIndex, ok := lookup[key]
270 + if !ok {
271 + continue
272 + }
273 + if source.index == targetIndex {
274 + continue
275 + }
276 + if _, targetParsed := parsed[targetIndex]; targetParsed {
277 + continue
278 + }
279 + addPair(source.index, targetIndex, lldpMatchPassPortDesc)
280 + }
281 +
282 + for _, source := range links {
283 + if _, ok := parsed[source.index]; ok {
284 + continue
285 + }
286 + key := lldpCompositeKey(
287 + source.localSysName,
288 + source.remoteSysName,
289 + normalizeLLDPPortIDForMatch(source.remotePortID, source.remotePortIDSubtype),
290 + normalizeLLDPPortSubtypeForMatch(source.remotePortIDSubtype),
291 + normalizeLLDPPortIDForMatch(source.localPortID, source.localPortIDSubtype),
292 + normalizeLLDPPortSubtypeForMatch(source.localPortIDSubtype),
293 + )
294 + targetIndex, ok := lookup[key]
295 + if !ok {
296 + continue
297 + }
298 + if source.index == targetIndex {
299 + continue
300 + }
301 + if _, targetParsed := parsed[targetIndex]; targetParsed {
302 + continue
303 + }
304 + addPair(source.index, targetIndex, lldpMatchPassSysName)
305 + }
306 +
307 + for _, source := range links {
308 + if _, ok := parsed[source.index]; ok {
309 + continue
310 + }
311 + key := lldpCompositeKey(
312 + lldpIdentityTokenForMatch(source.localMatchID, source.localChassisID),
313 + lldpIdentityTokenForMatch(source.remoteMatchID, source.remoteChassisID),
314 + normalizeLLDPPortIDForMatch(source.localPortID, source.localPortIDSubtype),
315 + normalizeLLDPPortSubtypeForMatch(source.localPortIDSubtype),
316 + )
317 + targetIndex, ok := lookup[key]
318 + if !ok {
319 + continue
320 + }
321 + if source.index == targetIndex {
322 + continue
323 + }
324 + if _, targetParsed := parsed[targetIndex]; targetParsed {
325 + continue
326 + }
327 + addPair(source.index, targetIndex, lldpMatchPassChassisPort)
328 + }
329 +
330 + for _, source := range links {
331 + if _, ok := parsed[source.index]; ok {
332 + continue
333 + }
334 + key := lldpCompositeKey(
335 + lldpIdentityTokenForMatch(source.localMatchID, source.localChassisID),
336 + lldpIdentityTokenForMatch(source.remoteMatchID, source.remoteChassisID),
337 + source.localPortDescr,
338 + )
339 + targetIndex, ok := lookup[key]
340 + if !ok {
341 + continue
342 + }
343 + if source.index == targetIndex {
344 + continue
345 + }
346 + if _, targetParsed := parsed[targetIndex]; targetParsed {
347 + continue
348 + }
349 + addPair(source.index, targetIndex, lldpMatchPassChassisDescr)
350 + }
351 +
352 + for _, source := range links {
353 + if _, ok := parsed[source.index]; ok {
354 + continue
355 + }
356 + key := lldpCompositeKey(
357 + lldpIdentityTokenForMatch(source.localMatchID, source.localChassisID),
358 + lldpIdentityTokenForMatch(source.remoteMatchID, source.remoteChassisID),
359 + )
360 + targetIndex, ok := lookup[key]
361 + if !ok {
362 + continue
363 + }
364 + if source.index == targetIndex {
365 + continue
366 + }
367 + if _, targetParsed := parsed[targetIndex]; targetParsed {
368 + continue
369 + }
370 + addPair(source.index, targetIndex, lldpMatchPassChassis)
371 + }
372 +
373 + return pairs
374 +}
375 +
376 +func buildLLDPTargetOverrides(links []lldpMatchLink, pairs []lldpMatchedPair) map[int]string {
377 + if len(pairs) == 0 {
378 + return nil
379 + }
380 +
381 + indexToLink := make(map[int]lldpMatchLink, len(links))
382 + for _, link := range links {
383 + indexToLink[link.index] = link
384 + }
385 +
386 + overrides := make(map[int]string, len(pairs)*2)
387 + for _, pair := range pairs {
388 + source, sourceOK := indexToLink[pair.sourceIndex]
389 + target, targetOK := indexToLink[pair.targetIndex]
390 + if !sourceOK || !targetOK {
391 + continue
392 + }
393 +
394 + if _, exists := overrides[source.index]; !exists {
395 + overrides[source.index] = target.sourceDeviceID
396 + }
397 + if _, exists := overrides[target.index]; !exists {
398 + overrides[target.index] = source.sourceDeviceID
399 + }
400 + }
401 +
402 + return overrides
403 +}
404 +
405 +func buildLLDPPairMetadata(links []lldpMatchLink, pairs []lldpMatchedPair) map[int]matchedPairMetadata {
406 + if len(pairs) == 0 {
407 + return nil
408 + }
409 +
410 + indexToLink := make(map[int]lldpMatchLink, len(links))
411 + for _, link := range links {
412 + indexToLink[link.index] = link
413 + }
414 +
415 + metadata := make(map[int]matchedPairMetadata, len(pairs)*2)
416 + for _, pair := range pairs {
417 + sourceLink, sourceOK := indexToLink[pair.sourceIndex]
418 + targetLink, targetOK := indexToLink[pair.targetIndex]
419 + if !sourceOK || !targetOK {
420 + continue
421 + }
422 +
423 + pairID := canonicalAdjacencyPairID(
424 + "lldp",
425 + sourceLink.sourceDeviceID,
426 + sourceLink.sourcePort,
427 + targetLink.sourceDeviceID,
428 + targetLink.sourcePort,
429 + )
430 + if pairID == "" {
431 + continue
432 + }
433 +
434 + metadata[sourceLink.index] = matchedPairMetadata{
435 + id: pairID,
436 + pass: pair.pass,
437 + }
438 + metadata[targetLink.index] = matchedPairMetadata{
439 + id: pairID,
440 + pass: pair.pass,
441 + }
442 + }
443 +
444 + return metadata
445 +}
src/go/pkg/topology/engine/l2_pipeline_normalization.go new
+63
@@ -0,0 +1,63 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "strings"
6 +
7 +func canonicalARPProtocol(protocol string) string {
8 + protocol = strings.TrimSpace(strings.ToLower(protocol))
9 + switch protocol {
10 + case "", "arp":
11 + return "arp"
12 + case "nd":
13 + return "nd"
14 + default:
15 + return "arp"
16 + }
17 +}
18 +
19 +func canonicalAddrType(addrType, ip string) string {
20 + addrType = strings.TrimSpace(strings.ToLower(addrType))
21 + if ipAddr := parseAddr(ip); ipAddr.IsValid() {
22 + if ipAddr.Is4() {
23 + return "ipv4"
24 + }
25 + return "ipv6"
26 + }
27 + if addrType == "" {
28 + return ""
29 + }
30 + return addrType
31 +}
32 +
33 +func deriveRemoteDeviceID(hostname, chassisID, mgmtIP, fallback string) string {
34 + if host := canonicalHost(hostname); host != "" {
35 + return host
36 + }
37 + if ch := canonicalToken(chassisID); ch != "" {
38 + return "chassis-" + ch
39 + }
40 + if ip := canonicalIP(mgmtIP); ip != "" {
41 + return "ip-" + strings.ReplaceAll(ip, ":", "-")
42 + }
43 + if fb := canonicalHost(fallback); fb != "" {
44 + return fb
45 + }
46 + return "discovered-unknown"
47 +}
48 +
49 +func canonicalHost(v string) string {
50 + v = strings.TrimSpace(strings.ToLower(v))
51 + v = strings.TrimSuffix(v, ".")
52 + return v
53 +}
54 +
55 +func canonicalToken(v string) string {
56 + v = strings.TrimSpace(strings.ToLower(v))
57 + v = strings.TrimPrefix(v, "0x")
58 + v = strings.ReplaceAll(v, ":", "")
59 + v = strings.ReplaceAll(v, "-", "")
60 + v = strings.ReplaceAll(v, ".", "")
61 + v = strings.ReplaceAll(v, " ", "")
62 + return v
63 +}
src/go/pkg/topology/engine/l2_pipeline_normalization_test.go new
+68
@@ -0,0 +1,68 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestCSVToTopologySet_NormalizesAndDeduplicates(t *testing.T) {
12 + set := csvToTopologySet(" LLDP, cdp , , lldp, STP ")
13 + require.Equal(t, map[string]struct{}{
14 + "lldp": {},
15 + "cdp": {},
16 + "stp": {},
17 + }, set)
18 +}
19 +
20 +func TestCanonicalAddrType_PrefersIPFamilyAndFallsBack(t *testing.T) {
21 + require.Equal(t, "ipv4", canonicalAddrType("", "10.0.0.1"))
22 + require.Equal(t, "ipv6", canonicalAddrType("ipv4", "2001:db8::1"))
23 + require.Equal(t, "other", canonicalAddrType(" Other ", "not-an-ip"))
24 + require.Equal(t, "", canonicalAddrType("", "not-an-ip"))
25 +}
26 +
27 +func TestCanonicalARPProtocol_DefaultsToARP(t *testing.T) {
28 + require.Equal(t, "arp", canonicalARPProtocol(""))
29 + require.Equal(t, "arp", canonicalARPProtocol(" ARP "))
30 + require.Equal(t, "nd", canonicalARPProtocol(" ND "))
31 + require.Equal(t, "arp", canonicalARPProtocol("bogus"))
32 +}
33 +
34 +func TestDeriveRemoteDeviceID_PreferenceOrder(t *testing.T) {
35 + require.Equal(t, "switch-a", deriveRemoteDeviceID("Switch-A.", "00:11:22:33:44:55", "10.0.0.1", "fallback"))
36 + require.Equal(t, "chassis-001122334455", deriveRemoteDeviceID("", "00:11:22:33:44:55", "10.0.0.1", "fallback"))
37 + require.Equal(t, "ip-2001-db8--1", deriveRemoteDeviceID("", "", "2001:db8::1", "fallback"))
38 + require.Equal(t, "fallback-host", deriveRemoteDeviceID("", "", "", "Fallback-Host."))
39 + require.Equal(t, "discovered-unknown", deriveRemoteDeviceID("", "", "", ""))
40 +}
41 +
42 +func TestCanonicalToken_Strips0xPrefix(t *testing.T) {
43 + require.Equal(t, "001122334455", canonicalToken("0x00:11:22:33:44:55"))
44 + require.Equal(t, "001122334455", canonicalToken("00:11:22:33:44:55"))
45 + require.Equal(t, deriveRemoteDeviceID("", "0x00:11:22:33:44:55", "", ""), deriveRemoteDeviceID("", "00:11:22:33:44:55", "", ""))
46 +}
47 +
48 +func TestNormalizeLLDPPortHelpers_NormalizeBySubtype(t *testing.T) {
49 + require.Equal(t, "001122334455", normalizeLLDPPortIDForMatch("00:11:22:33:44:55", "macAddress"))
50 + require.Equal(t, "10.0.0.1", normalizeLLDPPortIDForMatch("0a000001", "networkAddress"))
51 + require.Equal(t, "Gi0/1", normalizeLLDPPortIDForMatch(" Gi0/1 ", "interfaceName"))
52 + require.Equal(t, "mac", normalizeLLDPPortSubtypeForMatch("3"))
53 + require.Equal(t, "network", normalizeLLDPPortSubtypeForMatch("networkAddress"))
54 + require.Equal(t, "local", normalizeLLDPPortSubtypeForMatch(" local "))
55 +}
56 +
57 +func TestNormalizeLLDPChassisAndMACToken(t *testing.T) {
58 + require.Equal(t, "10.0.0.1", normalizeLLDPChassisForMatch("0a000001"))
59 + require.Equal(t, "001122334455", normalizeLLDPChassisForMatch("00:11:22:33:44:55"))
60 + require.Equal(t, "switch-a", normalizeLLDPChassisForMatch("switch-a"))
61 + require.Equal(t, "001122334455", canonicalLLDPMACToken("0x001122334455"))
62 + require.Equal(t, "", canonicalLLDPMACToken("001122"))
63 +}
64 +
65 +func TestCanonicalBridgeAddr_RejectsAllZeroMAC(t *testing.T) {
66 + require.Equal(t, "00:11:22:33:44:55", canonicalBridgeAddr("00:00:00:00:00:00", "00:11:22:33:44:55"))
67 + require.Equal(t, "", canonicalBridgeAddr("00:00:00:00:00:00", "00:00:00:00:00:00"))
68 +}
src/go/pkg/topology/engine/l2_pipeline_pairing.go new
+74
@@ -0,0 +1,74 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "strings"
6 +
7 +const (
8 + adjacencyLabelPairID = "pair_id"
9 + adjacencyLabelPairPass = "pair_pass"
10 +)
11 +
12 +type matchedPairMetadata struct {
13 + id string
14 + pass string
15 +}
16 +
17 +func canonicalAdjacencyPairID(protocol, leftDeviceID, leftPort, rightDeviceID, rightPort string) string {
18 + protocol = strings.ToLower(strings.TrimSpace(protocol))
19 + leftKey := topologyMatchCompositeKey(strings.TrimSpace(leftDeviceID), strings.TrimSpace(leftPort))
20 + rightKey := topologyMatchCompositeKey(strings.TrimSpace(rightDeviceID), strings.TrimSpace(rightPort))
21 + if protocol == "" || leftKey == "" || rightKey == "" {
22 + return ""
23 + }
24 + if rightKey < leftKey {
25 + leftKey, rightKey = rightKey, leftKey
26 + }
27 + return protocol + ":" + leftKey + "<->" + rightKey
28 +}
29 +
30 +func applyAdjacencyPairMetadata(adj *Adjacency, metadata matchedPairMetadata) {
31 + if adj == nil || metadata.id == "" {
32 + return
33 + }
34 + if adj.Labels == nil {
35 + adj.Labels = make(map[string]string)
36 + }
37 + adj.Labels[adjacencyLabelPairID] = metadata.id
38 + if metadata.pass != "" {
39 + adj.Labels[adjacencyLabelPairPass] = metadata.pass
40 + }
41 +}
42 +
43 +func addAdjacency(adjacencies map[string]Adjacency, adj Adjacency) bool {
44 + sourceID := strings.TrimSpace(adj.SourceID)
45 + targetID := strings.TrimSpace(adj.TargetID)
46 + if sourceID == "" || targetID == "" {
47 + return false
48 + }
49 + if sourceID == targetID {
50 + sourcePort := strings.TrimSpace(adj.SourcePort)
51 + targetPort := strings.TrimSpace(adj.TargetPort)
52 + if sourcePort == "" || targetPort == "" || sourcePort == targetPort {
53 + return false
54 + }
55 + }
56 + key := adjacencyKey(adj)
57 + if _, ok := adjacencies[key]; ok {
58 + return false
59 + }
60 + adjacencies[key] = adj
61 + return true
62 +}
63 +
64 +func addAttachment(attachments map[string]Attachment, attachment Attachment) bool {
65 + if strings.TrimSpace(attachment.DeviceID) == "" || strings.TrimSpace(attachment.EndpointID) == "" {
66 + return false
67 + }
68 + key := attachmentKey(attachment)
69 + if _, ok := attachments[key]; ok {
70 + return false
71 + }
72 + attachments[key] = attachment
73 + return true
74 +}
src/go/pkg/topology/engine/l2_pipeline_registration.go new
+222
@@ -0,0 +1,222 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "fmt"
7 + "net/netip"
8 + "strconv"
9 + "strings"
10 +)
11 +
12 +func (s *l2BuildState) registerObservations(observations []L2Observation) error {
13 + for _, obs := range observations {
14 + if err := s.registerObservation(obs); err != nil {
15 + return err
16 + }
17 + }
18 + return nil
19 +}
20 +
21 +func (s *l2BuildState) registerObservation(obs L2Observation) error {
22 + deviceID := strings.TrimSpace(obs.DeviceID)
23 + if deviceID == "" {
24 + return fmt.Errorf("observation with empty device id")
25 + }
26 +
27 + device := Device{
28 + ID: deviceID,
29 + Hostname: strings.TrimSpace(obs.Hostname),
30 + SysObject: strings.TrimSpace(obs.SysObjectID),
31 + ChassisID: strings.TrimSpace(obs.ChassisID),
32 + }
33 + if primaryMAC := primaryL2MACIdentity(obs.ChassisID, obs.BaseBridgeAddress); primaryMAC != "" {
34 + device.ChassisID = primaryMAC
35 + }
36 + if !obs.Inferred {
37 + s.managedObservationByDeviceID[deviceID] = true
38 + }
39 + if device.Hostname == "" {
40 + device.Hostname = device.ID
41 + }
42 + if addr := parseAddr(obs.ManagementIP); addr.IsValid() {
43 + device.Addresses = []netip.Addr{addr}
44 + }
45 + if len(device.Labels) == 0 {
46 + device.Labels = make(map[string]string)
47 + }
48 + observedProtocols := observationProtocolsUsed(obs)
49 + if existing, ok := s.devices[device.ID]; ok {
50 + device = mergeObservedDevice(existing, device)
51 + if device.Labels == nil {
52 + device.Labels = make(map[string]string)
53 + }
54 + for protocol := range csvToTopologySet(existing.Labels["protocols_observed"]) {
55 + observedProtocols[protocol] = struct{}{}
56 + }
57 + }
58 + if len(observedProtocols) > 0 {
59 + device.Labels["protocols_observed"] = setToCSV(observedProtocols)
60 + }
61 + s.devices[device.ID] = device
62 +
63 + if host := canonicalHost(device.Hostname); host != "" {
64 + s.hostToID[host] = device.ID
65 + }
66 + if ip := canonicalIP(obs.ManagementIP); ip != "" {
67 + s.ipToID[ip] = device.ID
68 + }
69 + if mac := primaryL2MACIdentity(device.ChassisID, ""); mac != "" {
70 + if _, exists := s.macToID[mac]; !exists {
71 + s.macToID[mac] = device.ID
72 + }
73 + s.chassisToID[canonicalToken(mac)] = device.ID
74 + } else if chassis := canonicalToken(device.ChassisID); chassis != "" {
75 + s.chassisToID[chassis] = device.ID
76 + }
77 + if bridgeAddr := canonicalBridgeAddr(obs.BaseBridgeAddress, device.ChassisID); bridgeAddr != "" {
78 + if _, exists := s.bridgeAddrToID[bridgeAddr]; !exists {
79 + s.bridgeAddrToID[bridgeAddr] = device.ID
80 + }
81 + }
82 +
83 + for _, iface := range obs.Interfaces {
84 + if iface.IfIndex <= 0 {
85 + continue
86 + }
87 + ifName := strings.TrimSpace(iface.IfName)
88 + ifDescr := strings.TrimSpace(iface.IfDescr)
89 + if ifName == "" {
90 + ifName = ifDescr
91 + }
92 + if ifDescr == "" {
93 + ifDescr = ifName
94 + }
95 + if ifName == "" {
96 + continue
97 + }
98 + engIface := Interface{
99 + DeviceID: device.ID,
100 + IfIndex: iface.IfIndex,
101 + IfName: ifName,
102 + IfDescr: ifDescr,
103 + MAC: normalizeMAC(iface.MAC),
104 + }
105 + if ifType := strings.TrimSpace(iface.InterfaceType); ifType != "" {
106 + if engIface.Labels == nil {
107 + engIface.Labels = make(map[string]string)
108 + }
109 + engIface.Labels["if_type"] = ifType
110 + }
111 + if admin := strings.TrimSpace(iface.AdminStatus); admin != "" {
112 + if engIface.Labels == nil {
113 + engIface.Labels = make(map[string]string)
114 + }
115 + engIface.Labels["admin_status"] = admin
116 + }
117 + if oper := strings.TrimSpace(iface.OperStatus); oper != "" {
118 + if engIface.Labels == nil {
119 + engIface.Labels = make(map[string]string)
120 + }
121 + engIface.Labels["oper_status"] = oper
122 + }
123 + if ifAlias := strings.TrimSpace(iface.IfAlias); ifAlias != "" {
124 + if engIface.Labels == nil {
125 + engIface.Labels = make(map[string]string)
126 + }
127 + engIface.Labels["if_alias"] = ifAlias
128 + }
129 + if iface.SpeedBps > 0 {
130 + if engIface.Labels == nil {
131 + engIface.Labels = make(map[string]string)
132 + }
133 + engIface.Labels["speed_bps"] = strconv.FormatInt(iface.SpeedBps, 10)
134 + }
135 + if iface.LastChange > 0 {
136 + if engIface.Labels == nil {
137 + engIface.Labels = make(map[string]string)
138 + }
139 + engIface.Labels["last_change"] = strconv.FormatInt(iface.LastChange, 10)
140 + }
141 + if duplex := strings.TrimSpace(iface.Duplex); duplex != "" {
142 + if engIface.Labels == nil {
143 + engIface.Labels = make(map[string]string)
144 + }
145 + engIface.Labels["duplex"] = duplex
146 + }
147 + if engIface.MAC != "" {
148 + if engIface.Labels == nil {
149 + engIface.Labels = make(map[string]string)
150 + }
151 + engIface.Labels["mac"] = engIface.MAC
152 + }
153 + s.interfaces[ifaceKey(engIface)] = engIface
154 + s.ifNameByDeviceIfIndex[deviceIfIndexKey(device.ID, iface.IfIndex)] = ifName
155 + }
156 +
157 + return nil
158 +}
159 +
160 +func mergeObservedDevice(existing, incoming Device) Device {
161 + out := existing
162 + if strings.TrimSpace(out.ID) == "" {
163 + out.ID = incoming.ID
164 + }
165 + if strings.TrimSpace(incoming.Hostname) != "" && (strings.TrimSpace(out.Hostname) == "" || out.Hostname == out.ID) {
166 + out.Hostname = incoming.Hostname
167 + }
168 + if strings.TrimSpace(out.SysObject) == "" {
169 + out.SysObject = incoming.SysObject
170 + }
171 + if strings.TrimSpace(out.ChassisID) == "" {
172 + out.ChassisID = incoming.ChassisID
173 + }
174 + out.Addresses = mergeObservedDeviceAddresses(existing.Addresses, incoming.Addresses)
175 + out.Labels = mergeObservedDeviceLabels(existing.Labels, incoming.Labels)
176 + if strings.TrimSpace(out.Hostname) == "" {
177 + out.Hostname = out.ID
178 + }
179 + return out
180 +}
181 +
182 +func mergeObservedDeviceAddresses(existing, incoming []netip.Addr) []netip.Addr {
183 + if len(existing) == 0 && len(incoming) == 0 {
184 + return nil
185 + }
186 + merged := make(map[string]netip.Addr, len(existing)+len(incoming))
187 + for _, addr := range existing {
188 + if addr.IsValid() {
189 + merged[addr.String()] = addr
190 + }
191 + }
192 + for _, addr := range incoming {
193 + if addr.IsValid() {
194 + merged[addr.String()] = addr
195 + }
196 + }
197 + return sortedAddrValues(merged)
198 +}
199 +
200 +func mergeObservedDeviceLabels(existing, incoming map[string]string) map[string]string {
201 + if len(existing) == 0 && len(incoming) == 0 {
202 + return nil
203 + }
204 + out := make(map[string]string, len(existing)+len(incoming))
205 + for key, value := range existing {
206 + if value != "" {
207 + out[key] = value
208 + }
209 + }
210 + for key, value := range incoming {
211 + if value == "" {
212 + continue
213 + }
214 + if strings.TrimSpace(out[key]) == "" {
215 + out[key] = value
216 + }
217 + }
218 + if len(out) == 0 {
219 + return nil
220 + }
221 + return out
222 +}
src/go/pkg/topology/engine/l2_pipeline_remote_resolution.go new
+167
@@ -0,0 +1,167 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "net/netip"
7 + "strings"
8 +)
9 +
10 +func (s *l2BuildState) isMACCompatibleWithDevice(deviceID, remoteMAC string) bool {
11 + deviceID = strings.TrimSpace(deviceID)
12 + remoteMAC = normalizeMAC(remoteMAC)
13 + if deviceID == "" || remoteMAC == "" {
14 + return true
15 + }
16 + device, ok := s.devices[deviceID]
17 + if !ok {
18 + return true
19 + }
20 + deviceMAC := primaryL2MACIdentity(device.ChassisID, "")
21 + if deviceMAC == "" {
22 + return true
23 + }
24 + return deviceMAC == remoteMAC
25 +}
26 +
27 +func (s *l2BuildState) shouldEnforceHostnameMACGuard(deviceID, mgmtIP string) bool {
28 + deviceID = strings.TrimSpace(deviceID)
29 + if deviceID == "" {
30 + return false
31 + }
32 + if canonicalIP(mgmtIP) != "" {
33 + return true
34 + }
35 + return s.managedObservationByDeviceID[deviceID]
36 +}
37 +
38 +func (s *l2BuildState) resolveKnownRemote(hostname, chassisID, mgmtIP, remoteMAC string) string {
39 + remoteIP := canonicalIP(mgmtIP)
40 + enforceMACGuard := remoteMAC != ""
41 + candidates := []string{
42 + s.hostToID[canonicalHost(hostname)],
43 + s.chassisToID[canonicalToken(chassisID)],
44 + s.ipToID[remoteIP],
45 + }
46 + for _, candidateID := range candidates {
47 + candidateID = strings.TrimSpace(candidateID)
48 + if candidateID == "" {
49 + continue
50 + }
51 + if enforceMACGuard && !s.isMACCompatibleWithDevice(candidateID, remoteMAC) {
52 + continue
53 + }
54 + return candidateID
55 + }
56 + return ""
57 +}
58 +
59 +func (s *l2BuildState) resolveRemote(hostname, chassisID, mgmtIP, fallbackID string) string {
60 + return s.resolveRemoteWithHostnameMACGuard(hostname, chassisID, mgmtIP, fallbackID, false)
61 +}
62 +
63 +func (s *l2BuildState) resolveRemoteEnforcingHostnameMACGuard(hostname, chassisID, mgmtIP, fallbackID string) string {
64 + return s.resolveRemoteWithHostnameMACGuard(hostname, chassisID, mgmtIP, fallbackID, true)
65 +}
66 +
67 +func (s *l2BuildState) resolveRemoteWithHostnameMACGuard(hostname, chassisID, mgmtIP, fallbackID string, enforceHostnameMACGuard bool) string {
68 + remoteMAC := primaryL2MACIdentity(chassisID, "")
69 + if knownID := s.resolveKnownRemote(hostname, chassisID, mgmtIP, remoteMAC); knownID != "" {
70 + if remoteMAC != "" {
71 + s.macToID[remoteMAC] = knownID
72 + s.chassisToID[canonicalToken(remoteMAC)] = knownID
73 + if device, ok := s.devices[knownID]; ok && primaryL2MACIdentity(device.ChassisID, "") == "" {
74 + device.ChassisID = remoteMAC
75 + s.devices[knownID] = device
76 + }
77 + }
78 + return knownID
79 + }
80 +
81 + if remoteMAC != "" {
82 + if id := s.macToID[remoteMAC]; id != "" {
83 + return id
84 + }
85 +
86 + generatedID := deriveRemoteDeviceID(hostname, remoteMAC, mgmtIP, fallbackID)
87 + if existingID := strings.TrimSpace(s.hostToID[canonicalHost(hostname)]); existingID != "" &&
88 + (enforceHostnameMACGuard || s.shouldEnforceHostnameMACGuard(existingID, mgmtIP)) &&
89 + !s.isMACCompatibleWithDevice(existingID, remoteMAC) {
90 + generatedID = deriveRemoteDeviceID("", remoteMAC, mgmtIP, fallbackID)
91 + }
92 + if _, ok := s.devices[generatedID]; !ok {
93 + device := Device{
94 + ID: generatedID,
95 + Hostname: strings.TrimSpace(hostname),
96 + SysObject: "",
97 + ChassisID: remoteMAC,
98 + }
99 + if device.Hostname == "" {
100 + device.Hostname = generatedID
101 + }
102 + if ip := parseAddr(mgmtIP); ip.IsValid() {
103 + device.Addresses = []netip.Addr{ip}
104 + }
105 + s.devices[generatedID] = device
106 + }
107 +
108 + s.macToID[remoteMAC] = generatedID
109 + s.chassisToID[canonicalToken(remoteMAC)] = generatedID
110 + if host := canonicalHost(hostname); host != "" {
111 + if existingID := strings.TrimSpace(s.hostToID[host]); existingID == "" ||
112 + (!enforceHostnameMACGuard && !s.shouldEnforceHostnameMACGuard(existingID, mgmtIP)) ||
113 + s.isMACCompatibleWithDevice(existingID, remoteMAC) {
114 + s.hostToID[host] = generatedID
115 + }
116 + }
117 + if ip := canonicalIP(mgmtIP); ip != "" {
118 + if existingID := strings.TrimSpace(s.ipToID[ip]); existingID == "" || s.isMACCompatibleWithDevice(existingID, remoteMAC) {
119 + s.ipToID[ip] = generatedID
120 + }
121 + }
122 + return generatedID
123 + }
124 +
125 + if id := s.hostToID[canonicalHost(hostname)]; id != "" {
126 + return id
127 + }
128 + if id := s.chassisToID[canonicalToken(chassisID)]; id != "" {
129 + return id
130 + }
131 + if id := s.ipToID[canonicalIP(mgmtIP)]; id != "" {
132 + return id
133 + }
134 +
135 + generatedID := deriveRemoteDeviceID(hostname, chassisID, mgmtIP, fallbackID)
136 + if _, ok := s.devices[generatedID]; !ok {
137 + device := Device{
138 + ID: generatedID,
139 + Hostname: strings.TrimSpace(hostname),
140 + SysObject: "",
141 + ChassisID: strings.TrimSpace(chassisID),
142 + }
143 + if device.Hostname == "" {
144 + device.Hostname = generatedID
145 + }
146 + if ip := parseAddr(mgmtIP); ip.IsValid() {
147 + device.Addresses = []netip.Addr{ip}
148 + }
149 + s.devices[generatedID] = device
150 + }
151 + if host := canonicalHost(hostname); host != "" {
152 + if _, exists := s.hostToID[host]; !exists {
153 + s.hostToID[host] = generatedID
154 + }
155 + }
156 + if chassis := canonicalToken(chassisID); chassis != "" {
157 + if _, exists := s.chassisToID[chassis]; !exists {
158 + s.chassisToID[chassis] = generatedID
159 + }
160 + }
161 + if ip := canonicalIP(mgmtIP); ip != "" {
162 + if _, exists := s.ipToID[ip]; !exists {
163 + s.ipToID[ip] = generatedID
164 + }
165 + }
166 + return generatedID
167 +}
src/go/pkg/topology/engine/l2_pipeline_remote_resolution_test.go new
+22
@@ -0,0 +1,22 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestIsMACCompatibleWithDevice_NormalizesRemoteMAC(t *testing.T) {
12 + state := newL2BuildState(1)
13 + state.devices["known-device"] = Device{
14 + ID: "known-device",
15 + Hostname: "switch-a",
16 + ChassisID: "00:11:22:33:44:55",
17 + }
18 +
19 + require.True(t, state.isMACCompatibleWithDevice("known-device", "0011.2233.4455"))
20 + require.True(t, state.isMACCompatibleWithDevice("known-device", "0x001122334455"))
21 + require.False(t, state.isMACCompatibleWithDevice("known-device", "00-11-22-33-44-66"))
22 +}
src/go/pkg/topology/engine/l2_pipeline_sorting.go new
+291
@@ -0,0 +1,291 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func sortedLLDPRemotes(in []LLDPRemoteObservation) []LLDPRemoteObservation {
11 + out := make([]LLDPRemoteObservation, 0, len(in))
12 + for _, remote := range in {
13 + if strings.TrimSpace(remote.ChassisID) == "" && strings.TrimSpace(remote.SysName) == "" {
14 + continue
15 + }
16 + out = append(out, remote)
17 + }
18 + sort.Slice(out, func(i, j int) bool {
19 + a, b := out[i], out[j]
20 + if a.LocalPortNum != b.LocalPortNum {
21 + return a.LocalPortNum < b.LocalPortNum
22 + }
23 + if a.RemoteIndex != b.RemoteIndex {
24 + return a.RemoteIndex < b.RemoteIndex
25 + }
26 + if a.SysName != b.SysName {
27 + return a.SysName < b.SysName
28 + }
29 + if a.ChassisID != b.ChassisID {
30 + return a.ChassisID < b.ChassisID
31 + }
32 + if a.PortID != b.PortID {
33 + return a.PortID < b.PortID
34 + }
35 + if a.PortIDSubtype != b.PortIDSubtype {
36 + return a.PortIDSubtype < b.PortIDSubtype
37 + }
38 + if a.LocalPortIDSubtype != b.LocalPortIDSubtype {
39 + return a.LocalPortIDSubtype < b.LocalPortIDSubtype
40 + }
41 + if a.PortDesc != b.PortDesc {
42 + return a.PortDesc < b.PortDesc
43 + }
44 + if a.LocalPortDesc != b.LocalPortDesc {
45 + return a.LocalPortDesc < b.LocalPortDesc
46 + }
47 + return a.ManagementIP < b.ManagementIP
48 + })
49 + return out
50 +}
51 +
52 +func sortedCDPRemotes(in []CDPRemoteObservation) []CDPRemoteObservation {
53 + out := make([]CDPRemoteObservation, 0, len(in))
54 + for _, remote := range in {
55 + if strings.TrimSpace(remote.DeviceID) == "" && strings.TrimSpace(remote.Address) == "" {
56 + continue
57 + }
58 + out = append(out, remote)
59 + }
60 + sort.Slice(out, func(i, j int) bool {
61 + a, b := out[i], out[j]
62 + if a.LocalIfIndex != b.LocalIfIndex {
63 + return a.LocalIfIndex < b.LocalIfIndex
64 + }
65 + if a.DeviceIndex != b.DeviceIndex {
66 + return a.DeviceIndex < b.DeviceIndex
67 + }
68 + if a.SysName != b.SysName {
69 + return a.SysName < b.SysName
70 + }
71 + if a.DeviceID != b.DeviceID {
72 + return a.DeviceID < b.DeviceID
73 + }
74 + return a.Address < b.Address
75 + })
76 + return out
77 +}
78 +
79 +func sortedBridgePorts(in []BridgePortObservation) []BridgePortObservation {
80 + out := make([]BridgePortObservation, 0, len(in))
81 + for _, bridgePort := range in {
82 + if strings.TrimSpace(bridgePort.BasePort) == "" || bridgePort.IfIndex <= 0 {
83 + continue
84 + }
85 + out = append(out, bridgePort)
86 + }
87 + sort.Slice(out, func(i, j int) bool {
88 + a, b := out[i], out[j]
89 + if a.BasePort != b.BasePort {
90 + return a.BasePort < b.BasePort
91 + }
92 + return a.IfIndex < b.IfIndex
93 + })
94 + return out
95 +}
96 +
97 +func sortedSTPPortEntries(in []STPPortObservation) []STPPortObservation {
98 + out := make([]STPPortObservation, 0, len(in))
99 + for _, entry := range in {
100 + if strings.TrimSpace(entry.Port) == "" {
101 + continue
102 + }
103 + out = append(out, entry)
104 + }
105 + sort.Slice(out, func(i, j int) bool {
106 + a, b := out[i], out[j]
107 + if a.Port != b.Port {
108 + return a.Port < b.Port
109 + }
110 + if a.VLANID != b.VLANID {
111 + return a.VLANID < b.VLANID
112 + }
113 + if a.IfIndex != b.IfIndex {
114 + return a.IfIndex < b.IfIndex
115 + }
116 + if a.IfName != b.IfName {
117 + return a.IfName < b.IfName
118 + }
119 + return a.DesignatedBridge < b.DesignatedBridge
120 + })
121 + return out
122 +}
123 +
124 +func sortedFDBEntries(in []FDBObservation) []FDBObservation {
125 + out := make([]FDBObservation, 0, len(in))
126 + for _, entry := range in {
127 + if strings.TrimSpace(entry.MAC) == "" {
128 + continue
129 + }
130 + out = append(out, entry)
131 + }
132 + sort.Slice(out, func(i, j int) bool {
133 + a, b := out[i], out[j]
134 + if a.BridgePort != b.BridgePort {
135 + return a.BridgePort < b.BridgePort
136 + }
137 + if a.VLANID != b.VLANID {
138 + return a.VLANID < b.VLANID
139 + }
140 + if a.IfIndex != b.IfIndex {
141 + return a.IfIndex < b.IfIndex
142 + }
143 + if a.MAC != b.MAC {
144 + return a.MAC < b.MAC
145 + }
146 + return a.Status < b.Status
147 + })
148 + return out
149 +}
150 +
151 +func sortedARPNDEntries(in []ARPNDObservation) []ARPNDObservation {
152 + out := make([]ARPNDObservation, 0, len(in))
153 + for _, entry := range in {
154 + if strings.TrimSpace(entry.MAC) == "" && strings.TrimSpace(entry.IP) == "" {
155 + continue
156 + }
157 + out = append(out, entry)
158 + }
159 + sort.Slice(out, func(i, j int) bool {
160 + a, b := out[i], out[j]
161 + if a.Protocol != b.Protocol {
162 + return a.Protocol < b.Protocol
163 + }
164 + if a.IfIndex != b.IfIndex {
165 + return a.IfIndex < b.IfIndex
166 + }
167 + if a.IP != b.IP {
168 + return a.IP < b.IP
169 + }
170 + if a.MAC != b.MAC {
171 + return a.MAC < b.MAC
172 + }
173 + if a.State != b.State {
174 + return a.State < b.State
175 + }
176 + return a.AddrType < b.AddrType
177 + })
178 + return out
179 +}
180 +
181 +func sortedDevices(in map[string]Device) []Device {
182 + out := make([]Device, 0, len(in))
183 + for _, dev := range in {
184 + out = append(out, dev)
185 + }
186 + sort.Slice(out, func(i, j int) bool {
187 + if out[i].ID != out[j].ID {
188 + return out[i].ID < out[j].ID
189 + }
190 + return out[i].Hostname < out[j].Hostname
191 + })
192 + return out
193 +}
194 +
195 +func sortedInterfaces(in map[string]Interface) []Interface {
196 + out := make([]Interface, 0, len(in))
197 + for _, iface := range in {
198 + out = append(out, iface)
199 + }
200 + sort.Slice(out, func(i, j int) bool {
201 + a, b := out[i], out[j]
202 + if a.DeviceID != b.DeviceID {
203 + return a.DeviceID < b.DeviceID
204 + }
205 + if a.IfIndex != b.IfIndex {
206 + return a.IfIndex < b.IfIndex
207 + }
208 + return a.IfName < b.IfName
209 + })
210 + return out
211 +}
212 +
213 +func sortedAdjacencies(in map[string]Adjacency) []Adjacency {
214 + out := make([]Adjacency, 0, len(in))
215 + for _, adj := range in {
216 + out = append(out, adj)
217 + }
218 + sort.Slice(out, func(i, j int) bool {
219 + a, b := out[i], out[j]
220 + if a.Protocol != b.Protocol {
221 + return a.Protocol < b.Protocol
222 + }
223 + if a.SourceID != b.SourceID {
224 + return a.SourceID < b.SourceID
225 + }
226 + if a.SourcePort != b.SourcePort {
227 + return a.SourcePort < b.SourcePort
228 + }
229 + if a.TargetID != b.TargetID {
230 + return a.TargetID < b.TargetID
231 + }
232 + return a.TargetPort < b.TargetPort
233 + })
234 + return out
235 +}
236 +
237 +func sortedAttachments(in map[string]Attachment) []Attachment {
238 + out := make([]Attachment, 0, len(in))
239 + for _, attachment := range in {
240 + out = append(out, attachment)
241 + }
242 + sort.Slice(out, func(i, j int) bool {
243 + a, b := out[i], out[j]
244 + if a.DeviceID != b.DeviceID {
245 + return a.DeviceID < b.DeviceID
246 + }
247 + if a.IfIndex != b.IfIndex {
248 + return a.IfIndex < b.IfIndex
249 + }
250 + if a.EndpointID != b.EndpointID {
251 + return a.EndpointID < b.EndpointID
252 + }
253 + return a.Method < b.Method
254 + })
255 + return out
256 +}
257 +
258 +func sortedEnrichments(in map[string]*enrichmentAccumulator) []Enrichment {
259 + out := make([]Enrichment, 0, len(in))
260 + for _, acc := range in {
261 + if acc == nil || strings.TrimSpace(acc.EndpointID) == "" {
262 + continue
263 + }
264 + enrichment := Enrichment{
265 + EndpointID: acc.EndpointID,
266 + MAC: acc.MAC,
267 + IPs: sortedAddrValues(acc.IPs),
268 + Labels: map[string]string{
269 + "sources": setToCSV(acc.Protocols),
270 + "device_ids": setToCSV(acc.DeviceIDs),
271 + "if_indexes": setToCSV(acc.IfIndexes),
272 + "if_names": setToCSV(acc.IfNames),
273 + "states": setToCSV(acc.States),
274 + "addr_types": setToCSV(acc.AddrTypes),
275 + },
276 + }
277 + pruneEmptyLabels(enrichment.Labels)
278 + out = append(out, enrichment)
279 + }
280 + sort.Slice(out, func(i, j int) bool {
281 + a, b := out[i], out[j]
282 + if a.EndpointID != b.EndpointID {
283 + return a.EndpointID < b.EndpointID
284 + }
285 + if a.MAC != b.MAC {
286 + return a.MAC < b.MAC
287 + }
288 + return len(a.IPs) < len(b.IPs)
289 + })
290 + return out
291 +}
src/go/pkg/topology/engine/l2_pipeline_stats.go new
+23
@@ -0,0 +1,23 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +func newL2ResultStats() map[string]any {
6 + return map[string]any{
7 + "devices_total": 0,
8 + "links_total": 0,
9 + "links_lldp": 0,
10 + "links_cdp": 0,
11 + "links_stp": 0,
12 + "attachments_total": 0,
13 + "attachments_fdb": 0,
14 + "enrichments_total": 0,
15 + "enrichments_arp_nd": 0,
16 + "bridge_domains_total": 0,
17 + "endpoints_total": 0,
18 + "identity_alias_endpoints_mapped": 0,
19 + "identity_alias_endpoints_ambiguous_mac": 0,
20 + "identity_alias_ips_merged": 0,
21 + "identity_alias_ips_conflict_skipped": 0,
22 + }
23 +}
src/go/pkg/topology/engine/l2_pipeline_test.go new
+1579
@@ -0,0 +1,1579 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 + "time"
8 +
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestBuildL2ResultFromObservations_LLDPAndCDP(t *testing.T) {
13 + observations := []L2Observation{
14 + {
15 + DeviceID: "switch-a",
16 + Hostname: "switch-a.example.net",
17 + ManagementIP: "10.0.0.1",
18 + ChassisID: "aa:bb:cc:dd:ee:ff",
19 + Interfaces: []ObservedInterface{
20 + {IfIndex: 8, IfName: "Gi0/0", IfDescr: "Gi0/0"},
21 + },
22 + LLDPRemotes: []LLDPRemoteObservation{
23 + {
24 + LocalPortNum: "8",
25 + RemoteIndex: "1",
26 + LocalPortID: "Gi0/0",
27 + ChassisID: "bb:cc:dd:ee:ff:00",
28 + SysName: "switch-b.example.net",
29 + PortID: "Gi0/1",
30 + ManagementIP: "10.0.0.2",
31 + },
32 + },
33 + CDPRemotes: []CDPRemoteObservation{
34 + {
35 + LocalIfIndex: 8,
36 + LocalIfName: "Gi0/0",
37 + DeviceIndex: "1",
38 + DeviceID: "switch-b.example.net",
39 + DevicePort: "Gi0/1",
40 + },
41 + },
42 + },
43 + {
44 + DeviceID: "switch-b",
45 + Hostname: "switch-b.example.net",
46 + ManagementIP: "10.0.0.2",
47 + ChassisID: "bb:cc:dd:ee:ff:00",
48 + Interfaces: []ObservedInterface{
49 + {IfIndex: 9, IfName: "Gi0/1", IfDescr: "Gi0/1"},
50 + },
51 + },
52 + }
53 +
54 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true, EnableCDP: true})
55 + require.NoError(t, err)
56 + require.Len(t, result.Devices, 2)
57 + require.Len(t, result.Interfaces, 2)
58 + require.Len(t, result.Adjacencies, 2)
59 + require.Equal(t, 2, result.Stats["links_total"])
60 + require.Equal(t, 1, result.Stats["links_lldp"])
61 + require.Equal(t, 1, result.Stats["links_cdp"])
62 +
63 + require.Equal(t, "cdp", result.Adjacencies[0].Protocol)
64 + require.Equal(t, "switch-a", result.Adjacencies[0].SourceID)
65 + require.Equal(t, "switch-b", result.Adjacencies[0].TargetID)
66 + require.Equal(t, "lldp", result.Adjacencies[1].Protocol)
67 + require.Equal(t, "switch-a", result.Adjacencies[1].SourceID)
68 + require.Equal(t, "switch-b", result.Adjacencies[1].TargetID)
69 +}
70 +
71 +func TestBuildL2ResultFromObservations_DefaultProtocols(t *testing.T) {
72 + observations := []L2Observation{
73 + {
74 + DeviceID: "switch-a",
75 + Hostname: "switch-a",
76 + ManagementIP: "10.0.0.1",
77 + CDPRemotes: []CDPRemoteObservation{
78 + {
79 + LocalIfIndex: 8,
80 + DeviceIndex: "1",
81 + Address: "0a000002",
82 + },
83 + },
84 + },
85 + {
86 + DeviceID: "switch-b",
87 + Hostname: "switch-b",
88 + ManagementIP: "10.0.0.2",
89 + },
90 + }
91 +
92 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{})
93 + require.NoError(t, err)
94 + require.Len(t, result.Adjacencies, 1)
95 + require.Equal(t, "switch-b", result.Adjacencies[0].TargetID)
96 + require.Equal(t, 0, result.Stats["links_lldp"])
97 + require.Equal(t, 1, result.Stats["links_cdp"])
98 +}
99 +
100 +func TestBuildL2ResultFromObservations_UsesProvidedCollectedAt(t *testing.T) {
101 + collectedAt := time.Date(2026, time.April, 2, 0, 0, 0, 0, time.UTC)
102 +
103 + result, err := BuildL2ResultFromObservations([]L2Observation{
104 + {
105 + DeviceID: "switch-a",
106 + Hostname: "switch-a.example.net",
107 + },
108 + }, DiscoverOptions{CollectedAt: collectedAt})
109 + require.NoError(t, err)
110 + require.Equal(t, collectedAt, result.CollectedAt)
111 +}
112 +
113 +func TestBuildL2ResultFromObservations_InterfaceStatusLabels(t *testing.T) {
114 + observations := []L2Observation{
115 + {
116 + DeviceID: "switch-a",
117 + Hostname: "switch-a",
118 + Interfaces: []ObservedInterface{
119 + {
120 + IfIndex: 8,
121 + IfName: "Gi0/0",
122 + IfDescr: "Gi0/0",
123 + IfAlias: "uplink-a",
124 + MAC: "AA:BB:CC:DD:EE:FF",
125 + SpeedBps: 1_000_000_000,
126 + LastChange: 12345,
127 + Duplex: "full",
128 + InterfaceType: "ethernetcsmacd",
129 + AdminStatus: "up",
130 + OperStatus: "lowerLayerDown",
131 + },
132 + },
133 + },
134 + }
135 +
136 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{})
137 + require.NoError(t, err)
138 + require.Len(t, result.Interfaces, 1)
139 + require.Equal(t, "ethernetcsmacd", result.Interfaces[0].Labels["if_type"])
140 + require.Equal(t, "up", result.Interfaces[0].Labels["admin_status"])
141 + require.Equal(t, "lowerLayerDown", result.Interfaces[0].Labels["oper_status"])
142 + require.Equal(t, "uplink-a", result.Interfaces[0].Labels["if_alias"])
143 + require.Equal(t, "aa:bb:cc:dd:ee:ff", result.Interfaces[0].Labels["mac"])
144 + require.Equal(t, "1000000000", result.Interfaces[0].Labels["speed_bps"])
145 + require.Equal(t, "12345", result.Interfaces[0].Labels["last_change"])
146 + require.Equal(t, "full", result.Interfaces[0].Labels["duplex"])
147 +}
148 +
149 +func TestBuildL2ResultFromObservations_DeviceProtocolsObservedLabel(t *testing.T) {
150 + observations := []L2Observation{
151 + {
152 + DeviceID: "switch-a",
153 + Hostname: "switch-a",
154 + ManagementIP: "10.0.0.1",
155 + BridgePorts: []BridgePortObservation{
156 + {BasePort: "3", IfIndex: 3},
157 + },
158 + FDBEntries: []FDBObservation{
159 + {MAC: "70:49:a2:65:72:cd", BridgePort: "3", Status: "learned"},
160 + },
161 + ARPNDEntries: []ARPNDObservation{
162 + {IfIndex: 3, IP: "10.0.0.20", MAC: "70:49:a2:65:72:cd", Protocol: "arp"},
163 + },
164 + // Collected STP row that does not form a usable topology edge.
165 + STPPorts: []STPPortObservation{
166 + {Port: "3", DesignatedBridge: "00:11:22:33:44:55"},
167 + },
168 + },
169 + }
170 +
171 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{
172 + EnableBridge: true,
173 + EnableARP: true,
174 + EnableSTP: true,
175 + })
176 + require.NoError(t, err)
177 + require.Len(t, result.Devices, 1)
178 + require.Equal(t, "arp,bridge,fdb,stp", result.Devices[0].Labels["protocols_observed"])
179 +}
180 +
181 +func TestBuildL2ResultFromObservations_MergesDuplicateDeviceObservations(t *testing.T) {
182 + observations := []L2Observation{
183 + {
184 + DeviceID: "switch-a",
185 + Hostname: "switch-a.example.net",
186 + ManagementIP: "10.0.0.1",
187 + SysObjectID: "1.3.6.1.4.1.9.1.1",
188 + ChassisID: "aa:bb:cc:dd:ee:ff",
189 + BridgePorts: []BridgePortObservation{
190 + {BasePort: "3", IfIndex: 3},
191 + },
192 + },
193 + {
194 + DeviceID: "switch-a",
195 + ManagementIP: "10.0.0.2",
196 + ARPNDEntries: []ARPNDObservation{
197 + {IfIndex: 3, IP: "10.0.0.20", MAC: "70:49:a2:65:72:cd", Protocol: "arp"},
198 + },
199 + },
200 + }
201 +
202 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{
203 + EnableBridge: true,
204 + EnableARP: true,
205 + })
206 + require.NoError(t, err)
207 +
208 + device := findDeviceByID(result.Devices, "switch-a")
209 + require.NotNil(t, device)
210 + require.Equal(t, "switch-a.example.net", device.Hostname)
211 + require.Equal(t, "1.3.6.1.4.1.9.1.1", device.SysObject)
212 + require.Equal(t, "aa:bb:cc:dd:ee:ff", device.ChassisID)
213 + require.Equal(t, []string{"10.0.0.1", "10.0.0.2"}, deviceAddressStrings(*device))
214 + require.Equal(t, "arp,bridge", device.Labels["protocols_observed"])
215 +}
216 +
217 +func TestBuildL2ResultFromObservations_SkipsSelfAdjacencies(t *testing.T) {
218 + observations := []L2Observation{
219 + {
220 + DeviceID: "dw",
221 + Hostname: "dw",
222 + ManagementIP: "10.104.133.114",
223 + ChassisID: "cf",
224 + LLDPRemotes: []LLDPRemoteObservation{
225 + {
226 + LocalPortNum: "1",
227 + RemoteIndex: "1",
228 + LocalPortID: "CF",
229 + ChassisID: "cf",
230 + SysName: "dw",
231 + PortID: "CF",
232 + },
233 + },
234 + CDPRemotes: []CDPRemoteObservation{
235 + {
236 + LocalIfIndex: 1,
237 + LocalIfName: "CF",
238 + DeviceIndex: "1",
239 + DeviceID: "dw",
240 + SysName: "dw",
241 + DevicePort: "CF",
242 + Address: "10.104.133.114",
243 + },
244 + },
245 + },
246 + }
247 +
248 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true, EnableCDP: true})
249 + require.NoError(t, err)
250 + require.Len(t, result.Devices, 1)
251 + require.Empty(t, result.Adjacencies)
252 + require.Equal(t, 0, result.Stats["links_total"])
253 + require.Equal(t, 0, result.Stats["links_lldp"])
254 + require.Equal(t, 0, result.Stats["links_cdp"])
255 +}
256 +
257 +func TestBuildL2ResultFromObservations_CDPSysNameAndDeviceID(t *testing.T) {
258 + observations := []L2Observation{
259 + {
260 + DeviceID: "switch-a",
261 + Hostname: "switch-a",
262 + ManagementIP: "10.0.0.1",
263 + CDPRemotes: []CDPRemoteObservation{
264 + {
265 + LocalIfIndex: 1,
266 + DeviceIndex: "1",
267 + DeviceID: "SEP001122334455",
268 + SysName: "distribution-sw",
269 + Address: "0a000002",
270 + DevicePort: "Gi0/48",
271 + },
272 + },
273 + },
274 + }
275 +
276 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableCDP: true})
277 + require.NoError(t, err)
278 + require.Len(t, result.Devices, 2)
279 + require.Len(t, result.Adjacencies, 1)
280 + require.Equal(t, "distribution-sw", result.Adjacencies[0].TargetID)
281 +
282 + var remote Device
283 + var local Device
284 + for _, dev := range result.Devices {
285 + if dev.ID == "distribution-sw" {
286 + remote = dev
287 + }
288 + if dev.ID == "switch-a" {
289 + local = dev
290 + }
291 + }
292 + require.Equal(t, "distribution-sw", remote.ID)
293 + require.Equal(t, "distribution-sw", remote.Hostname)
294 + require.Equal(t, "SEP001122334455", remote.ChassisID)
295 + require.Equal(t, "10.0.0.2", remote.Addresses[0].String())
296 + require.Equal(t, "true", remote.Labels["inferred"])
297 + require.Equal(t, "false", local.Labels["inferred"])
298 +}
299 +
300 +func TestBuildL2ResultFromObservations_FDBAttachments(t *testing.T) {
301 + observations := []L2Observation{
302 + {
303 + DeviceID: "switch-a",
304 + Hostname: "switch-a",
305 + Interfaces: []ObservedInterface{
306 + {IfIndex: 3, IfName: "Port3", IfDescr: "Port3"},
307 + },
308 + BridgePorts: []BridgePortObservation{
309 + {BasePort: "7", IfIndex: 3},
310 + },
311 + FDBEntries: []FDBObservation{
312 + {MAC: "7049a26572cd", BridgePort: "7", Status: "learned"},
313 + {MAC: "70:49:a2:65:72:ce", BridgePort: "7", Status: "learned"},
314 + {MAC: "70:49:a2:65:72:cd", BridgePort: "7", Status: "learned"},
315 + },
316 + },
317 + }
318 +
319 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
320 + require.NoError(t, err)
321 + require.Empty(t, result.Adjacencies)
322 + require.Len(t, result.Attachments, 2)
323 +
324 + first := result.Attachments[0]
325 + require.Equal(t, "switch-a", first.DeviceID)
326 + require.Equal(t, 3, first.IfIndex)
327 + require.Equal(t, "mac:70:49:a2:65:72:cd", first.EndpointID)
328 + require.Equal(t, "fdb", first.Method)
329 + require.Equal(t, "bridge-domain:switch-a:if:3", first.Labels["bridge_domain"])
330 + require.Equal(t, "7", first.Labels["bridge_port"])
331 + require.Equal(t, "Port3", first.Labels["if_name"])
332 +
333 + require.Equal(t, 2, result.Stats["attachments_total"])
334 + require.Equal(t, 2, result.Stats["attachments_fdb"])
335 + require.Equal(t, 1, result.Stats["bridge_domains_total"])
336 + require.Equal(t, 2, result.Stats["endpoints_total"])
337 +}
338 +
339 +func TestBuildL2ResultFromObservations_FDBDropsDuplicateMACAcrossPorts(t *testing.T) {
340 + observations := []L2Observation{
341 + {
342 + DeviceID: "switch-a",
343 + Hostname: "switch-a",
344 + BridgePorts: []BridgePortObservation{
345 + {BasePort: "1", IfIndex: 1},
346 + {BasePort: "2", IfIndex: 2},
347 + },
348 + FDBEntries: []FDBObservation{
349 + {MAC: "70:49:a2:65:72:cd", BridgePort: "1", Status: "learned"},
350 + {MAC: "70:49:a2:65:72:cd", BridgePort: "2", Status: "learned"},
351 + {MAC: "70:49:a2:65:72:ce", BridgePort: "2", Status: "learned"},
352 + },
353 + },
354 + }
355 +
356 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
357 + require.NoError(t, err)
358 + require.Len(t, result.Attachments, 1)
359 + require.Equal(t, "mac:70:49:a2:65:72:ce", result.Attachments[0].EndpointID)
360 + require.Equal(t, 1, result.Stats["attachments_fdb"])
361 +}
362 +
363 +func TestBuildL2ResultFromObservations_FDBKeepsSameMACAcrossPortsWhenVLANDiffers(t *testing.T) {
364 + observations := []L2Observation{
365 + {
366 + DeviceID: "switch-a",
367 + Hostname: "switch-a",
368 + BridgePorts: []BridgePortObservation{
369 + {BasePort: "1", IfIndex: 1},
370 + {BasePort: "2", IfIndex: 2},
371 + },
372 + FDBEntries: []FDBObservation{
373 + {MAC: "70:49:a2:65:72:cd", BridgePort: "1", Status: "learned", VLANID: "10"},
374 + {MAC: "70:49:a2:65:72:cd", BridgePort: "2", Status: "learned", VLANID: "20"},
375 + },
376 + },
377 + }
378 +
379 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
380 + require.NoError(t, err)
381 + require.Len(t, result.Attachments, 2)
382 +
383 + first := result.Attachments[0]
384 + second := result.Attachments[1]
385 + require.Equal(t, "mac:70:49:a2:65:72:cd", first.EndpointID)
386 + require.Equal(t, "mac:70:49:a2:65:72:cd", second.EndpointID)
387 + require.NotEqual(t, first.Labels["vlan_id"], second.Labels["vlan_id"])
388 + require.Equal(t, 2, result.Stats["attachments_fdb"])
389 +}
390 +
391 +func TestBuildL2ResultFromObservations_FDBSkipsSelfAndNonLearned(t *testing.T) {
392 + observations := []L2Observation{
393 + {
394 + DeviceID: "switch-a",
395 + Hostname: "switch-a",
396 + BridgePorts: []BridgePortObservation{
397 + {BasePort: "1", IfIndex: 1},
398 + {BasePort: "2", IfIndex: 2},
399 + {BasePort: "3", IfIndex: 3},
400 + },
401 + FDBEntries: []FDBObservation{
402 + {MAC: "00:11:22:33:44:55", BridgePort: "1", Status: "self"},
403 + {MAC: "00:11:22:33:44:55", BridgePort: "2", Status: "learned"},
404 + {MAC: "00:aa:bb:cc:dd:ee", BridgePort: "2", Status: "mgmt"},
405 + {MAC: "00:ff:ee:dd:cc:bb", BridgePort: "3", Status: "learned"},
406 + },
407 + },
408 + }
409 +
410 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
411 + require.NoError(t, err)
412 + require.Len(t, result.Attachments, 1)
413 + require.Equal(t, "mac:00:ff:ee:dd:cc:bb", result.Attachments[0].EndpointID)
414 + require.Equal(t, "3", result.Attachments[0].Labels["bridge_port"])
415 +}
416 +
417 +func TestBuildL2ResultFromObservations_STPAdjacency(t *testing.T) {
418 + observations := []L2Observation{
419 + {
420 + DeviceID: "switch-a",
421 + Hostname: "switch-a",
422 + BaseBridgeAddress: "00:11:22:33:44:55",
423 + Interfaces: []ObservedInterface{
424 + {IfIndex: 3, IfName: "Port3", IfDescr: "Port3"},
425 + },
426 + BridgePorts: []BridgePortObservation{
427 + {BasePort: "3", IfIndex: 3},
428 + },
429 + STPPorts: []STPPortObservation{
430 + {
431 + Port: "3",
432 + VLANID: "200",
433 + VLANName: "servers",
434 + DesignatedBridge: "66:77:88:99:aa:bb",
435 + DesignatedPort: "8001",
436 + State: "forwarding",
437 + },
438 + },
439 + },
440 + {
441 + DeviceID: "switch-b",
442 + Hostname: "switch-b",
443 + BaseBridgeAddress: "66:77:88:99:aa:bb",
444 + },
445 + }
446 +
447 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableSTP: true})
448 + require.NoError(t, err)
449 + require.Len(t, result.Adjacencies, 1)
450 + require.Equal(t, "stp", result.Adjacencies[0].Protocol)
451 + require.Equal(t, "switch-a", result.Adjacencies[0].SourceID)
452 + require.Equal(t, "switch-b", result.Adjacencies[0].TargetID)
453 + require.Equal(t, "Port3", result.Adjacencies[0].SourcePort)
454 + require.Equal(t, "200", result.Adjacencies[0].Labels["vlan_id"])
455 + require.Equal(t, "servers", result.Adjacencies[0].Labels["vlan_name"])
456 + require.Equal(t, 1, result.Stats["links_stp"])
457 +}
458 +
459 +func TestBuildL2ResultFromObservations_STPDoesNotCreateSyntheticActors(t *testing.T) {
460 + observations := []L2Observation{
461 + {
462 + DeviceID: "switch-a",
463 + Hostname: "switch-a",
464 + BaseBridgeAddress: "00:11:22:33:44:55",
465 + Interfaces: []ObservedInterface{
466 + {IfIndex: 3, IfName: "Port3", IfDescr: "Port3"},
467 + },
468 + BridgePorts: []BridgePortObservation{
469 + {BasePort: "3", IfIndex: 3},
470 + },
471 + STPPorts: []STPPortObservation{
472 + {
473 + Port: "3",
474 + DesignatedBridge: "66:77:88:99:aa:bb",
475 + DesignatedPort: "8001",
476 + State: "forwarding",
477 + },
478 + },
479 + },
480 + }
481 +
482 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableSTP: true})
483 + require.NoError(t, err)
484 + require.Len(t, result.Devices, 1)
485 + require.Empty(t, result.Adjacencies)
486 + require.Equal(t, 0, result.Stats["links_stp"])
487 +}
488 +
489 +func TestBuildL2ResultFromObservations_FDBBridgeDomainFallbackToBridgePort(t *testing.T) {
490 + observations := []L2Observation{
491 + {
492 + DeviceID: "switch-a",
493 + Hostname: "switch-a",
494 + FDBEntries: []FDBObservation{
495 + {MAC: "70:49:a2:65:72:cd", BridgePort: "77", Status: "learned"},
496 + },
497 + },
498 + }
499 +
500 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
501 + require.NoError(t, err)
502 + require.Len(t, result.Attachments, 1)
503 + require.Equal(t, 0, result.Attachments[0].IfIndex)
504 + require.Equal(t, "bridge-domain:switch-a:bp:77", result.Attachments[0].Labels["bridge_domain"])
505 +}
506 +
507 +func TestBuildL2ResultFromObservations_FDBVLANNameLabel(t *testing.T) {
508 + observations := []L2Observation{
509 + {
510 + DeviceID: "switch-a",
511 + Hostname: "switch-a",
512 + BridgePorts: []BridgePortObservation{
513 + {BasePort: "7", IfIndex: 3},
514 + },
515 + FDBEntries: []FDBObservation{
516 + {MAC: "70:49:a2:65:72:cd", BridgePort: "7", Status: "learned", VLANID: "200", VLANName: "servers"},
517 + },
518 + },
519 + }
520 +
521 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
522 + require.NoError(t, err)
523 + require.Len(t, result.Attachments, 1)
524 + require.Equal(t, "servers", result.Attachments[0].Labels["vlan_name"])
525 +}
526 +
527 +func TestBuildL2ResultFromObservations_BridgeOnlyDoesNotAutoEnableDiscoveryProtocols(t *testing.T) {
528 + observations := []L2Observation{
529 + {
530 + DeviceID: "switch-a",
531 + Hostname: "switch-a",
532 + LLDPRemotes: []LLDPRemoteObservation{
533 + {
534 + LocalPortNum: "1",
535 + LocalPortID: "Gi0/1",
536 + SysName: "switch-b",
537 + PortID: "Gi0/2",
538 + },
539 + },
540 + BridgePorts: []BridgePortObservation{
541 + {BasePort: "1", IfIndex: 1},
542 + },
543 + FDBEntries: []FDBObservation{
544 + {MAC: "70:49:a2:65:72:cd", BridgePort: "1"},
545 + },
546 + },
547 + {
548 + DeviceID: "switch-b",
549 + Hostname: "switch-b",
550 + },
551 + }
552 +
553 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
554 + require.NoError(t, err)
555 + require.Empty(t, result.Adjacencies)
556 + require.Len(t, result.Attachments, 1)
557 +}
558 +
559 +func TestBuildL2ResultFromObservations_ARPNDEnrichment(t *testing.T) {
560 + observations := []L2Observation{
561 + {
562 + DeviceID: "switch-a",
563 + Hostname: "switch-a",
564 + ARPNDEntries: []ARPNDObservation{
565 + {
566 + Protocol: "arp",
567 + IfIndex: 3,
568 + IfName: "Port3",
569 + IP: "10.20.4.84",
570 + MAC: "7049a26572cd",
571 + State: "reachable",
572 + AddrType: "ipv4",
573 + },
574 + {
575 + Protocol: "arp",
576 + IfIndex: 5,
577 + IfName: "Port5",
578 + IP: "10.20.4.85",
579 + MAC: "70:49:a2:65:72:cd",
580 + State: "reachable",
581 + AddrType: "ipv4",
582 + },
583 + },
584 + },
585 + }
586 +
587 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableARP: true})
588 + require.NoError(t, err)
589 + require.Empty(t, result.Adjacencies)
590 + require.Empty(t, result.Attachments)
591 + require.Len(t, result.Enrichments, 1)
592 +
593 + enrichment := result.Enrichments[0]
594 + require.Equal(t, "mac:70:49:a2:65:72:cd", enrichment.EndpointID)
595 + require.Equal(t, "70:49:a2:65:72:cd", enrichment.MAC)
596 + require.Len(t, enrichment.IPs, 2)
597 + require.Equal(t, "10.20.4.84", enrichment.IPs[0].String())
598 + require.Equal(t, "10.20.4.85", enrichment.IPs[1].String())
599 + require.Equal(t, "arp", enrichment.Labels["sources"])
600 + require.Equal(t, "switch-a", enrichment.Labels["device_ids"])
601 + require.Equal(t, "3,5", enrichment.Labels["if_indexes"])
602 + require.Equal(t, "Port3,Port5", enrichment.Labels["if_names"])
603 + require.Equal(t, "reachable", enrichment.Labels["states"])
604 + require.Equal(t, "ipv4", enrichment.Labels["addr_types"])
605 + require.Equal(t, 1, result.Stats["enrichments_total"])
606 + require.Equal(t, 1, result.Stats["enrichments_arp_nd"])
607 +}
608 +
609 +func TestBuildL2ResultFromObservations_SkipsMACLessARPNDEntries(t *testing.T) {
610 + observations := []L2Observation{
611 + {
612 + DeviceID: "switch-a",
613 + Hostname: "switch-a",
614 + ARPNDEntries: []ARPNDObservation{
615 + {
616 + Protocol: "arp",
617 + IfIndex: 7,
618 + IfName: "Port7",
619 + IP: "10.20.4.99",
620 + State: "reachable",
621 + AddrType: "ipv4",
622 + },
623 + {
624 + Protocol: "arp",
625 + IfIndex: 7,
626 + IfName: "Port7",
627 + IP: "10.20.4.100",
628 + MAC: "7049a26572cf",
629 + State: "reachable",
630 + AddrType: "ipv4",
631 + },
632 + },
633 + },
634 + }
635 +
636 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableARP: true})
637 + require.NoError(t, err)
638 + require.Len(t, result.Enrichments, 1)
639 + require.Equal(t, "mac:70:49:a2:65:72:cf", result.Enrichments[0].EndpointID)
640 + require.Equal(t, []string{"10.20.4.100"}, addressStrings(result.Enrichments[0].IPs))
641 + require.Equal(t, 1, result.Stats["endpoints_total"])
642 +}
643 +
644 +func TestBuildL2ResultFromObservations_ReconcilesARPAliasIntoLLDPDeviceIdentity(t *testing.T) {
645 + observations := []L2Observation{
646 + {
647 + DeviceID: "mikrotik-router",
648 + Hostname: "MikroTik-router",
649 + ManagementIP: "10.20.4.1",
650 + ChassisID: "18:fd:74:7e:c5:80",
651 + LLDPRemotes: []LLDPRemoteObservation{
652 + {
653 + LocalPortNum: "5",
654 + LocalPortID: "ether5",
655 + LocalPortIDSubtype: "interfaceName",
656 + ChassisID: "d8:5e:d3:0e:c5:e6",
657 + SysName: "costa-desktop",
658 + PortID: "enp6s0",
659 + PortIDSubtype: "interfaceName",
660 + ManagementIP: "fc00:f853:ccd:e793::1",
661 + },
662 + },
663 + ARPNDEntries: []ARPNDObservation{
664 + {
665 + Protocol: "arp",
666 + IfIndex: 5,
667 + IfName: "ether5",
668 + IP: "10.20.4.205",
669 + MAC: "d8:5e:d3:0e:c5:e6",
670 + State: "reachable",
671 + AddrType: "ipv4",
672 + },
673 + },
674 + },
675 + }
676 +
677 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true, EnableARP: true})
678 + require.NoError(t, err)
679 +
680 + costaDesktop := findDeviceByHostname(result.Devices, "costa-desktop")
681 + require.NotNil(t, costaDesktop)
682 + require.ElementsMatch(
683 + t,
684 + []string{"10.20.4.205", "fc00:f853:ccd:e793::1"},
685 + deviceAddressStrings(*costaDesktop),
686 + )
687 + require.Equal(t, 1, result.Stats["identity_alias_endpoints_mapped"])
688 + require.Equal(t, 1, result.Stats["identity_alias_ips_merged"])
689 +}
690 +
691 +func TestBuildL2ResultFromObservations_SkipsConflictingARPAliases(t *testing.T) {
692 + observations := []L2Observation{
693 + {
694 + DeviceID: "mikrotik-router",
695 + Hostname: "MikroTik-router",
696 + ManagementIP: "10.20.4.1",
697 + ChassisID: "18:fd:74:7e:c5:80",
698 + LLDPRemotes: []LLDPRemoteObservation{
699 + {
700 + LocalPortNum: "5",
701 + LocalPortID: "ether5",
702 + LocalPortIDSubtype: "interfaceName",
703 + ChassisID: "d8:5e:d3:0e:c5:e6",
704 + SysName: "costa-desktop",
705 + PortID: "enp6s0",
706 + PortIDSubtype: "interfaceName",
707 + ManagementIP: "fc00:f853:ccd:e793::1",
708 + },
709 + },
710 + ARPNDEntries: []ARPNDObservation{
711 + {
712 + Protocol: "arp",
713 + IfIndex: 5,
714 + IfName: "ether5",
715 + IP: "10.20.4.205",
716 + MAC: "d8:5e:d3:0e:c5:e6",
717 + State: "reachable",
718 + AddrType: "ipv4",
719 + },
720 + {
721 + Protocol: "arp",
722 + IfIndex: 7,
723 + IfName: "ether7",
724 + IP: "10.20.4.205",
725 + MAC: "70:49:a2:65:72:cd",
726 + State: "reachable",
727 + AddrType: "ipv4",
728 + },
729 + },
730 + },
731 + }
732 +
733 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true, EnableARP: true})
734 + require.NoError(t, err)
735 +
736 + costaDesktop := findDeviceByHostname(result.Devices, "costa-desktop")
737 + require.NotNil(t, costaDesktop)
738 + require.Equal(t, []string{"fc00:f853:ccd:e793::1"}, deviceAddressStrings(*costaDesktop))
739 + require.Equal(t, 1, result.Stats["identity_alias_endpoints_mapped"])
740 + require.Equal(t, 0, result.Stats["identity_alias_ips_merged"])
741 + require.Equal(t, 1, result.Stats["identity_alias_ips_conflict_skipped"])
742 +}
743 +
744 +func TestBuildL2ResultFromObservations_SkipsAmbiguousMACAliasOwnership(t *testing.T) {
745 + observations := []L2Observation{
746 + {
747 + DeviceID: "switch-a",
748 + Hostname: "switch-a",
749 + ManagementIP: "10.0.0.1",
750 + ChassisID: "00:11:22:33:44:55",
751 + Interfaces: []ObservedInterface{
752 + {IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
753 + },
754 + },
755 + {
756 + DeviceID: "switch-b",
757 + Hostname: "switch-b",
758 + ManagementIP: "10.0.0.2",
759 + ChassisID: "00:11:22:33:44:66",
760 + Interfaces: []ObservedInterface{
761 + {IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
762 + },
763 + },
764 + {
765 + DeviceID: "observer",
766 + Hostname: "observer",
767 + ARPNDEntries: []ARPNDObservation{
768 + {
769 + Protocol: "arp",
770 + IfIndex: 9,
771 + IfName: "Gi0/9",
772 + IP: "10.0.0.50",
773 + MAC: "aa:aa:aa:aa:aa:aa",
774 + State: "reachable",
775 + AddrType: "ipv4",
776 + },
777 + },
778 + },
779 + }
780 +
781 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableARP: true})
782 + require.NoError(t, err)
783 +
784 + switchA := findDeviceByID(result.Devices, "switch-a")
785 + switchB := findDeviceByID(result.Devices, "switch-b")
786 + require.NotNil(t, switchA)
787 + require.NotNil(t, switchB)
788 + require.NotContains(t, deviceAddressStrings(*switchA), "10.0.0.50")
789 + require.NotContains(t, deviceAddressStrings(*switchB), "10.0.0.50")
790 + require.Equal(t, 0, result.Stats["identity_alias_ips_merged"])
791 + require.Equal(t, 1, result.Stats["identity_alias_endpoints_ambiguous_mac"])
792 +}
793 +
794 +func TestNormalizeMAC_PadsSingleNibbleTokens(t *testing.T) {
795 + require.Equal(t, "00:15:99:9f:07:ef", normalizeMAC("0:15:99:9f:7:ef"))
796 + require.Equal(t, "60:33:4b:08:17:a8", normalizeMAC("60:33:4b:8:17:a8"))
797 + require.Equal(t, "00:90:1a:42:22:f8", normalizeMAC("0:90:1a:42:22:f8"))
798 + require.Equal(t, "00:11:22:33:44:55", normalizeMAC("0011.2233.4455"))
799 +}
800 +
801 +func TestBuildL2ResultFromObservations_DeterministicOrderingAndDedup(t *testing.T) {
802 + observations := []L2Observation{
803 + {
804 + DeviceID: "switch-a",
805 + Hostname: "switch-a",
806 + Interfaces: []ObservedInterface{
807 + {IfIndex: 7, IfName: "Port7", IfDescr: "Port7"},
808 + },
809 + BridgePorts: []BridgePortObservation{
810 + {BasePort: "2", IfIndex: 7},
811 + },
812 + FDBEntries: []FDBObservation{
813 + {MAC: "70:49:a2:65:72:ce", BridgePort: "2"},
814 + {MAC: "70:49:a2:65:72:cd", BridgePort: "2"},
815 + {MAC: "7049a26572ce", BridgePort: "2"}, // duplicate MAC, different format
816 + },
817 + ARPNDEntries: []ARPNDObservation{
818 + {Protocol: "arp", IfIndex: 7, IfName: "Port7", IP: "10.20.4.86", MAC: "70:49:a2:65:72:ce"},
819 + {Protocol: "arp", IfIndex: 7, IfName: "Port7", IP: "10.20.4.85", MAC: "70:49:a2:65:72:ce"},
820 + {Protocol: "arp", IfIndex: 7, IfName: "Port7", IP: "10.20.4.85", MAC: "7049a26572ce"}, // duplicate
821 + },
822 + },
823 + }
824 +
825 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true, EnableARP: true})
826 + require.NoError(t, err)
827 +
828 + require.Len(t, result.Attachments, 2)
829 + require.Equal(t, "mac:70:49:a2:65:72:cd", result.Attachments[0].EndpointID)
830 + require.Equal(t, "mac:70:49:a2:65:72:ce", result.Attachments[1].EndpointID)
831 +
832 + require.Len(t, result.Enrichments, 1)
833 + require.Equal(t, "mac:70:49:a2:65:72:ce", result.Enrichments[0].EndpointID)
834 + require.Len(t, result.Enrichments[0].IPs, 2)
835 + require.Equal(t, "10.20.4.85", result.Enrichments[0].IPs[0].String())
836 + require.Equal(t, "10.20.4.86", result.Enrichments[0].IPs[1].String())
837 +}
838 +
839 +func TestMatchLLDPLinksEnlinkdPassOrder_Precedence(t *testing.T) {
840 + links := []lldpMatchLink{
841 + {
842 + index: 0,
843 + sourceDeviceID: "node-a",
844 + localChassisID: "chassis-a",
845 + remoteChassisID: "chassis-b",
846 + localSysName: "node-a",
847 + remoteSysName: "node-b",
848 + localPortID: "Gi0/1",
849 + localPortIDSubtype: "5",
850 + remotePortID: "Gi0/2",
851 + remotePortIDSubtype: "5",
852 + localPortDescr: "GigabitEthernet0/1",
853 + remotePortDescr: "GigabitEthernet0/2",
854 + },
855 + {
856 + index: 1,
857 + sourceDeviceID: "node-b",
858 + localChassisID: "chassis-b",
859 + remoteChassisID: "chassis-a",
860 + localSysName: "node-b",
861 + remoteSysName: "node-a",
862 + localPortID: "Gi0/2",
863 + localPortIDSubtype: "5",
864 + remotePortID: "Gi0/1",
865 + remotePortIDSubtype: "5",
866 + localPortDescr: "GigabitEthernet0/2",
867 + remotePortDescr: "GigabitEthernet0/1",
868 + },
869 + }
870 +
871 + pairs := matchLLDPLinksEnlinkdPassOrder(links)
872 + require.Len(t, pairs, 1)
873 + require.Equal(t, 0, pairs[0].sourceIndex)
874 + require.Equal(t, 1, pairs[0].targetIndex)
875 + require.NotEmpty(t, pairs[0].pass)
876 +}
877 +
878 +func TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses(t *testing.T) {
879 + tests := []struct {
880 + name string
881 + pass string
882 + left, right lldpMatchLink
883 + }{
884 + {
885 + name: "port-description",
886 + pass: lldpMatchPassPortDesc,
887 + left: lldpMatchLink{
888 + index: 0,
889 + sourceDeviceID: "a",
890 + localChassisID: "A",
891 + remoteChassisID: "B",
892 + localSysName: "a",
893 + remoteSysName: "b",
894 + localPortID: "Gi0/1",
895 + localPortIDSubtype: "5",
896 + remotePortID: "wrong-b",
897 + remotePortIDSubtype: "5",
898 + localPortDescr: "PORT-A",
899 + remotePortDescr: "PORT-B",
900 + },
901 + right: lldpMatchLink{
902 + index: 1,
903 + sourceDeviceID: "b",
904 + localChassisID: "B",
905 + remoteChassisID: "A",
906 + localSysName: "b",
907 + remoteSysName: "a",
908 + localPortID: "Gi0/2",
909 + localPortIDSubtype: "5",
910 + remotePortID: "wrong-a",
911 + remotePortIDSubtype: "5",
912 + localPortDescr: "PORT-B",
913 + remotePortDescr: "PORT-A",
914 + },
915 + },
916 + {
917 + name: "sysname",
918 + pass: lldpMatchPassSysName,
919 + left: lldpMatchLink{
920 + index: 0,
921 + sourceDeviceID: "a",
922 + localChassisID: "A-LOCAL",
923 + remoteChassisID: "B-REMOTE",
924 + localSysName: "sys-a",
925 + remoteSysName: "sys-b",
926 + localPortID: "xe-0/0/1",
927 + localPortIDSubtype: "5",
928 + remotePortID: "xe-0/0/2",
929 + remotePortIDSubtype: "5",
930 + localPortDescr: "left-a",
931 + remotePortDescr: "left-b",
932 + },
933 + right: lldpMatchLink{
934 + index: 1,
935 + sourceDeviceID: "b",
936 + localChassisID: "B-LOCAL",
937 + remoteChassisID: "A-REMOTE",
938 + localSysName: "sys-b",
939 + remoteSysName: "sys-a",
940 + localPortID: "xe-0/0/2",
941 + localPortIDSubtype: "5",
942 + remotePortID: "xe-0/0/1",
943 + remotePortIDSubtype: "5",
944 + localPortDescr: "right-b",
945 + remotePortDescr: "right-a",
946 + },
947 + },
948 + {
949 + name: "chassis-port-subtype",
950 + pass: lldpMatchPassChassisPort,
951 + left: lldpMatchLink{
952 + index: 0,
953 + sourceDeviceID: "a",
954 + localChassisID: "A",
955 + remoteChassisID: "B",
956 + localSysName: "a-local",
957 + remoteSysName: "b-remote",
958 + localPortID: "A1",
959 + localPortIDSubtype: "5",
960 + remotePortID: "wrong-b",
961 + remotePortIDSubtype: "5",
962 + localPortDescr: "DA",
963 + remotePortDescr: "RA",
964 + },
965 + right: lldpMatchLink{
966 + index: 1,
967 + sourceDeviceID: "b",
968 + localChassisID: "B",
969 + remoteChassisID: "A",
970 + localSysName: "b-local",
971 + remoteSysName: "a-remote",
972 + localPortID: "B1",
973 + localPortIDSubtype: "5",
974 + remotePortID: "A1",
975 + remotePortIDSubtype: "5",
976 + localPortDescr: "DB",
977 + remotePortDescr: "RB",
978 + },
979 + },
980 + {
981 + name: "chassis-port-description",
982 + pass: lldpMatchPassChassisDescr,
983 + left: lldpMatchLink{
984 + index: 0,
985 + sourceDeviceID: "a",
986 + localChassisID: "A",
987 + remoteChassisID: "B",
988 + localSysName: "a-local",
989 + remoteSysName: "b-remote",
990 + localPortID: "A1",
991 + localPortIDSubtype: "5",
992 + remotePortID: "wrong-b",
993 + remotePortIDSubtype: "5",
994 + localPortDescr: "PORT-A",
995 + remotePortDescr: "REMOTE-A",
996 + },
997 + right: lldpMatchLink{
998 + index: 1,
999 + sourceDeviceID: "b",
1000 + localChassisID: "B",
1001 + remoteChassisID: "A",
1002 + localSysName: "b-local",
1003 + remoteSysName: "a-remote",
1004 + localPortID: "B1",
1005 + localPortIDSubtype: "5",
1006 + remotePortID: "wrong-a",
1007 + remotePortIDSubtype: "5",
1008 + localPortDescr: "PORT-B",
1009 + remotePortDescr: "PORT-A",
1010 + },
1011 + },
1012 + {
1013 + name: "chassis-only",
1014 + pass: lldpMatchPassChassis,
1015 + left: lldpMatchLink{
1016 + index: 0,
1017 + sourceDeviceID: "a",
1018 + localChassisID: "A",
1019 + remoteChassisID: "B",
1020 + localSysName: "a-local",
1021 + remoteSysName: "b-remote",
1022 + localPortID: "A1",
1023 + localPortIDSubtype: "5",
1024 + remotePortID: "wrong-b",
1025 + remotePortIDSubtype: "5",
1026 + localPortDescr: "PORT-A",
1027 + remotePortDescr: "REMOTE-A",
1028 + },
1029 + right: lldpMatchLink{
1030 + index: 1,
1031 + sourceDeviceID: "b",
1032 + localChassisID: "B",
1033 + remoteChassisID: "A",
1034 + localSysName: "b-local",
1035 + remoteSysName: "a-remote",
1036 + localPortID: "B1",
1037 + localPortIDSubtype: "5",
1038 + remotePortID: "wrong-a",
1039 + remotePortIDSubtype: "5",
1040 + localPortDescr: "PORT-B",
1041 + remotePortDescr: "REMOTE-B",
1042 + },
1043 + },
1044 + }
1045 +
1046 + for _, tc := range tests {
1047 + t.Run(tc.name, func(t *testing.T) {
1048 + pairs := matchLLDPLinksEnlinkdPassOrder([]lldpMatchLink{tc.left, tc.right})
1049 + require.Len(t, pairs, 1)
1050 + require.Equal(t, 0, pairs[0].sourceIndex)
1051 + require.Equal(t, 1, pairs[0].targetIndex)
1052 + require.Equal(t, tc.pass, pairs[0].pass)
1053 + })
1054 + }
1055 +}
1056 +
1057 +func TestBuildL2ResultFromObservations_LLDPPairsAcrossChassisRepresentations(t *testing.T) {
1058 + observations := []L2Observation{
1059 + {
1060 + DeviceID: "router-a",
1061 + Hostname: "MikroTik-router",
1062 + ManagementIP: "10.20.4.1",
1063 + ChassisID: "18:FD:74:7E:C5:80",
1064 + LLDPRemotes: []LLDPRemoteObservation{
1065 + {
1066 + LocalPortNum: "3",
1067 + LocalPortID: "ether3",
1068 + LocalPortIDSubtype: "interfaceName",
1069 + ChassisID: "7049A26572CD",
1070 + PortID: "",
1071 + PortIDSubtype: "interfaceName",
1072 + SysName: "XS1930",
1073 + ManagementIP: "10.20.4.84",
1074 + },
1075 + },
1076 + },
1077 + {
1078 + DeviceID: "switch-b",
1079 + Hostname: "XS1930",
1080 + ManagementIP: "10.20.4.84",
1081 + ChassisID: "70:49:a2:65:72:cd",
1082 + LLDPRemotes: []LLDPRemoteObservation{
1083 + {
1084 + LocalPortNum: "8",
1085 + LocalPortID: "8",
1086 + LocalPortIDSubtype: "local",
1087 + ChassisID: "18fd747ec580",
1088 + PortID: "ether3",
1089 + PortIDSubtype: "interfaceName",
1090 + SysName: "MikroTik-router",
1091 + ManagementIP: "10.20.4.1",
1092 + },
1093 + },
1094 + },
1095 + }
1096 +
1097 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true})
1098 + require.NoError(t, err)
1099 + require.Len(t, result.Adjacencies, 2)
1100 +
1101 + var pairID string
1102 + for _, adj := range result.Adjacencies {
1103 + require.Equal(t, "lldp", adj.Protocol)
1104 + require.NotEmpty(t, adj.Labels[adjacencyLabelPairID])
1105 + require.NotEmpty(t, adj.Labels[adjacencyLabelPairPass])
1106 + if pairID == "" {
1107 + pairID = adj.Labels[adjacencyLabelPairID]
1108 + }
1109 + require.Equal(t, pairID, adj.Labels[adjacencyLabelPairID])
1110 + }
1111 +}
1112 +
1113 +func TestBuildL2ResultFromObservations_LLDPPairsAcrossKnownDeviceIdentityDespiteChassisMismatch(t *testing.T) {
1114 + observations := []L2Observation{
1115 + {
1116 + DeviceID: "mikrotik-router",
1117 + Hostname: "MikroTik-router",
1118 + ManagementIP: "10.20.4.1",
1119 + ChassisID: "18:FD:74:7E:C5:80",
1120 + LLDPRemotes: []LLDPRemoteObservation{
1121 + {
1122 + LocalPortNum: "3",
1123 + LocalPortID: "ether3",
1124 + LocalPortIDSubtype: "interfaceName",
1125 + ChassisID: "70:49:A2:65:72:D5",
1126 + PortID: "",
1127 + PortIDSubtype: "interfaceName",
1128 + SysName: "XS1930",
1129 + ManagementIP: "10.20.4.84",
1130 + },
1131 + },
1132 + },
1133 + {
1134 + DeviceID: "xs1930",
1135 + Hostname: "XS1930",
1136 + ManagementIP: "10.20.4.84",
1137 + ChassisID: "70:49:A2:65:72:CD",
1138 + LLDPRemotes: []LLDPRemoteObservation{
1139 + {
1140 + LocalPortNum: "8",
1141 + LocalPortID: "8",
1142 + LocalPortIDSubtype: "local",
1143 + ChassisID: "18:FD:74:7E:C5:80",
1144 + PortID: "ether3",
1145 + PortIDSubtype: "interfaceName",
1146 + SysName: "MikroTik-router",
1147 + ManagementIP: "10.20.4.1",
1148 + },
1149 + },
1150 + },
1151 + }
1152 +
1153 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true})
1154 + require.NoError(t, err)
1155 + require.Len(t, result.Adjacencies, 2)
1156 +
1157 + for _, adj := range result.Adjacencies {
1158 + require.Equal(t, "lldp", adj.Protocol)
1159 + require.NotEmpty(t, adj.Labels[adjacencyLabelPairID])
1160 + require.NotEmpty(t, adj.Labels[adjacencyLabelPairPass])
1161 + }
1162 +}
1163 +
1164 +func TestBuildL2ResultFromObservations_KeepsDistinctRemotesWhenMACDiffersDespiteSameSecondaryIdentity(t *testing.T) {
1165 + observations := []L2Observation{
1166 + {
1167 + DeviceID: "local-a",
1168 + Hostname: "local-a",
1169 + ManagementIP: "10.0.0.1",
1170 + ChassisID: "00:00:00:00:10:01",
1171 + LLDPRemotes: []LLDPRemoteObservation{
1172 + {
1173 + LocalPortNum: "1",
1174 + LocalPortID: "eth1",
1175 + LocalPortIDSubtype: "interfaceName",
1176 + ChassisID: "00:11:22:33:44:55",
1177 + SysName: "shared-secondary-id",
1178 + ManagementIP: "10.20.30.40",
1179 + },
1180 + },
1181 + },
1182 + {
1183 + DeviceID: "local-b",
1184 + Hostname: "local-b",
1185 + ManagementIP: "10.0.0.2",
1186 + ChassisID: "00:00:00:00:10:02",
1187 + LLDPRemotes: []LLDPRemoteObservation{
1188 + {
1189 + LocalPortNum: "1",
1190 + LocalPortID: "eth1",
1191 + LocalPortIDSubtype: "interfaceName",
1192 + ChassisID: "00:11:22:33:44:66",
1193 + SysName: "shared-secondary-id",
1194 + ManagementIP: "10.20.30.40",
1195 + },
1196 + },
1197 + },
1198 + }
1199 +
1200 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true})
1201 + require.NoError(t, err)
1202 +
1203 + var remoteCount int
1204 + remoteIDs := make(map[string]struct{})
1205 + for _, device := range result.Devices {
1206 + if device.Hostname != "shared-secondary-id" {
1207 + continue
1208 + }
1209 + remoteCount++
1210 + remoteIDs[device.ID] = struct{}{}
1211 + }
1212 + require.Equal(t, 2, remoteCount)
1213 + require.Len(t, remoteIDs, 2)
1214 +}
1215 +
1216 +func TestMatchLLDPLinksEnlinkdPassOrder_DoesNotDropLinksWhenSysNamesAreEmpty(t *testing.T) {
1217 + links := []lldpMatchLink{
1218 + {
1219 + index: 0,
1220 + localChassisID: "00:11:22:33:44:55",
1221 + localMatchID: "device-a",
1222 + localPortID: "Gi0/1",
1223 + localPortIDSubtype: "interfaceName",
1224 + remoteChassisID: "00:11:22:33:44:66",
1225 + remotePortID: "Gi0/2",
1226 + remotePortIDSubtype: "interfaceName",
1227 + },
1228 + {
1229 + index: 1,
1230 + localChassisID: "00:11:22:33:44:66",
1231 + localMatchID: "device-b",
1232 + localPortID: "Gi0/2",
1233 + localPortIDSubtype: "interfaceName",
1234 + remoteChassisID: "00:11:22:33:44:55",
1235 + remotePortID: "Gi0/1",
1236 + remotePortIDSubtype: "interfaceName",
1237 + },
1238 + }
1239 +
1240 + pairs := matchLLDPLinksEnlinkdPassOrder(links)
1241 +
1242 + require.Len(t, pairs, 1)
1243 + require.Equal(t, 0, pairs[0].sourceIndex)
1244 + require.Equal(t, 1, pairs[0].targetIndex)
1245 + require.NotEmpty(t, pairs[0].pass)
1246 +}
1247 +
1248 +func TestResolveKnownRemote_RejectsHostnameMatchWhenMACMismatchesWithoutMgmtIP(t *testing.T) {
1249 + state := newL2BuildState(1)
1250 + state.devices["known-device"] = Device{
1251 + ID: "known-device",
1252 + Hostname: "shared-host",
1253 + ChassisID: "00:11:22:33:44:55",
1254 + }
1255 + state.hostToID["shared-host"] = "known-device"
1256 +
1257 + require.Empty(t, state.resolveKnownRemote("shared-host", "", "", "00:11:22:33:44:66"))
1258 +}
1259 +
1260 +func TestResolveRemote_UsesMACDerivedIDWhenManagedHostnameCollidesWithoutMgmtIP(t *testing.T) {
1261 + state := newL2BuildState(1)
1262 + state.devices["known-device"] = Device{
1263 + ID: "known-device",
1264 + Hostname: "shared-host",
1265 + ChassisID: "00:11:22:33:44:55",
1266 + }
1267 + state.managedObservationByDeviceID["known-device"] = true
1268 + state.hostToID["shared-host"] = "known-device"
1269 +
1270 + id := state.resolveRemote("shared-host", "00:11:22:33:44:66", "", "")
1271 +
1272 + require.Equal(t, "chassis-001122334466", id)
1273 + require.Equal(t, "known-device", state.hostToID["shared-host"])
1274 + require.Equal(t, id, state.macToID["00:11:22:33:44:66"])
1275 +}
1276 +
1277 +func TestResolveRemote_ReusesHostnameIDForUnmanagedRemoteCollisions(t *testing.T) {
1278 + state := newL2BuildState(1)
1279 +
1280 + firstID := state.resolveRemote("shared-host", "00:11:22:33:44:55", "", "")
1281 + secondID := state.resolveRemote("shared-host", "00:11:22:33:44:66", "", "")
1282 +
1283 + require.Equal(t, "shared-host", firstID)
1284 + require.Equal(t, "shared-host", secondID)
1285 + require.Equal(t, "shared-host", state.hostToID["shared-host"])
1286 +}
1287 +
1288 +func TestResolveRemoteEnforcingHostnameMACGuard_SplitsUnmanagedHostnameCollisions(t *testing.T) {
1289 + state := newL2BuildState(1)
1290 +
1291 + firstID := state.resolveRemoteEnforcingHostnameMACGuard("shared-host", "00:11:22:33:44:55", "", "")
1292 + secondID := state.resolveRemoteEnforcingHostnameMACGuard("shared-host", "00:11:22:33:44:66", "", "")
1293 +
1294 + require.Equal(t, "shared-host", firstID)
1295 + require.Equal(t, "chassis-001122334466", secondID)
1296 + require.Equal(t, "shared-host", state.hostToID["shared-host"])
1297 + require.Equal(t, secondID, state.macToID["00:11:22:33:44:66"])
1298 +}
1299 +
1300 +func TestRegisterObservation_ReinitializesLabelsAfterEmptyMerge(t *testing.T) {
1301 + state := newL2BuildState(1)
1302 + state.devices["switch-a"] = Device{ID: "switch-a", Hostname: "switch-a"}
1303 +
1304 + err := state.registerObservation(L2Observation{
1305 + DeviceID: "switch-a",
1306 + LLDPRemotes: []LLDPRemoteObservation{
1307 + {LocalPortNum: "1", ChassisID: "00:11:22:33:44:55"},
1308 + },
1309 + })
1310 +
1311 + require.NoError(t, err)
1312 + require.Equal(t, "lldp", state.devices["switch-a"].Labels["protocols_observed"])
1313 +}
1314 +
1315 +func TestMatchLLDPLinksEnlinkdPassOrder_SkipsEmptyPortDescriptions(t *testing.T) {
1316 + links := []lldpMatchLink{
1317 + {
1318 + index: 0,
1319 + sourceDeviceID: "a",
1320 + localChassisID: "A",
1321 + remoteChassisID: "B",
1322 + localPortID: "A-1",
1323 + localPortIDSubtype: "5",
1324 + remotePortID: "wrong-b-1",
1325 + remotePortIDSubtype: "5",
1326 + },
1327 + {
1328 + index: 1,
1329 + sourceDeviceID: "a",
1330 + localChassisID: "A",
1331 + remoteChassisID: "B",
1332 + localPortID: "A-2",
1333 + localPortIDSubtype: "5",
1334 + remotePortID: "wrong-b-2",
1335 + remotePortIDSubtype: "5",
1336 + },
1337 + {
1338 + index: 2,
1339 + sourceDeviceID: "b",
1340 + localChassisID: "B",
1341 + remoteChassisID: "A",
1342 + localPortID: "B-2",
1343 + localPortIDSubtype: "5",
1344 + remotePortID: "A-2",
1345 + remotePortIDSubtype: "5",
1346 + },
1347 + {
1348 + index: 3,
1349 + sourceDeviceID: "b",
1350 + localChassisID: "B",
1351 + remoteChassisID: "A",
1352 + localPortID: "B-1",
1353 + localPortIDSubtype: "5",
1354 + remotePortID: "A-1",
1355 + remotePortIDSubtype: "5",
1356 + },
1357 + }
1358 +
1359 + pairs := matchLLDPLinksEnlinkdPassOrder(links)
1360 +
1361 + require.Len(t, pairs, 2)
1362 + require.Equal(t, lldpMatchPassChassisPort, pairs[0].pass)
1363 + require.Equal(t, 0, pairs[0].sourceIndex)
1364 + require.Equal(t, 3, pairs[0].targetIndex)
1365 + require.Equal(t, lldpMatchPassChassisPort, pairs[1].pass)
1366 + require.Equal(t, 1, pairs[1].sourceIndex)
1367 + require.Equal(t, 2, pairs[1].targetIndex)
1368 +}
1369 +
1370 +func TestMatchCDPLinksEnlinkdPassOrder_DefaultAndParsedTarget(t *testing.T) {
1371 + links := []cdpMatchLink{
1372 + {
1373 + index: 0,
1374 + sourceDeviceID: "node-a",
1375 + sourceGlobalID: "A-GID",
1376 + localInterfaceName: "Gi0/0",
1377 + remoteDeviceID: "B-GID",
1378 + remoteDevicePort: "Gi0/1",
1379 + },
1380 + {
1381 + index: 1,
1382 + sourceDeviceID: "node-b",
1383 + sourceGlobalID: "B-GID",
1384 + localInterfaceName: "Gi0/1",
1385 + remoteDeviceID: "A-GID",
1386 + remoteDevicePort: "Gi0/0",
1387 + },
1388 + {
1389 + index: 2,
1390 + sourceDeviceID: "node-c",
1391 + sourceGlobalID: "A-GID",
1392 + localInterfaceName: "Gi0/0",
1393 + remoteDeviceID: "B-GID",
1394 + remoteDevicePort: "Gi0/1",
1395 + },
1396 + }
1397 +
1398 + pairs := matchCDPLinksEnlinkdPassOrder(links)
1399 + require.Len(t, pairs, 1)
1400 + require.Equal(t, 0, pairs[0].sourceIndex)
1401 + require.Equal(t, 1, pairs[0].targetIndex)
1402 + require.Equal(t, cdpMatchPassDefault, pairs[0].pass)
1403 +}
1404 +
1405 +func TestBuildCDPLookupMap_PreservesFirstDuplicateKey(t *testing.T) {
1406 + links := []cdpMatchLink{
1407 + {
1408 + index: 0,
1409 + sourceGlobalID: "A-GID",
1410 + localInterfaceName: "Gi0/0",
1411 + remoteDeviceID: "B-GID",
1412 + remoteDevicePort: "Gi0/1",
1413 + },
1414 + {
1415 + index: 1,
1416 + sourceGlobalID: "A-GID",
1417 + localInterfaceName: "Gi0/0",
1418 + remoteDeviceID: "B-GID",
1419 + remoteDevicePort: "Gi0/1",
1420 + },
1421 + }
1422 +
1423 + lookup := buildCDPLookupMap(links)
1424 + key := topologyMatchCompositeKey("Gi0/1", "Gi0/0", "A-GID", "B-GID")
1425 + value, ok := lookup[key]
1426 + require.True(t, ok)
1427 + require.Equal(t, 0, value)
1428 +}
1429 +
1430 +func TestMatchCDPLinksEnlinkdPassOrder_SkipsSelfTarget(t *testing.T) {
1431 + links := []cdpMatchLink{
1432 + {
1433 + index: 0,
1434 + sourceDeviceID: "node-a",
1435 + sourceGlobalID: "A-GID",
1436 + localInterfaceName: "Gi0/0",
1437 + remoteDeviceID: "A-GID",
1438 + remoteDevicePort: "Gi0/0",
1439 + },
1440 + }
1441 +
1442 + pairs := matchCDPLinksEnlinkdPassOrder(links)
1443 + require.Empty(t, pairs)
1444 +}
1445 +
1446 +func TestBuildL2ResultFromObservations_AnnotatesPairMetadata(t *testing.T) {
1447 + observations := []L2Observation{
1448 + {
1449 + DeviceID: "switch-a",
1450 + Hostname: "A-GID",
1451 + ManagementIP: "10.0.0.1",
1452 + ChassisID: "aa:aa:aa:aa:aa:aa",
1453 + Interfaces: []ObservedInterface{
1454 + {IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1455 + },
1456 + LLDPRemotes: []LLDPRemoteObservation{
1457 + {
1458 + LocalPortNum: "1",
1459 + RemoteIndex: "1",
1460 + LocalPortID: "Gi0/1",
1461 + LocalPortIDSubtype: "5",
1462 + ChassisID: "bb:bb:bb:bb:bb:bb",
1463 + SysName: "B-GID",
1464 + PortID: "Gi0/2",
1465 + PortIDSubtype: "5",
1466 + },
1467 + },
1468 + CDPRemotes: []CDPRemoteObservation{
1469 + {
1470 + LocalIfIndex: 1,
1471 + LocalIfName: "Gi0/1",
1472 + DeviceIndex: "1",
1473 + DeviceID: "B-GID",
1474 + SysName: "B-GID",
1475 + DevicePort: "Gi0/2",
1476 + },
1477 + },
1478 + },
1479 + {
1480 + DeviceID: "switch-b",
1481 + Hostname: "B-GID",
1482 + ManagementIP: "10.0.0.2",
1483 + ChassisID: "bb:bb:bb:bb:bb:bb",
1484 + Interfaces: []ObservedInterface{
1485 + {IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1486 + },
1487 + LLDPRemotes: []LLDPRemoteObservation{
1488 + {
1489 + LocalPortNum: "2",
1490 + RemoteIndex: "1",
1491 + LocalPortID: "Gi0/2",
1492 + LocalPortIDSubtype: "5",
1493 + ChassisID: "aa:aa:aa:aa:aa:aa",
1494 + SysName: "A-GID",
1495 + PortID: "Gi0/1",
1496 + PortIDSubtype: "5",
1497 + },
1498 + },
1499 + CDPRemotes: []CDPRemoteObservation{
1500 + {
1501 + LocalIfIndex: 2,
1502 + LocalIfName: "Gi0/2",
1503 + DeviceIndex: "1",
1504 + DeviceID: "A-GID",
1505 + SysName: "A-GID",
1506 + DevicePort: "Gi0/1",
1507 + },
1508 + },
1509 + },
1510 + }
1511 +
1512 + result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true, EnableCDP: true})
1513 + require.NoError(t, err)
1514 + require.Len(t, result.Adjacencies, 4)
1515 +
1516 + pairIDs := make(map[string]map[string]struct{})
1517 + pairPasses := make(map[string]string)
1518 + for _, adj := range result.Adjacencies {
1519 + if adj.Protocol != "lldp" && adj.Protocol != "cdp" {
1520 + continue
1521 + }
1522 + pairID := adj.Labels[adjacencyLabelPairID]
1523 + pairPass := adj.Labels[adjacencyLabelPairPass]
1524 +
1525 + require.NotEmpty(t, pairID)
1526 + require.NotEmpty(t, pairPass)
1527 +
1528 + if pairIDs[adj.Protocol] == nil {
1529 + pairIDs[adj.Protocol] = make(map[string]struct{})
1530 + }
1531 + pairIDs[adj.Protocol][pairID] = struct{}{}
1532 +
1533 + if existingPass, ok := pairPasses[adj.Protocol]; ok {
1534 + require.Equal(t, existingPass, pairPass)
1535 + } else {
1536 + pairPasses[adj.Protocol] = pairPass
1537 + }
1538 + }
1539 +
1540 + require.Len(t, pairIDs["lldp"], 1)
1541 + require.Len(t, pairIDs["cdp"], 1)
1542 + require.Equal(t, lldpMatchPassDefault, pairPasses["lldp"])
1543 + require.Equal(t, cdpMatchPassDefault, pairPasses["cdp"])
1544 +}
1545 +
1546 +func TestBuildL2ResultFromObservations_ErrorsOnEmptyInput(t *testing.T) {
1547 + _, err := BuildL2ResultFromObservations(nil, DiscoverOptions{EnableLLDP: true})
1548 + require.Error(t, err)
1549 + require.ErrorIs(t, err, ErrInvalidRequest)
1550 +}
1551 +
1552 +func findDeviceByHostname(devices []Device, hostname string) *Device {
1553 + for i := range devices {
1554 + if devices[i].Hostname == hostname {
1555 + return &devices[i]
1556 + }
1557 + }
1558 + return nil
1559 +}
1560 +
1561 +func findDeviceByID(devices []Device, id string) *Device {
1562 + for i := range devices {
1563 + if devices[i].ID == id {
1564 + return &devices[i]
1565 + }
1566 + }
1567 + return nil
1568 +}
1569 +
1570 +func deviceAddressStrings(device Device) []string {
1571 + out := make([]string, 0, len(device.Addresses))
1572 + for _, addr := range device.Addresses {
1573 + if !addr.IsValid() {
1574 + continue
1575 + }
1576 + out = append(out, addr.String())
1577 + }
1578 + return out
1579 +}
src/go/pkg/topology/engine/mac_oui_lookup.go new
+146
@@ -0,0 +1,146 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + _ "embed"
7 + "sort"
8 + "strings"
9 + "sync"
10 +
11 + "github.com/netdata/netdata/go/plugins/pkg/topology"
12 +)
13 +
14 +//go:embed mac_oui_vendors.tsv
15 +var macOUIVendorsTSV string
16 +
17 +type topologyOUIVendorIndex struct {
18 + byPrefixLen map[int]map[string]string
19 + prefixLens []int
20 +}
21 +
22 +var (
23 + topologyOUIVendorsOnce sync.Once
24 + topologyOUIVendorsIndex topologyOUIVendorIndex
25 +)
26 +
27 +func loadTopologyOUIVendorsIndex() topologyOUIVendorIndex {
28 + topologyOUIVendorsOnce.Do(func() {
29 + topologyOUIVendorsIndex = buildTopologyOUIVendorIndex(macOUIVendorsTSV)
30 + })
31 + return topologyOUIVendorsIndex
32 +}
33 +
34 +func buildTopologyOUIVendorIndex(tsv string) topologyOUIVendorIndex {
35 + byPrefixLen := make(map[int]map[string]string)
36 + for line := range strings.SplitSeq(tsv, "\n") {
37 + line = strings.TrimSpace(line)
38 + if line == "" || strings.HasPrefix(line, "#") {
39 + continue
40 + }
41 + prefix, vendor, ok := strings.Cut(line, "\t")
42 + if !ok {
43 + continue
44 + }
45 + prefix = strings.ToUpper(strings.TrimSpace(prefix))
46 + vendor = strings.TrimSpace(vendor)
47 + if prefix == "" || vendor == "" {
48 + continue
49 + }
50 + if len(prefix) < 6 || len(prefix) > 12 {
51 + continue
52 + }
53 + if !isHexToken(prefix) {
54 + continue
55 + }
56 + if byPrefixLen[len(prefix)] == nil {
57 + byPrefixLen[len(prefix)] = make(map[string]string)
58 + }
59 + if _, exists := byPrefixLen[len(prefix)][prefix]; exists {
60 + continue
61 + }
62 + byPrefixLen[len(prefix)][prefix] = vendor
63 + }
64 +
65 + prefixLens := make([]int, 0, len(byPrefixLen))
66 + for prefixLen := range byPrefixLen {
67 + prefixLens = append(prefixLens, prefixLen)
68 + }
69 + sort.Slice(prefixLens, func(i, j int) bool {
70 + return prefixLens[i] > prefixLens[j]
71 + })
72 + return topologyOUIVendorIndex{
73 + byPrefixLen: byPrefixLen,
74 + prefixLens: prefixLens,
75 + }
76 +}
77 +
78 +func isHexToken(value string) bool {
79 + if value == "" {
80 + return false
81 + }
82 + for _, r := range value {
83 + if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'F') || (r >= 'a' && r <= 'f') {
84 + continue
85 + }
86 + return false
87 + }
88 + return true
89 +}
90 +
91 +func lookupTopologyVendorByMAC(mac string) (vendor string, prefix string) {
92 + return lookupTopologyVendorByMACInIndex(loadTopologyOUIVendorsIndex(), mac)
93 +}
94 +
95 +func lookupTopologyVendorByMACInIndex(index topologyOUIVendorIndex, mac string) (vendor string, prefix string) {
96 + mac = normalizeMAC(mac)
97 + if mac == "" {
98 + return "", ""
99 + }
100 + hex := strings.ToUpper(strings.ReplaceAll(mac, ":", ""))
101 + if hex == "" {
102 + return "", ""
103 + }
104 +
105 + for _, prefixLen := range index.prefixLens {
106 + if len(hex) < prefixLen {
107 + continue
108 + }
109 + candidatePrefix := hex[:prefixLen]
110 + candidateVendor, ok := index.byPrefixLen[prefixLen][candidatePrefix]
111 + if !ok {
112 + continue
113 + }
114 + return candidateVendor, candidatePrefix
115 + }
116 + return "", ""
117 +}
118 +
119 +func inferTopologyVendorFromMatch(match topology.Match) (vendor string, prefix string) {
120 + candidates := make(map[string]struct{}, len(match.MacAddresses)+len(match.ChassisIDs))
121 + for _, value := range match.MacAddresses {
122 + if mac := normalizeMAC(value); mac != "" {
123 + candidates[mac] = struct{}{}
124 + }
125 + }
126 + for _, value := range match.ChassisIDs {
127 + if mac := normalizeMAC(value); mac != "" {
128 + candidates[mac] = struct{}{}
129 + }
130 + }
131 + if len(candidates) == 0 {
132 + return "", ""
133 + }
134 +
135 + macs := make([]string, 0, len(candidates))
136 + for mac := range candidates {
137 + macs = append(macs, mac)
138 + }
139 + sort.Strings(macs)
140 + for _, mac := range macs {
141 + if vendor, prefix := lookupTopologyVendorByMAC(mac); vendor != "" {
142 + return vendor, prefix
143 + }
144 + }
145 + return "", ""
146 +}
src/go/pkg/topology/engine/mac_oui_lookup_test.go new
+66
@@ -0,0 +1,66 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strings"
7 + "testing"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/topology"
10 + "github.com/stretchr/testify/require"
11 +)
12 +
13 +func TestBuildTopologyOUIVendorIndex_IgnoresInvalidLinesAndKeepsFirstDuplicate(t *testing.T) {
14 + index := buildTopologyOUIVendorIndex(strings.Join([]string{
15 + "# comment",
16 + "08EA44\tExtreme Networks Headquarters",
17 + "08EA44\tduplicate should be ignored",
18 + "286FB9\tNokia Shanghai Bell Co., Ltd.",
19 + "08EA4411\tExtreme Specific",
20 + "bad-line-without-tab",
21 + "12345\ttoo short",
22 + "1234567890123\ttoo long",
23 + "GGGGGG\tvalid-length non-hex prefix",
24 + "ABCDEF\t",
25 + "\tNo Prefix",
26 + }, "\n"))
27 +
28 + require.Equal(t, []int{8, 6}, index.prefixLens)
29 + require.Equal(t, "Extreme Networks Headquarters", index.byPrefixLen[6]["08EA44"])
30 + require.Equal(t, "Nokia Shanghai Bell Co., Ltd.", index.byPrefixLen[6]["286FB9"])
31 + require.Equal(t, "Extreme Specific", index.byPrefixLen[8]["08EA4411"])
32 + require.NotContains(t, index.byPrefixLen[6], "GGGGGG")
33 + require.Len(t, index.byPrefixLen[6], 2)
34 +}
35 +
36 +func TestLookupTopologyVendorByMACInIndex_PrefersLongestPrefixAndNormalizesMAC(t *testing.T) {
37 + index := buildTopologyOUIVendorIndex(`
38 +08EA44 Extreme Networks Headquarters
39 +08EA4411 Extreme Specific
40 +`)
41 +
42 + vendor, prefix := lookupTopologyVendorByMACInIndex(index, "08ea.4411.2233")
43 + require.Equal(t, "Extreme Specific", vendor)
44 + require.Equal(t, "08EA4411", prefix)
45 +
46 + vendor, prefix = lookupTopologyVendorByMACInIndex(index, "08-ea-44-aa-bb-cc")
47 + require.Equal(t, "Extreme Networks Headquarters", vendor)
48 + require.Equal(t, "08EA44", prefix)
49 +}
50 +
51 +func TestInferTopologyVendorFromMatch_UsesDeterministicCandidateOrder(t *testing.T) {
52 + match := topology.Match{
53 + MacAddresses: []string{
54 + "28:6f:b9:00:00:22",
55 + "08:ea:44:11:22:33",
56 + },
57 + ChassisIDs: []string{
58 + "28:6f:b9:00:00:22",
59 + "08ea.4411.2233",
60 + },
61 + }
62 +
63 + vendor, prefix := inferTopologyVendorFromMatch(match)
64 + require.Equal(t, "Extreme Networks Headquarters", vendor)
65 + require.Equal(t, "08EA44", prefix)
66 +}
src/go/pkg/topology/engine/node_topology.go new
+35
@@ -0,0 +1,35 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "net/netip"
6 +
7 +// NodeTopologyEntity mirrors the minimum node fields used by Enlinkd node topology logic.
8 +type NodeTopologyEntity struct {
9 + ID int
10 + Label string
11 + SysObject string
12 + SysName string
13 + Address netip.Addr
14 +}
15 +
16 +// IPInterfaceTopologyEntity mirrors the minimum IP interface fields used by Enlinkd node topology logic.
17 +type IPInterfaceTopologyEntity struct {
18 + ID int
19 + NodeID int
20 + IPAddress netip.Addr
21 + NetMask netip.Addr
22 + IsManaged bool
23 + IsSnmpPrimary bool
24 + IfIndex int
25 + SnmpInterfaceID int
26 +}
27 +
28 +// SnmpInterfaceTopologyEntity mirrors the minimum SNMP interface fields used by Enlinkd node topology logic.
29 +type SnmpInterfaceTopologyEntity struct {
30 + ID int
31 + NodeID int
32 + IfIndex int
33 + IfName string
34 + IfDescr string
35 +}
src/go/pkg/topology/engine/node_topology_router.go new
+318
@@ -0,0 +1,318 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strconv"
8 + "strings"
9 +)
10 +
11 +// NetworkRouterTopology mirrors Enlinkd NetworkRouterTopologyUpdater graph payload.
12 +type NetworkRouterTopology struct {
13 + Vertices []NetworkRouterVertex
14 + Edges []NetworkRouterEdge
15 + DefaultVertex string
16 +}
17 +
18 +// NetworkRouterVertex is one node or subnet vertex in the network-router topology.
19 +type NetworkRouterVertex struct {
20 + ID string
21 + Label string
22 + Address string
23 + IconKey string
24 + NodeID int
25 + ToolTip string
26 + IsSubnet bool
27 +}
28 +
29 +// NetworkRouterPort is one endpoint port in the network-router topology.
30 +type NetworkRouterPort struct {
31 + ID string
32 + Vertex string
33 + IPID int
34 + IfIndex int
35 + IfName string
36 + Addr string
37 + ToolTip string
38 +}
39 +
40 +// NetworkRouterEdge is one link in the network-router topology.
41 +type NetworkRouterEdge struct {
42 + ID string
43 + SourcePort NetworkRouterPort
44 + TargetPort NetworkRouterPort
45 +}
46 +
47 +// BuildNetworkRouterTopology ports NetworkRouterTopologyUpdater.buildTopology().
48 +func BuildNetworkRouterTopology(service *NodeTopologyService, ipv4prefix, ipv6prefix int) NetworkRouterTopology {
49 + if service == nil {
50 + return NetworkRouterTopology{}
51 + }
52 +
53 + result := NetworkRouterTopology{}
54 + vertexByID := make(map[string]NetworkRouterVertex)
55 + edgeByID := make(map[string]NetworkRouterEdge)
56 +
57 + nodeMap := make(map[int]NodeTopologyEntity)
58 + for _, node := range service.FindAllNode() {
59 + nodeMap[node.ID] = node
60 + }
61 + ipPrimaryMap := getIPPrimaryMap(service.FindAllIP())
62 + ipTable := getIPInterfaceTable(service.FindAllIP())
63 + snmpByID := make(map[int]SnmpInterfaceTopologyEntity)
64 + for _, snmp := range service.FindAllSnmp() {
65 + snmpByID[snmp.ID] = snmp
66 + }
67 +
68 + addVertex := func(v NetworkRouterVertex) {
69 + if strings.TrimSpace(v.ID) == "" {
70 + return
71 + }
72 + if _, exists := vertexByID[v.ID]; exists {
73 + return
74 + }
75 + vertexByID[v.ID] = v
76 + }
77 + addEdge := func(e NetworkRouterEdge) {
78 + if strings.TrimSpace(e.ID) == "" {
79 + return
80 + }
81 + if _, exists := edgeByID[e.ID]; exists {
82 + return
83 + }
84 + edgeByID[e.ID] = e
85 + }
86 +
87 + for _, node := range service.FindAllNode() {
88 + primary := ipPrimaryMap[node.ID]
89 + addVertex(createNodeVertex(node, primary))
90 + }
91 +
92 + for _, subnet := range service.FindAllLegalPointToPointSubNetwork() {
93 + if subnet == nil {
94 + continue
95 + }
96 + nodeIDs := subnet.NodeIDs()
97 + if len(nodeIDs) < 2 {
98 + continue
99 + }
100 + sourceNodeID := nodeIDs[0]
101 + targetNodeID := nodeIDs[1]
102 +
103 + source, sourceOK := nodeMap[sourceNodeID]
104 + target, targetOK := nodeMap[targetNodeID]
105 + if !sourceOK || !targetOK {
106 + continue
107 + }
108 +
109 + sourceIP, sourceIPOK := firstIPInSubnet(ipTable[sourceNodeID], subnet)
110 + targetIP, targetIPOK := firstIPInSubnet(ipTable[targetNodeID], subnet)
111 + if !sourceIPOK || !targetIPOK {
112 + continue
113 + }
114 +
115 + sourcePort := createNodePort(createNodeVertex(source, ipPrimaryMap[source.ID]), sourceIP, snmpByID[sourceIP.SnmpInterfaceID])
116 + targetPort := createNodePort(createNodeVertex(target, ipPrimaryMap[target.ID]), targetIP, snmpByID[targetIP.SnmpInterfaceID])
117 + addEdge(NetworkRouterEdge{
118 + ID: sourcePort.Vertex + keySep + sourcePort.ID + "->" + targetPort.Vertex + keySep + targetPort.ID,
119 + SourcePort: sourcePort,
120 + TargetPort: targetPort,
121 + })
122 + }
123 +
124 + for _, subnet := range service.FindSubNetworkByNetworkPrefixLessThen(ipv4prefix, ipv6prefix) {
125 + if subnet == nil {
126 + continue
127 + }
128 + networkVertex := createNetworkVertex(subnet)
129 + addVertex(networkVertex)
130 + for _, targetNodeID := range subnet.NodeIDs() {
131 + targetNode, ok := nodeMap[targetNodeID]
132 + if !ok {
133 + continue
134 + }
135 + targetIP, found := firstIPInSubnet(ipTable[targetNodeID], subnet)
136 + if !found {
137 + continue
138 + }
139 + targetVertex := createNodeVertex(targetNode, ipPrimaryMap[targetNodeID])
140 + sourcePort := createNetworkPort(networkVertex, targetIP)
141 + targetPort := createNodePort(targetVertex, targetIP, snmpByID[targetIP.SnmpInterfaceID])
142 + addEdge(NetworkRouterEdge{
143 + ID: sourcePort.Vertex + keySep + sourcePort.ID + "->" + targetPort.Vertex + keySep + targetPort.ID,
144 + SourcePort: sourcePort,
145 + TargetPort: targetPort,
146 + })
147 + }
148 + }
149 +
150 + if len(ipPrimaryMap) > 0 {
151 + nodeIDs := make([]int, 0, len(ipPrimaryMap))
152 + for nodeID := range ipPrimaryMap {
153 + nodeIDs = append(nodeIDs, nodeID)
154 + }
155 + sort.Ints(nodeIDs)
156 + for _, nodeID := range nodeIDs {
157 + if nodeID <= 0 {
158 + continue
159 + }
160 + result.DefaultVertex = strconv.Itoa(nodeID)
161 + break
162 + }
163 + }
164 +
165 + result.Vertices = sortedNetworkRouterVertices(vertexByID)
166 + result.Edges = sortedNetworkRouterEdges(edgeByID)
167 + return result
168 +}
169 +
170 +func createNodeVertex(node NodeTopologyEntity, primary IPInterfaceTopologyEntity) NetworkRouterVertex {
171 + address := ""
172 + if primary.IPAddress.IsValid() {
173 + address = primary.IPAddress.String()
174 + } else if node.Address.IsValid() {
175 + address = node.Address.String()
176 + }
177 + label := strings.TrimSpace(node.Label)
178 + if label == "" {
179 + label = strconv.Itoa(node.ID)
180 + }
181 + vertex := NetworkRouterVertex{
182 + ID: strconv.Itoa(node.ID),
183 + Label: label,
184 + Address: address,
185 + IconKey: "node",
186 + NodeID: node.ID,
187 + }
188 + vertex.ToolTip = "Node: " + label
189 + if address != "" {
190 + vertex.ToolTip += " (" + address + ")"
191 + }
192 + return vertex
193 +}
194 +
195 +func createNodePort(vertex NetworkRouterVertex, ip IPInterfaceTopologyEntity, snmp SnmpInterfaceTopologyEntity) NetworkRouterPort {
196 + port := NetworkRouterPort{
197 + ID: ip.IPAddress.String(),
198 + Vertex: vertex.ID,
199 + IPID: ip.ID,
200 + Addr: ip.IPAddress.String(),
201 + }
202 + if snmp.ID > 0 {
203 + port.IfIndex = snmp.IfIndex
204 + port.IfName = snmp.IfName
205 + }
206 + port.ToolTip = "Port " + port.Addr
207 + if port.IfName != "" {
208 + port.ToolTip += " (" + port.IfName + ")"
209 + }
210 + return port
211 +}
212 +
213 +func createNetworkPort(vertex NetworkRouterVertex, target IPInterfaceTopologyEntity) NetworkRouterPort {
214 + addr := "to: " + target.IPAddress.String()
215 + return NetworkRouterPort{
216 + ID: vertex.ID + "to:" + target.IPAddress.String(),
217 + Vertex: vertex.ID,
218 + IPID: target.ID,
219 + Addr: addr,
220 + ToolTip: "Port " + addr,
221 + }
222 +}
223 +
224 +func createNetworkVertex(network *SubNetwork) NetworkRouterVertex {
225 + cidr := ""
226 + nodeIDs := ""
227 + if network != nil {
228 + cidr = network.CIDR()
229 + nodeIDValues := network.NodeIDs()
230 + parts := make([]string, 0, len(nodeIDValues))
231 + for _, nodeID := range nodeIDValues {
232 + parts = append(parts, strconv.Itoa(nodeID))
233 + }
234 + nodeIDs = strings.Join(parts, ",")
235 + }
236 + return NetworkRouterVertex{
237 + ID: cidr,
238 + Label: cidr,
239 + Address: cidr,
240 + IconKey: "cloud",
241 + ToolTip: "SubNetwork: " + cidr + ", Nodeids:[" + nodeIDs + "]",
242 + IsSubnet: true,
243 + }
244 +}
245 +
246 +func getIPPrimaryMap(ips []IPInterfaceTopologyEntity) map[int]IPInterfaceTopologyEntity {
247 + primary := make(map[int]IPInterfaceTopologyEntity)
248 + for _, ip := range ips {
249 + if ip.NodeID <= 0 || !ip.IPAddress.IsValid() {
250 + continue
251 + }
252 + current, exists := primary[ip.NodeID]
253 + if !exists {
254 + primary[ip.NodeID] = ip
255 + continue
256 + }
257 + if ip.IsSnmpPrimary {
258 + primary[ip.NodeID] = ip
259 + continue
260 + }
261 + primary[ip.NodeID] = current
262 + }
263 + return primary
264 +}
265 +
266 +func getIPInterfaceTable(ips []IPInterfaceTopologyEntity) map[int][]IPInterfaceTopologyEntity {
267 + table := make(map[int][]IPInterfaceTopologyEntity)
268 + for _, ip := range ips {
269 + if ip.NodeID <= 0 || !ip.IPAddress.IsValid() {
270 + continue
271 + }
272 + table[ip.NodeID] = append(table[ip.NodeID], ip)
273 + }
274 + for nodeID := range table {
275 + sort.Slice(table[nodeID], func(i, j int) bool {
276 + if table[nodeID][i].ID != table[nodeID][j].ID {
277 + return table[nodeID][i].ID < table[nodeID][j].ID
278 + }
279 + return compareAddr(table[nodeID][i].IPAddress, table[nodeID][j].IPAddress) < 0
280 + })
281 + }
282 + return table
283 +}
284 +
285 +func firstIPInSubnet(ips []IPInterfaceTopologyEntity, subnet *SubNetwork) (IPInterfaceTopologyEntity, bool) {
286 + for _, ip := range ips {
287 + if subnet.IsInRange(ip.IPAddress) {
288 + return ip, true
289 + }
290 + }
291 + return IPInterfaceTopologyEntity{}, false
292 +}
293 +
294 +func sortedNetworkRouterVertices(values map[string]NetworkRouterVertex) []NetworkRouterVertex {
295 + keys := make([]string, 0, len(values))
296 + for key := range values {
297 + keys = append(keys, key)
298 + }
299 + sort.Strings(keys)
300 + result := make([]NetworkRouterVertex, 0, len(keys))
301 + for _, key := range keys {
302 + result = append(result, values[key])
303 + }
304 + return result
305 +}
306 +
307 +func sortedNetworkRouterEdges(values map[string]NetworkRouterEdge) []NetworkRouterEdge {
308 + keys := make([]string, 0, len(values))
309 + for key := range values {
310 + keys = append(keys, key)
311 + }
312 + sort.Strings(keys)
313 + result := make([]NetworkRouterEdge, 0, len(keys))
314 + for _, key := range keys {
315 + result = append(result, values[key])
316 + }
317 + return result
318 +}
src/go/pkg/topology/engine/node_topology_service.go new
+357
@@ -0,0 +1,357 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "sort"
6 +
7 +// NodeTopologyService ports Enlinkd NodeTopologyServiceImpl logic.
8 +type NodeTopologyService struct {
9 + nodes []NodeTopologyEntity
10 + ips []IPInterfaceTopologyEntity
11 + snmp []SnmpInterfaceTopologyEntity
12 +}
13 +
14 +// NewNodeTopologyService builds a deterministic node topology service snapshot.
15 +func NewNodeTopologyService(nodes []NodeTopologyEntity, ips []IPInterfaceTopologyEntity, snmp []SnmpInterfaceTopologyEntity) *NodeTopologyService {
16 + out := &NodeTopologyService{
17 + nodes: append([]NodeTopologyEntity(nil), nodes...),
18 + ips: append([]IPInterfaceTopologyEntity(nil), ips...),
19 + snmp: append([]SnmpInterfaceTopologyEntity(nil), snmp...),
20 + }
21 +
22 + sort.Slice(out.nodes, func(i, j int) bool {
23 + if out.nodes[i].ID != out.nodes[j].ID {
24 + return out.nodes[i].ID < out.nodes[j].ID
25 + }
26 + return out.nodes[i].Label < out.nodes[j].Label
27 + })
28 + sort.Slice(out.ips, func(i, j int) bool {
29 + if out.ips[i].ID != out.ips[j].ID {
30 + return out.ips[i].ID < out.ips[j].ID
31 + }
32 + if out.ips[i].NodeID != out.ips[j].NodeID {
33 + return out.ips[i].NodeID < out.ips[j].NodeID
34 + }
35 + return out.ips[i].IPAddress.String() < out.ips[j].IPAddress.String()
36 + })
37 + sort.Slice(out.snmp, func(i, j int) bool {
38 + if out.snmp[i].ID != out.snmp[j].ID {
39 + return out.snmp[i].ID < out.snmp[j].ID
40 + }
41 + if out.snmp[i].NodeID != out.snmp[j].NodeID {
42 + return out.snmp[i].NodeID < out.snmp[j].NodeID
43 + }
44 + return out.snmp[i].IfIndex < out.snmp[j].IfIndex
45 + })
46 + return out
47 +}
48 +
49 +// FindAllNode returns all node entities.
50 +func (s *NodeTopologyService) FindAllNode() []NodeTopologyEntity {
51 + if s == nil {
52 + return nil
53 + }
54 + return append([]NodeTopologyEntity(nil), s.nodes...)
55 +}
56 +
57 +// FindAllIP returns all IP interface entities.
58 +func (s *NodeTopologyService) FindAllIP() []IPInterfaceTopologyEntity {
59 + if s == nil {
60 + return nil
61 + }
62 + return append([]IPInterfaceTopologyEntity(nil), s.ips...)
63 +}
64 +
65 +// FindAllSnmp returns all SNMP interface entities.
66 +func (s *NodeTopologyService) FindAllSnmp() []SnmpInterfaceTopologyEntity {
67 + if s == nil {
68 + return nil
69 + }
70 + return append([]SnmpInterfaceTopologyEntity(nil), s.snmp...)
71 +}
72 +
73 +// FindAllSubNetwork ports NodeTopologyServiceImpl.findAllSubNetwork().
74 +func (s *NodeTopologyService) FindAllSubNetwork() []*SubNetwork {
75 + if s == nil {
76 + return nil
77 + }
78 + byKey := make(map[string]*SubNetwork)
79 + keys := make([]string, 0)
80 +
81 + for _, ip := range s.ips {
82 + if !ip.IsManaged || !ip.IPAddress.IsValid() || !ip.NetMask.IsValid() {
83 + continue
84 + }
85 + network, ok := NetworkAddress(ip.IPAddress, ip.NetMask)
86 + if !ok {
87 + continue
88 + }
89 + key := subnetKey(network, ip.NetMask)
90 + subnet := byKey[key]
91 + if subnet == nil {
92 + created, err := NewSubNetwork(ip.NodeID, ip.IPAddress, ip.NetMask)
93 + if err != nil {
94 + continue
95 + }
96 + byKey[key] = created
97 + keys = append(keys, key)
98 + continue
99 + }
100 + subnet.Add(ip.NodeID, ip.IPAddress)
101 + }
102 +
103 + for _, ip := range s.ips {
104 + if !ip.IsManaged || !ip.IPAddress.IsValid() || ip.NetMask.IsValid() {
105 + continue
106 + }
107 + for _, key := range keys {
108 + subnet := byKey[key]
109 + if subnet == nil {
110 + continue
111 + }
112 + subnet.Add(ip.NodeID, ip.IPAddress)
113 + }
114 + }
115 +
116 + sorted := sortedSubnetworkKeys(byKey)
117 + result := make([]*SubNetwork, 0, len(sorted))
118 + for _, key := range sorted {
119 + subnet := byKey[key]
120 + if subnet == nil {
121 + continue
122 + }
123 + result = append(result, subnet.clone())
124 + }
125 + return result
126 +}
127 +
128 +// FindAllLegalSubNetwork ports NodeTopologyServiceImpl.findAllLegalSubNetwork().
129 +func (s *NodeTopologyService) FindAllLegalSubNetwork() []*SubNetwork {
130 + all := s.FindAllSubNetwork()
131 + if len(all) == 0 {
132 + return nil
133 + }
134 + result := make([]*SubNetwork, 0, len(all))
135 + for _, subnet := range all {
136 + if subnet == nil || subnet.HasDuplicatedAddress() {
137 + continue
138 + }
139 + if InSameNetwork(subnet.Network(), loopbackAddrIPv4, subnet.Netmask()) {
140 + continue
141 + }
142 + result = append(result, subnet)
143 + }
144 + return result
145 +}
146 +
147 +// FindSubNetworkByNetworkPrefixLessThen ports NodeTopologyServiceImpl.findSubNetworkByNetworkPrefixLessThen().
148 +func (s *NodeTopologyService) FindSubNetworkByNetworkPrefixLessThen(ipv4prefix, ipv6prefix int) []*SubNetwork {
149 + legal := s.FindAllLegalSubNetwork()
150 + if len(legal) == 0 {
151 + return nil
152 + }
153 + result := make([]*SubNetwork, 0, len(legal))
154 + for _, subnet := range legal {
155 + if subnet == nil {
156 + continue
157 + }
158 + prefix := subnet.NetworkPrefix()
159 + if subnet.IsIPv4Subnetwork() {
160 + if prefix < ipv4prefix {
161 + result = append(result, subnet)
162 + }
163 + continue
164 + }
165 + if prefix < ipv6prefix {
166 + result = append(result, subnet)
167 + }
168 + }
169 + return result
170 +}
171 +
172 +// FindAllPointToPointSubNetwork ports NodeTopologyServiceImpl.findAllPointToPointSubNetwork().
173 +func (s *NodeTopologyService) FindAllPointToPointSubNetwork() []*SubNetwork {
174 + all := s.FindAllSubNetwork()
175 + if len(all) == 0 {
176 + return nil
177 + }
178 + result := make([]*SubNetwork, 0, len(all))
179 + for _, subnet := range all {
180 + if subnet == nil {
181 + continue
182 + }
183 + if IsPointToPointMask(subnet.Netmask()) {
184 + result = append(result, subnet)
185 + }
186 + }
187 + return result
188 +}
189 +
190 +// FindAllLegalPointToPointSubNetwork ports NodeTopologyServiceImpl.findAllLegalPointToPointSubNetwork().
191 +func (s *NodeTopologyService) FindAllLegalPointToPointSubNetwork() []*SubNetwork {
192 + legal := s.FindAllLegalSubNetwork()
193 + if len(legal) == 0 {
194 + return nil
195 + }
196 + result := make([]*SubNetwork, 0, len(legal))
197 + for _, subnet := range legal {
198 + if subnet == nil {
199 + continue
200 + }
201 + if IsPointToPointMask(subnet.Netmask()) && len(subnet.NodeIDs()) == 2 {
202 + result = append(result, subnet)
203 + }
204 + }
205 + return result
206 +}
207 +
208 +// FindAllLoopbacks ports NodeTopologyServiceImpl.findAllLoopbacks().
209 +func (s *NodeTopologyService) FindAllLoopbacks() []*SubNetwork {
210 + all := s.FindAllSubNetwork()
211 + if len(all) == 0 {
212 + return nil
213 + }
214 + result := make([]*SubNetwork, 0, len(all))
215 + for _, subnet := range all {
216 + if subnet == nil {
217 + continue
218 + }
219 + if IsLoopbackMask(subnet.Netmask()) {
220 + result = append(result, subnet)
221 + }
222 + }
223 + return result
224 +}
225 +
226 +// FindAllLegalLoopbacks ports NodeTopologyServiceImpl.findAllLegalLoopbacks().
227 +func (s *NodeTopologyService) FindAllLegalLoopbacks() []*SubNetwork {
228 + all := s.FindAllSubNetwork()
229 + if len(all) == 0 {
230 + return nil
231 + }
232 + result := make([]*SubNetwork, 0, len(all))
233 + for _, subnet := range all {
234 + if subnet == nil {
235 + continue
236 + }
237 + if IsLoopbackMask(subnet.Netmask()) && len(subnet.NodeIDs()) == 1 {
238 + result = append(result, subnet)
239 + }
240 + }
241 + return result
242 +}
243 +
244 +// GetNodeIDPriorityMap ports NodeTopologyServiceImpl.getNodeidPriorityMap().
245 +func (s *NodeTopologyService) GetNodeIDPriorityMap() map[int]int {
246 + priorityMap := make(map[int]int)
247 + legal := s.FindAllLegalSubNetwork()
248 + remaining := make(map[string]*SubNetwork)
249 + for _, subnet := range legal {
250 + if subnet == nil || len(subnet.NodeIDs()) <= 1 {
251 + continue
252 + }
253 + remaining[subnet.key()] = subnet
254 + }
255 +
256 + priority := 0
257 + for len(remaining) > 0 {
258 + start := getNextSubnetwork(remaining)
259 + if start == nil {
260 + break
261 + }
262 + delete(remaining, start.key())
263 + for _, nodeID := range start.NodeIDs() {
264 + priorityMap[nodeID] = priority
265 + }
266 + priority = getConnectedSubnets(start, remaining, priorityMap, priority+1)
267 + }
268 + return priorityMap
269 +}
270 +
271 +func getNextSubnetwork(subnets map[string]*SubNetwork) *SubNetwork {
272 + var selected *SubNetwork
273 + for _, subnet := range subnets {
274 + if subnet == nil {
275 + continue
276 + }
277 + if selected == nil {
278 + selected = subnet
279 + continue
280 + }
281 + selectedSize := len(selected.NodeIDs())
282 + subnetSize := len(subnet.NodeIDs())
283 + if selectedSize < subnetSize {
284 + selected = subnet
285 + continue
286 + }
287 + if selectedSize != subnetSize {
288 + continue
289 + }
290 + if compareAddr(selected.Network(), subnet.Network()) > 0 {
291 + selected = subnet
292 + }
293 + }
294 + return selected
295 +}
296 +
297 +func getConnectedSubnets(starting *SubNetwork, subnetworks map[string]*SubNetwork, priorityMap map[int]int, priority int) int {
298 + if starting == nil || len(subnetworks) == 0 {
299 + return priority
300 + }
301 +
302 + downlevels := make([]*SubNetwork, 0)
303 + for _, subnet := range subnetworks {
304 + if subnet == nil {
305 + continue
306 + }
307 + if hasNodeIntersection(starting, subnet) {
308 + downlevels = append(downlevels, subnet)
309 + }
310 + }
311 + for _, subnet := range downlevels {
312 + delete(subnetworks, subnet.key())
313 + }
314 +
315 + for _, subnet := range downlevels {
316 + if subnet == nil {
317 + continue
318 + }
319 + addingNodes := make([]int, 0)
320 + for _, nodeID := range subnet.NodeIDs() {
321 + if _, exists := priorityMap[nodeID]; exists {
322 + continue
323 + }
324 + addingNodes = append(addingNodes, nodeID)
325 + }
326 + if len(addingNodes) == 0 {
327 + continue
328 + }
329 + for _, nodeID := range addingNodes {
330 + priorityMap[nodeID] = priority
331 + }
332 + priority++
333 + }
334 +
335 + if len(downlevels) > 0 && len(subnetworks) > 0 {
336 + for _, level := range downlevels {
337 + priority = getConnectedSubnets(level, subnetworks, priorityMap, priority)
338 + }
339 + }
340 + return priority
341 +}
342 +
343 +func hasNodeIntersection(left, right *SubNetwork) bool {
344 + if left == nil || right == nil {
345 + return false
346 + }
347 + rightIDs := make(map[int]struct{}, len(right.nodeInterfaceMap))
348 + for nodeID := range right.nodeInterfaceMap {
349 + rightIDs[nodeID] = struct{}{}
350 + }
351 + for nodeID := range left.nodeInterfaceMap {
352 + if _, ok := rightIDs[nodeID]; ok {
353 + return true
354 + }
355 + }
356 + return false
357 +}
src/go/pkg/topology/engine/node_topology_subnet.go new
+353
@@ -0,0 +1,353 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "fmt"
7 + "net/netip"
8 + "sort"
9 + "strconv"
10 +)
11 +
12 +var (
13 + pointToPointMaskIPv4 = netip.MustParseAddr("255.255.255.252")
14 + pointToPointMaskIPv6 = netip.MustParseAddr("ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe")
15 + loopbackMaskIPv4 = netip.MustParseAddr("255.255.255.255")
16 + loopbackMaskIPv6 = netip.MustParseAddr("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")
17 + loopbackAddrIPv4 = netip.MustParseAddr("127.0.0.1")
18 +)
19 +
20 +// SubNetwork mirrors Enlinkd SubNetwork behavior for node/IP membership.
21 +type SubNetwork struct {
22 + network netip.Addr
23 + netmask netip.Addr
24 + nodeInterfaceMap map[int]map[netip.Addr]struct{}
25 +}
26 +
27 +// NewSubNetwork creates a subnet from one managed IP interface.
28 +func NewSubNetwork(nodeID int, ip, netmask netip.Addr) (*SubNetwork, error) {
29 + if nodeID <= 0 {
30 + return nil, fmt.Errorf("node id is required")
31 + }
32 + if !ip.IsValid() {
33 + return nil, fmt.Errorf("ip is required")
34 + }
35 + if !netmask.IsValid() {
36 + return nil, fmt.Errorf("netmask is required")
37 + }
38 + network, ok := NetworkAddress(ip, netmask)
39 + if !ok {
40 + return nil, fmt.Errorf("cannot build network from ip %q and netmask %q", ip, netmask)
41 + }
42 + s := &SubNetwork{
43 + network: network,
44 + netmask: netmask,
45 + nodeInterfaceMap: map[int]map[netip.Addr]struct{}{},
46 + }
47 + s.nodeInterfaceMap[nodeID] = map[netip.Addr]struct{}{ip.Unmap(): {}}
48 + return s, nil
49 +}
50 +
51 +// Network returns the network address.
52 +func (s *SubNetwork) Network() netip.Addr {
53 + if s == nil {
54 + return netip.Addr{}
55 + }
56 + return s.network
57 +}
58 +
59 +// Netmask returns the subnet mask.
60 +func (s *SubNetwork) Netmask() netip.Addr {
61 + if s == nil {
62 + return netip.Addr{}
63 + }
64 + return s.netmask
65 +}
66 +
67 +// CIDR returns network/prefix format.
68 +func (s *SubNetwork) CIDR() string {
69 + if s == nil || !s.network.IsValid() || !s.netmask.IsValid() {
70 + return ""
71 + }
72 + prefix, err := MaskToCIDRPrefix(s.netmask)
73 + if err != nil {
74 + return ""
75 + }
76 + return s.network.String() + "/" + strconv.Itoa(prefix)
77 +}
78 +
79 +// NetworkPrefix returns the CIDR prefix for the mask.
80 +func (s *SubNetwork) NetworkPrefix() int {
81 + if s == nil {
82 + return 0
83 + }
84 + prefix, err := MaskToCIDRPrefix(s.netmask)
85 + if err != nil {
86 + return 0
87 + }
88 + return prefix
89 +}
90 +
91 +// IsIPv4Subnetwork reports if the subnet uses IPv4.
92 +func (s *SubNetwork) IsIPv4Subnetwork() bool {
93 + return s != nil && s.network.IsValid() && s.network.Is4()
94 +}
95 +
96 +// NodeIDs returns sorted node IDs in the subnet.
97 +func (s *SubNetwork) NodeIDs() []int {
98 + if s == nil || len(s.nodeInterfaceMap) == 0 {
99 + return nil
100 + }
101 + ids := make([]int, 0, len(s.nodeInterfaceMap))
102 + for nodeID := range s.nodeInterfaceMap {
103 + ids = append(ids, nodeID)
104 + }
105 + sort.Ints(ids)
106 + return ids
107 +}
108 +
109 +// Add adds one node/IP membership if the address is in range.
110 +func (s *SubNetwork) Add(nodeID int, ip netip.Addr) bool {
111 + if s == nil || nodeID <= 0 || !ip.IsValid() || !s.IsInRange(ip) {
112 + return false
113 + }
114 + ip = ip.Unmap()
115 + if _, ok := s.nodeInterfaceMap[nodeID]; !ok {
116 + s.nodeInterfaceMap[nodeID] = map[netip.Addr]struct{}{}
117 + }
118 + if _, exists := s.nodeInterfaceMap[nodeID][ip]; exists {
119 + return false
120 + }
121 + s.nodeInterfaceMap[nodeID][ip] = struct{}{}
122 + return true
123 +}
124 +
125 +// Remove removes one node/IP membership.
126 +func (s *SubNetwork) Remove(nodeID int, ip netip.Addr) bool {
127 + if s == nil || nodeID <= 0 || !ip.IsValid() {
128 + return false
129 + }
130 + ip = ip.Unmap()
131 + ips, ok := s.nodeInterfaceMap[nodeID]
132 + if !ok {
133 + return false
134 + }
135 + if _, exists := ips[ip]; !exists {
136 + return false
137 + }
138 + delete(ips, ip)
139 + if len(ips) == 0 {
140 + delete(s.nodeInterfaceMap, nodeID)
141 + }
142 + return true
143 +}
144 +
145 +// IsInRange reports if ip belongs to this subnet.
146 +func (s *SubNetwork) IsInRange(ip netip.Addr) bool {
147 + if s == nil || !ip.IsValid() || !s.network.IsValid() || !s.netmask.IsValid() {
148 + return false
149 + }
150 + return InSameNetwork(ip.Unmap(), s.network, s.netmask)
151 +}
152 +
153 +// HasDuplicatedAddress reports true when the same address exists under multiple entries.
154 +func (s *SubNetwork) HasDuplicatedAddress() bool {
155 + if s == nil {
156 + return false
157 + }
158 + seen := make(map[netip.Addr]struct{})
159 + for _, addresses := range s.nodeInterfaceMap {
160 + for addr := range addresses {
161 + if _, ok := seen[addr]; ok {
162 + return true
163 + }
164 + seen[addr] = struct{}{}
165 + }
166 + }
167 + return false
168 +}
169 +
170 +func (s *SubNetwork) clone() *SubNetwork {
171 + if s == nil {
172 + return nil
173 + }
174 + out := &SubNetwork{
175 + network: s.network,
176 + netmask: s.netmask,
177 + nodeInterfaceMap: make(map[int]map[netip.Addr]struct{}, len(s.nodeInterfaceMap)),
178 + }
179 + for nodeID, ips := range s.nodeInterfaceMap {
180 + copySet := make(map[netip.Addr]struct{}, len(ips))
181 + for ip := range ips {
182 + copySet[ip] = struct{}{}
183 + }
184 + out.nodeInterfaceMap[nodeID] = copySet
185 + }
186 + return out
187 +}
188 +
189 +func (s *SubNetwork) key() string {
190 + if s == nil {
191 + return ""
192 + }
193 + return subnetKey(s.network, s.netmask)
194 +}
195 +
196 +func subnetKey(network, netmask netip.Addr) string {
197 + if !network.IsValid() || !netmask.IsValid() {
198 + return ""
199 + }
200 + // Unmap IPv4-mapped IPv6 addresses so that ::ffff:10.0.0.0 and 10.0.0.0
201 + // produce the same key.
202 + network = network.Unmap()
203 + netmask = netmask.Unmap()
204 + return network.String() + keySep + netmask.String()
205 +}
206 +
207 +func sortedSubnetworkKeys(subnets map[string]*SubNetwork) []string {
208 + keys := make([]string, 0, len(subnets))
209 + for key := range subnets {
210 + keys = append(keys, key)
211 + }
212 + sort.Strings(keys)
213 + return keys
214 +}
215 +
216 +func compareAddr(a, b netip.Addr) int {
217 + ab := addrBytes(a)
218 + bb := addrBytes(b)
219 + if len(ab) != len(bb) {
220 + if len(ab) < len(bb) {
221 + return -1
222 + }
223 + return 1
224 + }
225 + for i := range ab {
226 + if ab[i] < bb[i] {
227 + return -1
228 + }
229 + if ab[i] > bb[i] {
230 + return 1
231 + }
232 + }
233 + return 0
234 +}
235 +
236 +// IsPointToPointMask ports InetAddressUtils.isPointToPointMask().
237 +func IsPointToPointMask(mask netip.Addr) bool {
238 + mask = mask.Unmap()
239 + return mask == pointToPointMaskIPv4 || mask == pointToPointMaskIPv6
240 +}
241 +
242 +// IsLoopbackMask ports InetAddressUtils.isLoopbackMask().
243 +func IsLoopbackMask(mask netip.Addr) bool {
244 + mask = mask.Unmap()
245 + return mask == loopbackMaskIPv4 || mask == loopbackMaskIPv6
246 +}
247 +
248 +// InSameNetwork ports InetAddressUtils.inSameNetwork().
249 +func InSameNetwork(addr1, addr2, mask netip.Addr) bool {
250 + addr1 = addr1.Unmap()
251 + addr2 = addr2.Unmap()
252 + mask = mask.Unmap()
253 + if !addr1.IsValid() || !addr2.IsValid() || !mask.IsValid() {
254 + return false
255 + }
256 + if addr1.Is4() != addr2.Is4() || addr1.Is4() != mask.Is4() {
257 + return false
258 + }
259 +
260 + ab := addrBytes(addr1)
261 + bb := addrBytes(addr2)
262 + mb := addrBytes(mask)
263 + if len(ab) != len(bb) || len(ab) != len(mb) {
264 + return false
265 + }
266 + for i := range ab {
267 + if (ab[i] & mb[i]) != (bb[i] & mb[i]) {
268 + return false
269 + }
270 + }
271 + return true
272 +}
273 +
274 +// NetworkAddress returns ip&mask for matching IP families.
275 +func NetworkAddress(ip, mask netip.Addr) (netip.Addr, bool) {
276 + ip = ip.Unmap()
277 + mask = mask.Unmap()
278 + if !ip.IsValid() || !mask.IsValid() || ip.Is4() != mask.Is4() {
279 + return netip.Addr{}, false
280 + }
281 + ib := addrBytes(ip)
282 + mb := addrBytes(mask)
283 + if len(ib) != len(mb) {
284 + return netip.Addr{}, false
285 + }
286 + out := make([]byte, len(ib))
287 + for i := range ib {
288 + out[i] = ib[i] & mb[i]
289 + }
290 + addr, ok := netip.AddrFromSlice(out)
291 + if !ok {
292 + return netip.Addr{}, false
293 + }
294 + return addr.Unmap(), true
295 +}
296 +
297 +// MaskToCIDRPrefix ports InetAddressUtils.convertInetAddressMaskToCidr().
298 +func MaskToCIDRPrefix(mask netip.Addr) (int, error) {
299 + mask = mask.Unmap()
300 + if !mask.IsValid() {
301 + return 0, fmt.Errorf("invalid mask")
302 + }
303 + foundZero := false
304 + cidr := 0
305 + for _, value := range addrBytes(mask) {
306 + k := int(value)
307 + if foundZero && k != 0 {
308 + return 0, fmt.Errorf("invalid mask %q", mask)
309 + }
310 + switch k {
311 + case 255:
312 + cidr += 8
313 + case 254:
314 + cidr += 7
315 + foundZero = true
316 + case 252:
317 + cidr += 6
318 + foundZero = true
319 + case 248:
320 + cidr += 5
321 + foundZero = true
322 + case 240:
323 + cidr += 4
324 + foundZero = true
325 + case 224:
326 + cidr += 3
327 + foundZero = true
328 + case 192:
329 + cidr += 2
330 + foundZero = true
331 + case 128:
332 + cidr += 1
333 + foundZero = true
334 + case 0:
335 + foundZero = true
336 + default:
337 + return 0, fmt.Errorf("invalid mask %q", mask)
338 + }
339 + }
340 + return cidr, nil
341 +}
342 +
343 +func addrBytes(addr netip.Addr) []byte {
344 + if !addr.IsValid() {
345 + return nil
346 + }
347 + if addr.Is4() {
348 + a := addr.As4()
349 + return a[:]
350 + }
351 + a := addr.As16()
352 + return a[:]
353 +}
src/go/pkg/topology/engine/node_topology_test.go new
+179
@@ -0,0 +1,179 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "net/netip"
7 + "testing"
8 +
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestSubNetworkLifecycleAndHelpers(t *testing.T) {
13 + subnet, err := NewSubNetwork(2, netip.MustParseAddr("192.0.2.2"), netip.MustParseAddr("255.255.255.252"))
14 + require.NoError(t, err)
15 + require.Equal(t, netip.MustParseAddr("192.0.2.0"), subnet.Network())
16 + require.Equal(t, netip.MustParseAddr("255.255.255.252"), subnet.Netmask())
17 + require.Equal(t, "192.0.2.0/30", subnet.CIDR())
18 + require.Equal(t, 30, subnet.NetworkPrefix())
19 + require.True(t, subnet.IsIPv4Subnetwork())
20 + require.Equal(t, []int{2}, subnet.NodeIDs())
21 +
22 + require.True(t, subnet.IsInRange(netip.MustParseAddr("192.0.2.1")))
23 + require.False(t, subnet.IsInRange(netip.MustParseAddr("192.0.2.8")))
24 +
25 + require.True(t, subnet.Add(1, netip.MustParseAddr("192.0.2.1")))
26 + require.False(t, subnet.Add(1, netip.MustParseAddr("192.0.2.1")))
27 + require.False(t, subnet.Add(3, netip.MustParseAddr("192.0.2.8")))
28 + require.Equal(t, []int{1, 2}, subnet.NodeIDs())
29 +
30 + require.True(t, subnet.Add(3, netip.MustParseAddr("192.0.2.1")))
31 + require.True(t, subnet.HasDuplicatedAddress())
32 + require.True(t, subnet.Remove(3, netip.MustParseAddr("192.0.2.1")))
33 + require.False(t, subnet.HasDuplicatedAddress())
34 + require.False(t, subnet.Remove(3, netip.MustParseAddr("192.0.2.1")))
35 +
36 + cloned := subnet.clone()
37 + require.NotNil(t, cloned)
38 + require.True(t, cloned.Remove(1, netip.MustParseAddr("192.0.2.1")))
39 + require.Equal(t, []int{1, 2}, subnet.NodeIDs())
40 + require.Equal(t, []int{2}, cloned.NodeIDs())
41 +
42 + require.Equal(t, "192.0.2.0\x00255.255.255.252", subnet.key())
43 + require.Equal(t, "192.0.2.0\x00255.255.255.252", subnetKey(subnet.Network(), subnet.Netmask()))
44 + require.Equal(t, []string{"a", "b"}, sortedSubnetworkKeys(map[string]*SubNetwork{
45 + "b": subnet,
46 + "a": cloned,
47 + }))
48 +
49 + require.Equal(t, -1, compareAddr(netip.MustParseAddr("10.0.0.1"), netip.MustParseAddr("10.0.0.2")))
50 + require.Equal(t, 1, compareAddr(netip.MustParseAddr("2001:db8::2"), netip.MustParseAddr("2001:db8::1")))
51 + require.Equal(t, 0, compareAddr(netip.MustParseAddr("10.0.0.1"), netip.MustParseAddr("10.0.0.1")))
52 +
53 + require.True(t, IsPointToPointMask(netip.MustParseAddr("255.255.255.252")))
54 + require.True(t, IsPointToPointMask(netip.MustParseAddr("ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe")))
55 + require.True(t, IsLoopbackMask(netip.MustParseAddr("255.255.255.255")))
56 + require.True(t, InSameNetwork(
57 + netip.MustParseAddr("10.0.0.1"),
58 + netip.MustParseAddr("10.0.0.2"),
59 + netip.MustParseAddr("255.255.255.252"),
60 + ))
61 + require.False(t, InSameNetwork(
62 + netip.MustParseAddr("10.0.0.1"),
63 + netip.MustParseAddr("10.0.0.5"),
64 + netip.MustParseAddr("255.255.255.252"),
65 + ))
66 +
67 + network, ok := NetworkAddress(netip.MustParseAddr("10.0.0.2"), netip.MustParseAddr("255.255.255.252"))
68 + require.True(t, ok)
69 + require.Equal(t, netip.MustParseAddr("10.0.0.0"), network)
70 +
71 + prefix, err := MaskToCIDRPrefix(netip.MustParseAddr("255.255.255.252"))
72 + require.NoError(t, err)
73 + require.Equal(t, 30, prefix)
74 + _, err = MaskToCIDRPrefix(netip.MustParseAddr("255.0.255.0"))
75 + require.Error(t, err)
76 +}
77 +
78 +func TestNodeTopologyServiceAndRouterTopology(t *testing.T) {
79 + service := NewNodeTopologyService(
80 + []NodeTopologyEntity{
81 + {ID: 3, Label: "node-3", Address: netip.MustParseAddr("192.168.1.3")},
82 + {ID: 1, Label: "node-1", Address: netip.MustParseAddr("10.0.0.1")},
83 + {ID: 2, Label: "node-2", Address: netip.MustParseAddr("10.0.0.2")},
84 + },
85 + []IPInterfaceTopologyEntity{
86 + {ID: 5, NodeID: 2, IPAddress: netip.MustParseAddr("192.168.1.2"), NetMask: netip.MustParseAddr("255.255.255.0"), IsManaged: true, SnmpInterfaceID: 103},
87 + {ID: 2, NodeID: 1, IPAddress: netip.MustParseAddr("10.0.0.1"), NetMask: netip.MustParseAddr("255.255.255.252"), IsManaged: true, IsSnmpPrimary: true, SnmpInterfaceID: 101},
88 + {ID: 6, NodeID: 3, IPAddress: netip.MustParseAddr("127.0.0.1"), NetMask: netip.MustParseAddr("255.255.255.255"), IsManaged: true},
89 + {ID: 4, NodeID: 3, IPAddress: netip.MustParseAddr("192.168.1.3"), NetMask: netip.MustParseAddr("255.255.255.0"), IsManaged: true, IsSnmpPrimary: true, SnmpInterfaceID: 104},
90 + {ID: 3, NodeID: 2, IPAddress: netip.MustParseAddr("10.0.0.2"), NetMask: netip.MustParseAddr("255.255.255.252"), IsManaged: true, IsSnmpPrimary: true, SnmpInterfaceID: 102},
91 + {ID: 1, NodeID: 1, IPAddress: netip.MustParseAddr("10.10.10.1"), IsManaged: false},
92 + },
93 + []SnmpInterfaceTopologyEntity{
94 + {ID: 104, NodeID: 3, IfIndex: 4, IfName: "eth4"},
95 + {ID: 101, NodeID: 1, IfIndex: 1, IfName: "eth1"},
96 + {ID: 103, NodeID: 2, IfIndex: 3, IfName: "eth3"},
97 + {ID: 102, NodeID: 2, IfIndex: 2, IfName: "eth2"},
98 + },
99 + )
100 +
101 + nodes := service.FindAllNode()
102 + require.Equal(t, []int{1, 2, 3}, []int{nodes[0].ID, nodes[1].ID, nodes[2].ID})
103 +
104 + ips := service.FindAllIP()
105 + require.Equal(t, []int{1, 2, 3, 4, 5, 6}, []int{ips[0].ID, ips[1].ID, ips[2].ID, ips[3].ID, ips[4].ID, ips[5].ID})
106 +
107 + snmp := service.FindAllSnmp()
108 + require.Equal(t, []int{101, 102, 103, 104}, []int{snmp[0].ID, snmp[1].ID, snmp[2].ID, snmp[3].ID})
109 +
110 + allSubnets := service.FindAllSubNetwork()
111 + require.Len(t, allSubnets, 3)
112 + require.Equal(t, []string{"10.0.0.0/30", "127.0.0.1/32", "192.168.1.0/24"}, []string{
113 + allSubnets[0].CIDR(),
114 + allSubnets[1].CIDR(),
115 + allSubnets[2].CIDR(),
116 + })
117 +
118 + legal := service.FindAllLegalSubNetwork()
119 + require.Len(t, legal, 2)
120 + require.Equal(t, []string{"10.0.0.0/30", "192.168.1.0/24"}, []string{legal[0].CIDR(), legal[1].CIDR()})
121 +
122 + ptp := service.FindAllPointToPointSubNetwork()
123 + require.Len(t, ptp, 1)
124 + require.Equal(t, "10.0.0.0/30", ptp[0].CIDR())
125 +
126 + legalPTP := service.FindAllLegalPointToPointSubNetwork()
127 + require.Len(t, legalPTP, 1)
128 + require.Equal(t, []int{1, 2}, legalPTP[0].NodeIDs())
129 +
130 + loopbacks := service.FindAllLoopbacks()
131 + require.Len(t, loopbacks, 1)
132 + require.Equal(t, "127.0.0.1/32", loopbacks[0].CIDR())
133 +
134 + legalLoopbacks := service.FindAllLegalLoopbacks()
135 + require.Len(t, legalLoopbacks, 1)
136 + require.Equal(t, []int{3}, legalLoopbacks[0].NodeIDs())
137 +
138 + multibet := service.FindSubNetworkByNetworkPrefixLessThen(30, 126)
139 + require.Len(t, multibet, 1)
140 + require.Equal(t, "192.168.1.0/24", multibet[0].CIDR())
141 +
142 + require.Equal(t, map[int]int{
143 + 1: 0,
144 + 2: 0,
145 + 3: 1,
146 + }, service.GetNodeIDPriorityMap())
147 +
148 + router := BuildNetworkRouterTopology(service, 30, 126)
149 + require.Equal(t, "1", router.DefaultVertex)
150 + require.Len(t, router.Vertices, 4)
151 + require.Len(t, router.Edges, 3)
152 +
153 + require.Equal(t, []string{"1", "192.168.1.0/24", "2", "3"}, []string{
154 + router.Vertices[0].ID,
155 + router.Vertices[1].ID,
156 + router.Vertices[2].ID,
157 + router.Vertices[3].ID,
158 + })
159 +
160 + require.Equal(t, []string{
161 + "1\x0010.0.0.1->2\x0010.0.0.2",
162 + "192.168.1.0/24\x00192.168.1.0/24to:192.168.1.2->2\x00192.168.1.2",
163 + "192.168.1.0/24\x00192.168.1.0/24to:192.168.1.3->3\x00192.168.1.3",
164 + }, []string{
165 + router.Edges[0].ID,
166 + router.Edges[1].ID,
167 + router.Edges[2].ID,
168 + })
169 + require.Equal(t, "1", router.Edges[0].SourcePort.Vertex)
170 + require.Equal(t, "2", router.Edges[0].TargetPort.Vertex)
171 + require.Equal(t, "eth1", router.Edges[0].SourcePort.IfName)
172 + require.Equal(t, "eth2", router.Edges[0].TargetPort.IfName)
173 + require.Equal(t, "192.168.1.0/24", router.Edges[1].SourcePort.Vertex)
174 + require.Equal(t, "2", router.Edges[1].TargetPort.Vertex)
175 + require.Equal(t, "", router.Edges[1].SourcePort.IfName)
176 + require.Equal(t, "eth3", router.Edges[1].TargetPort.IfName)
177 + require.Equal(t, "192.168.1.0/24", router.Edges[2].SourcePort.Vertex)
178 + require.Equal(t, "3", router.Edges[2].TargetPort.Vertex)
179 +}
src/go/pkg/topology/engine/noop.go new
+16
@@ -0,0 +1,16 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "context"
6 +
7 +// NoopEngine is a temporary placeholder that satisfies Engine.
8 +type NoopEngine struct{}
9 +
10 +func (NoopEngine) DiscoverByCIDRs(context.Context, CIDRRequest) (Result, error) {
11 + return Result{}, ErrNotImplemented
12 +}
13 +
14 +func (NoopEngine) DiscoverByDevices(context.Context, DeviceRequest) (Result, error) {
15 + return Result{}, ErrNotImplemented
16 +}
src/go/pkg/topology/engine/parity/README.md new
+76
@@ -0,0 +1,76 @@
1 +# Topology Parity Runbook
2 +
3 +## Scope
4 +- Validate topology engine parity against imported Enlinkd fixtures and assertion inventories.
5 +- Evidence files live in `src/go/pkg/topology/engine/parity/evidence`.
6 +
7 +## Prerequisites
8 +- Upstream checkout available at `/tmp/topology-library-repos/enlinkd`.
9 +- Run commands from `src/go`.
10 +
11 +## Refresh Evidence
12 +```bash
13 +go run ./tools/topology-parity-evidence --mode sync
14 +```
15 +
16 +What it does:
17 +- Verifies fixture source exists.
18 +- Refreshes local fixture mirror.
19 +- Regenerates:
20 + - `enlinkd-fixture-inventory.csv`
21 + - `enlinkd-test-method-inventory.csv`
22 + - `enlinkd-assertion-inventory.csv`
23 +
24 +## Verify Mirror Integrity
25 +```bash
26 +go run ./tools/topology-parity-evidence --mode verify
27 +```
28 +
29 +Expected:
30 +- `verify complete`
31 +- Fixture counts match upstream mirror and evidence inventory.
32 +
33 +## Run Full Parity Suite
34 +```bash
35 +go run ./tools/topology-parity-evidence --mode suite
36 +```
37 +
38 +Outputs:
39 +- `parity-summary.json`
40 +- Scenario pass/fail totals.
41 +- Mapped test/assertion coverage totals.
42 +- Determinism check (`runs=2`, `byte_identical=true` expected).
43 +
44 +## Run Behavior Oracle Diff (Go vs Enlinkd Golden)
45 +```bash
46 +go run ./tools/topology-parity-evidence --mode oracle-diff
47 +```
48 +
49 +Outputs:
50 +- `behavior-oracle-diff.json` (machine-readable per-scenario diff report).
51 +- `behavior-oracle-diff.md` (human-readable summary).
52 +- Fixture-input evidence per scenario (walk file path + sha256 + size).
53 +- In-scope pass criteria: zero diffs for device identity set, hostname identity, adjacency set, and metadata counts.
54 +
55 +## Run Go Test Gates
56 +```bash
57 +go test ./pkg/topology/engine/... ./plugin/go.d/collector/snmp ./tools/topology-parity-evidence -count=1
58 +```
59 +
60 +Expected:
61 +- All packages pass.
62 +
63 +## Failure Triage
64 +1. `verify` fails:
65 + - Confirm upstream checkout path exists.
66 + - Re-run `--mode sync`, then `--mode verify`.
67 +2. `suite` has failed scenarios:
68 + - Open `parity-summary.json`.
69 + - Inspect failing scenario IDs and manifests.
70 + - Re-run targeted tests in `./pkg/topology/engine/parity`.
71 +3. Coverage totals regress:
72 + - Check `assertion-mapping.csv` uniqueness and status values.
73 + - Reconcile `not-applicable-approved.csv` with mapping rows using `not-applicable-approved`.
74 +4. Determinism fails:
75 + - Re-run `suite` and compare generated summary files.
76 + - Check sort/order logic in parity result builders and adapters.
src/go/pkg/topology/engine/parity/doc.go new
+5
@@ -0,0 +1,5 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +// Package parity contains fixture and golden helpers used by topology engine
4 +// parity tests.
5 +package parity
src/go/pkg/topology/engine/parity/golden.go new
+281
@@ -0,0 +1,281 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package parity
4 +
5 +import (
6 + "bytes"
7 + "encoding/json"
8 + "fmt"
9 + "os"
10 + "sort"
11 + "strings"
12 +
13 + "gopkg.in/yaml.v3"
14 +)
15 +
16 +// GoldenVersion is the current parity golden schema version.
17 +const GoldenVersion = "v1"
18 +
19 +// GoldenDocument is the source-of-truth YAML structure for one scenario.
20 +type GoldenDocument struct {
21 + Version string `yaml:"version" json:"version"`
22 + ScenarioID string `yaml:"scenario_id" json:"scenario_id"`
23 + Description string `yaml:"description,omitempty" json:"description,omitempty"`
24 + Devices []GoldenDevice `yaml:"devices" json:"devices"`
25 + Adjacencies []GoldenAdjacency `yaml:"adjacencies" json:"adjacencies"`
26 + Expectations GoldenCounts `yaml:"expectations" json:"expectations"`
27 +}
28 +
29 +// GoldenCounts stores aggregate assertions used by parity tests.
30 +type GoldenCounts struct {
31 + DirectionalAdjacencies int `yaml:"directional_adjacencies" json:"directional_adjacencies"`
32 + BidirectionalPairs int `yaml:"bidirectional_pairs" json:"bidirectional_pairs"`
33 + Devices int `yaml:"devices" json:"devices"`
34 +}
35 +
36 +// GoldenDevice is one expected device in the scenario.
37 +type GoldenDevice struct {
38 + ID string `yaml:"id" json:"id"`
39 + Hostname string `yaml:"hostname" json:"hostname"`
40 +}
41 +
42 +// GoldenAdjacency is one expected directed adjacency observation.
43 +type GoldenAdjacency struct {
44 + Protocol string `yaml:"protocol" json:"protocol"`
45 + SourceDevice string `yaml:"source_device" json:"source_device"`
46 + SourcePort string `yaml:"source_port" json:"source_port"`
47 + TargetDevice string `yaml:"target_device" json:"target_device"`
48 + TargetPort string `yaml:"target_port" json:"target_port"`
49 +}
50 +
51 +// LoadGoldenYAML loads and validates a golden YAML source file.
52 +func LoadGoldenYAML(path string) (GoldenDocument, error) {
53 + data, err := os.ReadFile(path)
54 + if err != nil {
55 + return GoldenDocument{}, fmt.Errorf("read golden yaml %q: %w", path, err)
56 + }
57 + var doc GoldenDocument
58 + if err := yaml.Unmarshal(data, &doc); err != nil {
59 + return GoldenDocument{}, fmt.Errorf("decode golden yaml %q: %w", path, err)
60 + }
61 + if err := doc.validate(); err != nil {
62 + return GoldenDocument{}, fmt.Errorf("validate golden yaml %q: %w", path, err)
63 + }
64 + return doc.Canonical(), nil
65 +}
66 +
67 +// LoadGoldenJSON loads a generated canonical JSON cache.
68 +func LoadGoldenJSON(path string) (GoldenDocument, error) {
69 + data, err := os.ReadFile(path)
70 + if err != nil {
71 + return GoldenDocument{}, fmt.Errorf("read golden json %q: %w", path, err)
72 + }
73 + var doc GoldenDocument
74 + if err := json.Unmarshal(data, &doc); err != nil {
75 + return GoldenDocument{}, fmt.Errorf("decode golden json %q: %w", path, err)
76 + }
77 + if err := doc.validate(); err != nil {
78 + return GoldenDocument{}, fmt.Errorf("validate golden json %q: %w", path, err)
79 + }
80 + return doc.Canonical(), nil
81 +}
82 +
83 +// CanonicalJSON marshals a deterministic canonical JSON representation.
84 +func (d GoldenDocument) CanonicalJSON() ([]byte, error) {
85 + c := d.Canonical()
86 + out, err := json.MarshalIndent(c, "", " ")
87 + if err != nil {
88 + return nil, err
89 + }
90 + return append(out, '\n'), nil
91 +}
92 +
93 +// Canonical returns a deterministic ordering for slices in the document.
94 +func (d GoldenDocument) Canonical() GoldenDocument {
95 + out := d
96 + out.Devices = make([]GoldenDevice, 0, len(d.Devices))
97 + out.Devices = append(out.Devices, d.Devices...)
98 + out.Adjacencies = make([]GoldenAdjacency, 0, len(d.Adjacencies))
99 + out.Adjacencies = append(out.Adjacencies, d.Adjacencies...)
100 +
101 + sort.Slice(out.Devices, func(i, j int) bool {
102 + if out.Devices[i].ID != out.Devices[j].ID {
103 + return out.Devices[i].ID < out.Devices[j].ID
104 + }
105 + return out.Devices[i].Hostname < out.Devices[j].Hostname
106 + })
107 +
108 + sort.Slice(out.Adjacencies, func(i, j int) bool {
109 + ai := out.Adjacencies[i]
110 + aj := out.Adjacencies[j]
111 + if ai.Protocol != aj.Protocol {
112 + return ai.Protocol < aj.Protocol
113 + }
114 + if ai.SourceDevice != aj.SourceDevice {
115 + return ai.SourceDevice < aj.SourceDevice
116 + }
117 + if ai.SourcePort != aj.SourcePort {
118 + return ai.SourcePort < aj.SourcePort
119 + }
120 + if ai.TargetDevice != aj.TargetDevice {
121 + return ai.TargetDevice < aj.TargetDevice
122 + }
123 + return ai.TargetPort < aj.TargetPort
124 + })
125 + return out
126 +}
127 +
128 +// ValidateCache compares authored YAML against generated JSON cache.
129 +func ValidateCache(yamlPath, jsonPath string) error {
130 + yamlDoc, err := LoadGoldenYAML(yamlPath)
131 + if err != nil {
132 + return err
133 + }
134 + jsonDoc, err := LoadGoldenJSON(jsonPath)
135 + if err != nil {
136 + return err
137 + }
138 + yamlJSON, err := yamlDoc.CanonicalJSON()
139 + if err != nil {
140 + return fmt.Errorf("marshal canonical yaml document: %w", err)
141 + }
142 + jsonJSON, err := jsonDoc.CanonicalJSON()
143 + if err != nil {
144 + return fmt.Errorf("marshal canonical json document: %w", err)
145 + }
146 + if !bytes.Equal(yamlJSON, jsonJSON) {
147 + return fmt.Errorf("golden cache mismatch between %q and %q", yamlPath, jsonPath)
148 + }
149 + return nil
150 +}
151 +
152 +func (d GoldenDocument) validate() error {
153 + if d.Version == "" {
154 + return fmt.Errorf("version is required")
155 + }
156 + if d.Version != GoldenVersion {
157 + return fmt.Errorf("unsupported version %q (want %q)", d.Version, GoldenVersion)
158 + }
159 + if d.ScenarioID == "" {
160 + return fmt.Errorf("scenario_id is required")
161 + }
162 + if len(d.Devices) == 0 {
163 + return fmt.Errorf("at least one device is required")
164 + }
165 +
166 + seenDevices := make(map[string]struct{}, len(d.Devices))
167 + for _, dev := range d.Devices {
168 + if dev.ID == "" {
169 + return fmt.Errorf("device id is required")
170 + }
171 + if _, ok := seenDevices[dev.ID]; ok {
172 + return fmt.Errorf("duplicate device id %q", dev.ID)
173 + }
174 + seenDevices[dev.ID] = struct{}{}
175 + }
176 +
177 + for _, adj := range d.Adjacencies {
178 + if adj.Protocol == "" {
179 + return fmt.Errorf("adjacency protocol is required")
180 + }
181 + if _, ok := seenDevices[adj.SourceDevice]; !ok {
182 + return fmt.Errorf("adjacency source_device %q is not in devices", adj.SourceDevice)
183 + }
184 + if _, ok := seenDevices[adj.TargetDevice]; !ok {
185 + return fmt.Errorf("adjacency target_device %q is not in devices", adj.TargetDevice)
186 + }
187 + }
188 +
189 + if d.Expectations.DirectionalAdjacencies != len(d.Adjacencies) {
190 + return fmt.Errorf("expectations.directional_adjacencies=%d does not match adjacencies=%d", d.Expectations.DirectionalAdjacencies, len(d.Adjacencies))
191 + }
192 + if d.Expectations.BidirectionalPairs != countGoldenBidirectionalPairs(d.Adjacencies) {
193 + return fmt.Errorf("expectations.bidirectional_pairs=%d does not match bidirectional_pairs=%d", d.Expectations.BidirectionalPairs, countGoldenBidirectionalPairs(d.Adjacencies))
194 + }
195 + if d.Expectations.Devices != len(d.Devices) {
196 + return fmt.Errorf("expectations.devices=%d does not match devices=%d", d.Expectations.Devices, len(d.Devices))
197 + }
198 +
199 + return nil
200 +}
201 +
202 +func countGoldenBidirectionalPairs(adjacencies []GoldenAdjacency) int {
203 + directed := make(map[string]struct{}, len(adjacencies))
204 + for _, adj := range adjacencies {
205 + directed[goldenAdjacencyPairKey(adj)] = struct{}{}
206 + }
207 +
208 + counted := make(map[string]struct{}, len(directed))
209 + pairs := 0
210 + for _, adj := range adjacencies {
211 + forward := goldenAdjacencyPairKey(adj)
212 + reverse := goldenAdjacencyPairKey(GoldenAdjacency{
213 + Protocol: adj.Protocol,
214 + SourceDevice: adj.TargetDevice,
215 + SourcePort: adj.TargetPort,
216 + TargetDevice: adj.SourceDevice,
217 + TargetPort: adj.SourcePort,
218 + })
219 + if forward == reverse {
220 + continue
221 + }
222 +
223 + canonical := min(reverse, forward)
224 + if _, done := counted[canonical]; done {
225 + continue
226 + }
227 + if _, ok := directed[reverse]; !ok {
228 + continue
229 + }
230 +
231 + counted[canonical] = struct{}{}
232 + pairs++
233 + }
234 +
235 + return pairs
236 +}
237 +
238 +func goldenAdjacencyKey(parts ...string) string {
239 + return strings.Join(parts, "|")
240 +}
241 +
242 +func goldenAdjacencyPairKey(adj GoldenAdjacency) string {
243 + return goldenAdjacencyKey(
244 + adj.Protocol,
245 + adj.SourceDevice,
246 + normalizeGoldenPortForPairing(adj.SourcePort),
247 + adj.TargetDevice,
248 + normalizeGoldenPortForPairing(adj.TargetPort),
249 + )
250 +}
251 +
252 +func normalizeGoldenPortForPairing(port string) string {
253 + port = strings.ToLower(strings.TrimSpace(port))
254 + if port == "" {
255 + return ""
256 + }
257 +
258 + port = strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "").Replace(port)
259 +
260 + for _, alias := range []struct {
261 + short string
262 + long string
263 + }{
264 + {short: "gi", long: "gigabitethernet"},
265 + {short: "fa", long: "fastethernet"},
266 + {short: "te", long: "tengigabitethernet"},
267 + {short: "po", long: "portchannel"},
268 + } {
269 + if strings.HasPrefix(port, alias.long) {
270 + return port
271 + }
272 + if rest, ok := strings.CutPrefix(port, alias.short); ok && rest != "" {
273 + switch rest[0] {
274 + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '/':
275 + return alias.long + rest
276 + }
277 + }
278 + }
279 +
280 + return port
281 +}
src/go/pkg/topology/engine/parity/golden_fixture_test.go new
+295
@@ -0,0 +1,295 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +//go:build topology_fixtures
4 +
5 +package parity
6 +
7 +import (
8 + "testing"
9 +
10 + "github.com/stretchr/testify/require"
11 +)
12 +
13 +func TestValidateGoldenCache(t *testing.T) {
14 + manifest, err := LoadManifest("../../../../testdata/snmp/enlinkd/nms8003/manifest.yaml")
15 + require.NoError(t, err)
16 +
17 + scenario, ok := manifest.FindScenario("nms8003_lldp")
18 + require.True(t, ok)
19 +
20 + resolved, err := ResolveScenario("../../../../testdata/snmp/enlinkd/nms8003/manifest.yaml", scenario)
21 + require.NoError(t, err)
22 +
23 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
24 +}
25 +
26 +func TestValidateGoldenCache_NMS8000(t *testing.T) {
27 + manifestPath := "../../../../testdata/snmp/enlinkd/nms8000/manifest.yaml"
28 + manifest, err := LoadManifest(manifestPath)
29 + require.NoError(t, err)
30 +
31 + scenarios := []string{"nms8000_cdp", "nms8000_lldp"}
32 + for _, scenarioID := range scenarios {
33 + scenario, ok := manifest.FindScenario(scenarioID)
34 + require.True(t, ok)
35 +
36 + resolved, resolveErr := ResolveScenario(manifestPath, scenario)
37 + require.NoError(t, resolveErr)
38 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
39 + }
40 +}
41 +
42 +func TestValidateGoldenCache_NMS13637(t *testing.T) {
43 + manifestPath := "../../../../testdata/snmp/enlinkd/nms13637/manifest.yaml"
44 + manifest, err := LoadManifest(manifestPath)
45 + require.NoError(t, err)
46 +
47 + scenario, ok := manifest.FindScenario("nms13637_lldp")
48 + require.True(t, ok)
49 +
50 + resolved, err := ResolveScenario(manifestPath, scenario)
51 + require.NoError(t, err)
52 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
53 +}
54 +
55 +func TestValidateGoldenCache_NMS10205B(t *testing.T) {
56 + manifestPath := "../../../../testdata/snmp/enlinkd/nms10205b/manifest.yaml"
57 + manifest, err := LoadManifest(manifestPath)
58 + require.NoError(t, err)
59 +
60 + scenario, ok := manifest.FindScenario("nms10205b_lldp")
61 + require.True(t, ok)
62 +
63 + resolved, err := ResolveScenario(manifestPath, scenario)
64 + require.NoError(t, err)
65 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
66 +}
67 +
68 +func TestValidateGoldenCache_NMS17216(t *testing.T) {
69 + manifestPath := "../../../../testdata/snmp/enlinkd/nms17216/manifest.yaml"
70 + manifest, err := LoadManifest(manifestPath)
71 + require.NoError(t, err)
72 +
73 + scenarios := []string{"nms17216_lldp", "nms17216_cdp"}
74 + for _, scenarioID := range scenarios {
75 + scenario, ok := manifest.FindScenario(scenarioID)
76 + require.True(t, ok)
77 +
78 + resolved, resolveErr := ResolveScenario(manifestPath, scenario)
79 + require.NoError(t, resolveErr)
80 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
81 + }
82 +}
83 +
84 +func TestValidateGoldenCache_NMS0123(t *testing.T) {
85 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0123/manifest.yaml"
86 + manifest, err := LoadManifest(manifestPath)
87 + require.NoError(t, err)
88 +
89 + scenario, ok := manifest.FindScenario("nms0123_lldp")
90 + require.True(t, ok)
91 +
92 + resolved, err := ResolveScenario(manifestPath, scenario)
93 + require.NoError(t, err)
94 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
95 +}
96 +
97 +func TestValidateGoldenCache_NMS0002(t *testing.T) {
98 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0002/manifest.yaml"
99 + manifest, err := LoadManifest(manifestPath)
100 + require.NoError(t, err)
101 +
102 + scenarios := []string{"nms0002_cisco_juniper_lldp", "nms0002_cisco_alcatel_lldp"}
103 + for _, scenarioID := range scenarios {
104 + scenario, ok := manifest.FindScenario(scenarioID)
105 + require.True(t, ok)
106 +
107 + resolved, resolveErr := ResolveScenario(manifestPath, scenario)
108 + require.NoError(t, resolveErr)
109 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
110 + }
111 +}
112 +
113 +func TestValidateGoldenCache_NMS0000(t *testing.T) {
114 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0000/manifest.yaml"
115 + manifest, err := LoadManifest(manifestPath)
116 + require.NoError(t, err)
117 +
118 + scenarios := []string{
119 + "nms0000_network_all_lldp",
120 + "nms0000_network_two_connected_lldp",
121 + "nms0000_network_three_connected_lldp",
122 + "nms0000_microsense_lldp",
123 + "nms0000_ms16_lldp",
124 + "nms0000_planet_lldp",
125 + }
126 +
127 + for _, scenarioID := range scenarios {
128 + scenario, ok := manifest.FindScenario(scenarioID)
129 + require.True(t, ok)
130 +
131 + resolved, resolveErr := ResolveScenario(manifestPath, scenario)
132 + require.NoError(t, resolveErr)
133 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
134 + }
135 +}
136 +
137 +func TestValidateGoldenCache_NMS7467(t *testing.T) {
138 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7467/manifest.yaml"
139 + manifest, err := LoadManifest(manifestPath)
140 + require.NoError(t, err)
141 +
142 + scenario, ok := manifest.FindScenario("nms7467_cdp")
143 + require.True(t, ok)
144 +
145 + resolved, err := ResolveScenario(manifestPath, scenario)
146 + require.NoError(t, err)
147 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
148 +}
149 +
150 +func TestValidateGoldenCache_NMS7563(t *testing.T) {
151 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7563/manifest.yaml"
152 + manifest, err := LoadManifest(manifestPath)
153 + require.NoError(t, err)
154 +
155 + scenarios := []string{"nms7563_cisco01", "nms7563_homeserver_lldp", "nms7563_switch02_cdp"}
156 + for _, scenarioID := range scenarios {
157 + scenario, ok := manifest.FindScenario(scenarioID)
158 + require.True(t, ok)
159 +
160 + resolved, resolveErr := ResolveScenario(manifestPath, scenario)
161 + require.NoError(t, resolveErr)
162 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
163 + }
164 +}
165 +
166 +func TestValidateGoldenCache_NMS4930(t *testing.T) {
167 + manifestPath := "../../../../testdata/snmp/enlinkd/nms4930/manifest.yaml"
168 + manifest, err := LoadManifest(manifestPath)
169 + require.NoError(t, err)
170 +
171 + scenarios := []string{
172 + "nms4930_dlink1_bridge_fdb",
173 + "nms4930_dlink2_bridge_fdb",
174 + }
175 +
176 + for _, scenarioID := range scenarios {
177 + scenario, ok := manifest.FindScenario(scenarioID)
178 + require.True(t, ok)
179 +
180 + resolved, resolveErr := ResolveScenario(manifestPath, scenario)
181 + require.NoError(t, resolveErr)
182 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
183 + }
184 +}
185 +
186 +func TestValidateGoldenCache_NMS7777DW(t *testing.T) {
187 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7777dw/manifest.yaml"
188 + manifest, err := LoadManifest(manifestPath)
189 + require.NoError(t, err)
190 +
191 + scenario, ok := manifest.FindScenario("nms7777dw_lldp_no_links")
192 + require.True(t, ok)
193 +
194 + resolved, err := ResolveScenario(manifestPath, scenario)
195 + require.NoError(t, err)
196 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
197 +}
198 +
199 +func TestValidateGoldenCache_NMS13923(t *testing.T) {
200 + manifestPath := "../../../../testdata/snmp/enlinkd/nms13923/manifest.yaml"
201 + manifest, err := LoadManifest(manifestPath)
202 + require.NoError(t, err)
203 +
204 + scenario, ok := manifest.FindScenario("nms13923_lldp")
205 + require.True(t, ok)
206 +
207 + resolved, err := ResolveScenario(manifestPath, scenario)
208 + require.NoError(t, err)
209 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
210 +}
211 +
212 +func TestValidateGoldenCache_NMS13593(t *testing.T) {
213 + manifestPath := "../../../../testdata/snmp/enlinkd/nms13593/manifest.yaml"
214 + manifest, err := LoadManifest(manifestPath)
215 + require.NoError(t, err)
216 +
217 + scenario, ok := manifest.FindScenario("nms13593_lldp")
218 + require.True(t, ok)
219 +
220 + resolved, err := ResolveScenario(manifestPath, scenario)
221 + require.NoError(t, err)
222 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
223 +}
224 +
225 +func TestValidateGoldenCache_NMS7918(t *testing.T) {
226 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7918/manifest.yaml"
227 + manifest, err := LoadManifest(manifestPath)
228 + require.NoError(t, err)
229 +
230 + scenarios := []string{
231 + "nms7918_asw01_bridge_fdb",
232 + "nms7918_stcasw01_bridge_fdb",
233 + "nms7918_samasw01_bridge_fdb",
234 + "nms7918_ospwl01_arp",
235 + "nms7918_ospess01_arp",
236 + "nms7918_pe01_arp",
237 + }
238 +
239 + for _, scenarioID := range scenarios {
240 + scenario, ok := manifest.FindScenario(scenarioID)
241 + require.True(t, ok)
242 +
243 + resolved, resolveErr := ResolveScenario(manifestPath, scenario)
244 + require.NoError(t, resolveErr)
245 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
246 + }
247 +}
248 +
249 +func TestValidateGoldenCache_NMS18541(t *testing.T) {
250 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
251 + manifest, err := LoadManifest(manifestPath)
252 + require.NoError(t, err)
253 +
254 + scenarios := []string{
255 + "nms18541_network_all_lldp",
256 + "nms18541_topoqfx_sw01_sw02_sw03_lldp",
257 + "nms18541_topoqfx_sw01_lldp",
258 + "nms18541_topoqfx_sw02_lldp",
259 + "nms18541_topoqfx_sw03_lldp",
260 + "nms18541_topoqfx_sw04_lldp",
261 + "nms18541_topoqfx_sw08_lldp",
262 + "nms18541_topoqfx_sw09_lldp",
263 + "nms18541_qfx_lldp",
264 + "nms18541_microsens_sw01_lldp",
265 + "nms18541_microsens_sw02_lldp",
266 + "nms18541_microsens_sw03_lldp",
267 + "nms18541_microsens_sw04_lldp",
268 + "nms18541_microsens_sw08_lldp",
269 + "nms18541_microsens_sw09_lldp",
270 + }
271 +
272 + for _, scenarioID := range scenarios {
273 + scenario, ok := manifest.FindScenario(scenarioID)
274 + require.True(t, ok)
275 +
276 + resolved, resolveErr := ResolveScenario(manifestPath, scenario)
277 + require.NoError(t, resolveErr)
278 + require.NoError(t, ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON))
279 + }
280 +}
281 +
282 +func TestGoldenYAMLValidation(t *testing.T) {
283 + doc, err := LoadGoldenYAML("../../../../testdata/snmp/enlinkd/nms8003/golden/nms8003_lldp.yaml")
284 + require.NoError(t, err)
285 +
286 + require.Equal(t, GoldenVersion, doc.Version)
287 + require.Equal(t, "nms8003_lldp", doc.ScenarioID)
288 + require.Len(t, doc.Devices, 5)
289 + require.Len(t, doc.Adjacencies, 12)
290 + require.Equal(t, 6, doc.Expectations.BidirectionalPairs)
291 +
292 + payload, err := doc.CanonicalJSON()
293 + require.NoError(t, err)
294 + require.Contains(t, string(payload), "\"scenario_id\": \"nms8003_lldp\"")
295 +}
src/go/pkg/topology/engine/parity/golden_test.go new
+95
@@ -0,0 +1,95 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package parity
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestGoldenYAMLValidation_RejectsBidirectionalPairMismatch(t *testing.T) {
12 + doc := GoldenDocument{
13 + Version: GoldenVersion,
14 + ScenarioID: "pair-mismatch",
15 + Devices: []GoldenDevice{
16 + {ID: "a", Hostname: "a"},
17 + {ID: "b", Hostname: "b"},
18 + },
19 + Adjacencies: []GoldenAdjacency{
20 + {Protocol: "lldp", SourceDevice: "a", SourcePort: "Gi0/1", TargetDevice: "b", TargetPort: "Gi0/2"},
21 + {Protocol: "lldp", SourceDevice: "b", SourcePort: "Gi0/2", TargetDevice: "a", TargetPort: "Gi0/1"},
22 + },
23 + Expectations: GoldenCounts{
24 + DirectionalAdjacencies: 2,
25 + BidirectionalPairs: 0,
26 + Devices: 2,
27 + },
28 + }
29 +
30 + err := doc.validate()
31 + require.Error(t, err)
32 + require.Contains(t, err.Error(), "expectations.bidirectional_pairs")
33 +}
34 +
35 +func TestGoldenYAMLValidation_AcceptsBidirectionalDevicePairsWithPortAliases(t *testing.T) {
36 + doc := GoldenDocument{
37 + Version: GoldenVersion,
38 + ScenarioID: "pair-port-aliases",
39 + Devices: []GoldenDevice{
40 + {ID: "a", Hostname: "a"},
41 + {ID: "b", Hostname: "b"},
42 + },
43 + Adjacencies: []GoldenAdjacency{
44 + {Protocol: "cdp", SourceDevice: "a", SourcePort: "Gi0/0", TargetDevice: "b", TargetPort: "GigabitEthernet0/1"},
45 + {Protocol: "cdp", SourceDevice: "b", SourcePort: "Gi0/1", TargetDevice: "a", TargetPort: "GigabitEthernet0/0"},
46 + },
47 + Expectations: GoldenCounts{
48 + DirectionalAdjacencies: 2,
49 + BidirectionalPairs: 1,
50 + Devices: 2,
51 + },
52 + }
53 +
54 + require.NoError(t, doc.validate())
55 +}
56 +
57 +func TestGoldenYAMLValidation_SelfLoopsDoNotCountAsBidirectionalPairs(t *testing.T) {
58 + doc := GoldenDocument{
59 + Version: GoldenVersion,
60 + ScenarioID: "self-loop",
61 + Devices: []GoldenDevice{
62 + {ID: "a", Hostname: "a"},
63 + },
64 + Adjacencies: []GoldenAdjacency{
65 + {Protocol: "lldp", SourceDevice: "a", SourcePort: "Gi0/1", TargetDevice: "a", TargetPort: "Gi0/1"},
66 + },
67 + Expectations: GoldenCounts{
68 + DirectionalAdjacencies: 1,
69 + BidirectionalPairs: 1,
70 + Devices: 1,
71 + },
72 + }
73 +
74 + err := doc.validate()
75 + require.Error(t, err)
76 + require.Contains(t, err.Error(), "expectations.bidirectional_pairs")
77 +}
78 +
79 +func TestGoldenCanonicalJSON_UsesEmptyArrayForEmptyAdjacencies(t *testing.T) {
80 + doc := GoldenDocument{
81 + Version: GoldenVersion,
82 + ScenarioID: "empty-adjacencies",
83 + Devices: []GoldenDevice{{ID: "a", Hostname: "a"}},
84 + Adjacencies: nil,
85 + Expectations: GoldenCounts{
86 + DirectionalAdjacencies: 0,
87 + BidirectionalPairs: 0,
88 + Devices: 1,
89 + },
90 + }
91 +
92 + payload, err := doc.CanonicalJSON()
93 + require.NoError(t, err)
94 + require.Contains(t, string(payload), "\"adjacencies\": []")
95 +}
src/go/pkg/topology/engine/parity/l2_builder.go new
+850
@@ -0,0 +1,850 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package parity
4 +
5 +import (
6 + "encoding/hex"
7 + "fmt"
8 + "net/netip"
9 + "sort"
10 + "strconv"
11 + "strings"
12 +
13 + "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
14 +)
15 +
16 +// BuildOptions configures protocol extraction when converting walk fixtures
17 +// into an engine result.
18 +type BuildOptions struct {
19 + EnableLLDP bool
20 + EnableCDP bool
21 + EnableBridge bool
22 + EnableARP bool
23 +}
24 +
25 +// FixtureWalk is one device fixture with already-parsed walk records.
26 +type FixtureWalk struct {
27 + DeviceID string
28 + Hostname string
29 + Address string
30 + Records []WalkRecord
31 +}
32 +
33 +// LoadScenarioWalks loads all walk fixtures referenced by a resolved scenario.
34 +func LoadScenarioWalks(scenario ResolvedScenario) ([]FixtureWalk, error) {
35 + walks := make([]FixtureWalk, 0, len(scenario.Fixtures))
36 + for _, fixture := range scenario.Fixtures {
37 + ds, err := LoadWalkFile(fixture.WalkFile)
38 + if err != nil {
39 + return nil, fmt.Errorf("load walk for fixture %q: %w", fixture.DeviceID, err)
40 + }
41 + walks = append(walks, FixtureWalk{
42 + DeviceID: fixture.DeviceID,
43 + Hostname: fixture.Hostname,
44 + Address: fixture.Address,
45 + Records: ds.Records,
46 + })
47 + }
48 + return walks, nil
49 +}
50 +
51 +// BuildL2ResultFromWalks builds a deterministic layer-2 engine.Result from
52 +// LLDP/CDP/FDB/ARP observations in walk fixtures.
53 +func BuildL2ResultFromWalks(fixtures []FixtureWalk, opts BuildOptions) (engine.Result, error) {
54 + if len(fixtures) == 0 {
55 + return engine.Result{}, fmt.Errorf("at least one fixture is required")
56 + }
57 +
58 + observations := make([]engine.L2Observation, 0, len(fixtures))
59 + for _, fx := range fixtures {
60 + if strings.TrimSpace(fx.DeviceID) == "" {
61 + return engine.Result{}, fmt.Errorf("fixture with empty device id")
62 + }
63 + observations = append(observations, parseFixture(fx).toObservation())
64 + }
65 +
66 + return engine.BuildL2ResultFromObservations(observations, engine.DiscoverOptions{
67 + EnableLLDP: opts.EnableLLDP,
68 + EnableCDP: opts.EnableCDP,
69 + EnableBridge: opts.EnableBridge,
70 + EnableARP: opts.EnableARP,
71 + })
72 +}
73 +
74 +type parsedFixture struct {
75 + deviceID string
76 + hostname string
77 + mgmtIP string
78 + sysObjectID string
79 + chassisID string
80 + ifNameByIndex map[string]string
81 + bridgePortToIfIndex map[string]string
82 + portIDByNum map[string]string
83 + portIDSubtypeByNum map[string]string
84 + portDescByNum map[string]string
85 + fdbEntries map[string]fdbObs
86 + arpEntries map[string]arpObs
87 + lldpRemotes map[string]lldpRemoteObs
88 + cdpRemotes map[string]cdpRemoteObs
89 +}
90 +
91 +type lldpRemoteObs struct {
92 + localPortNum string
93 + remIndex string
94 + chassisID string
95 + sysName string
96 + portID string
97 + portIDSubtype string
98 + portDesc string
99 + mgmtIP string
100 +}
101 +
102 +type cdpRemoteObs struct {
103 + ifIndex string
104 + deviceIndex string
105 + deviceID string
106 + devicePort string
107 + addressType string
108 + address string
109 +}
110 +
111 +type fdbObs struct {
112 + mac string
113 + bridgePort string
114 + status string
115 +}
116 +
117 +type arpObs struct {
118 + ifIndex string
119 + ip string
120 + mac string
121 + state string
122 + addrType string
123 +}
124 +
125 +func parseFixture(f FixtureWalk) parsedFixture {
126 + p := parsedFixture{
127 + deviceID: strings.TrimSpace(f.DeviceID),
128 + hostname: strings.TrimSpace(f.Hostname),
129 + mgmtIP: strings.TrimSpace(f.Address),
130 + ifNameByIndex: make(map[string]string),
131 + bridgePortToIfIndex: make(map[string]string),
132 + portIDByNum: make(map[string]string),
133 + portIDSubtypeByNum: make(map[string]string),
134 + portDescByNum: make(map[string]string),
135 + fdbEntries: make(map[string]fdbObs),
136 + arpEntries: make(map[string]arpObs),
137 + lldpRemotes: make(map[string]lldpRemoteObs),
138 + cdpRemotes: make(map[string]cdpRemoteObs),
139 + }
140 +
141 + for _, rec := range f.Records {
142 + oid := normalizeOID(rec.OID)
143 + value := strings.TrimSpace(rec.Value)
144 +
145 + switch {
146 + case oid == "1.3.6.1.2.1.1.2.0":
147 + p.sysObjectID = value
148 + case oid == "1.3.6.1.2.1.1.5.0":
149 + if p.hostname == "" {
150 + p.hostname = value
151 + }
152 + case oid == "1.0.8802.1.1.2.1.3.3.0":
153 + p.hostname = value
154 + case oid == "1.0.8802.1.1.2.1.3.2.0":
155 + p.chassisID = normalizeHexToken(value)
156 + case strings.HasPrefix(oid, "1.3.6.1.2.1.31.1.1.1.1."):
157 + idx := strings.TrimPrefix(oid, "1.3.6.1.2.1.31.1.1.1.1.")
158 + p.ifNameByIndex[idx] = value
159 + case strings.HasPrefix(oid, "1.3.6.1.2.1.2.2.1.2."):
160 + idx := strings.TrimPrefix(oid, "1.3.6.1.2.1.2.2.1.2.")
161 + if _, ok := p.ifNameByIndex[idx]; !ok {
162 + p.ifNameByIndex[idx] = value
163 + }
164 + case strings.HasPrefix(oid, "1.3.6.1.2.1.17.1.4.1.2."):
165 + basePort := strings.TrimPrefix(oid, "1.3.6.1.2.1.17.1.4.1.2.")
166 + basePort = strings.TrimSpace(basePort)
167 + if basePort != "" {
168 + p.bridgePortToIfIndex[basePort] = value
169 + }
170 + case strings.HasPrefix(oid, "1.3.6.1.2.1.17.4.3.1.2."):
171 + if key, mac, ok := fdbIndexFromOID(oid, "1.3.6.1.2.1.17.4.3.1.2."); ok {
172 + entry := p.fdbEntries[key]
173 + if entry.mac == "" {
174 + entry.mac = mac
175 + }
176 + entry.bridgePort = value
177 + p.fdbEntries[key] = entry
178 + }
179 + case strings.HasPrefix(oid, "1.3.6.1.2.1.17.4.3.1.3."):
180 + if key, mac, ok := fdbIndexFromOID(oid, "1.3.6.1.2.1.17.4.3.1.3."); ok {
181 + entry := p.fdbEntries[key]
182 + if entry.mac == "" {
183 + entry.mac = mac
184 + }
185 + entry.status = value
186 + p.fdbEntries[key] = entry
187 + }
188 + case strings.HasPrefix(oid, "1.3.6.1.2.1.4.22.1.2."):
189 + if key, ifIndex, ip, ok := arpLegacyIndex(oid, "1.3.6.1.2.1.4.22.1.2."); ok {
190 + entry := p.arpEntries[key]
191 + entry.ifIndex = ifIndex
192 + entry.ip = ip
193 + entry.mac = value
194 + entry.addrType = "ipv4"
195 + p.arpEntries[key] = entry
196 + }
197 + case strings.HasPrefix(oid, "1.3.6.1.2.1.4.22.1.3."):
198 + if key, ifIndex, ip, ok := arpLegacyIndex(oid, "1.3.6.1.2.1.4.22.1.3."); ok {
199 + entry := p.arpEntries[key]
200 + entry.ifIndex = ifIndex
201 + entry.ip = ip
202 + entry.addrType = "ipv4"
203 + p.arpEntries[key] = entry
204 + }
205 + case strings.HasPrefix(oid, "1.3.6.1.2.1.4.22.1.4."):
206 + if key, ifIndex, ip, ok := arpLegacyIndex(oid, "1.3.6.1.2.1.4.22.1.4."); ok {
207 + entry := p.arpEntries[key]
208 + entry.ifIndex = ifIndex
209 + entry.ip = ip
210 + entry.state = value
211 + entry.addrType = "ipv4"
212 + p.arpEntries[key] = entry
213 + }
214 + case strings.HasPrefix(oid, "1.0.8802.1.1.2.1.3.7.1.3."):
215 + portNum := strings.TrimPrefix(oid, "1.0.8802.1.1.2.1.3.7.1.3.")
216 + p.portIDByNum[portNum] = value
217 + case strings.HasPrefix(oid, "1.0.8802.1.1.2.1.3.7.1.2."):
218 + portNum := strings.TrimPrefix(oid, "1.0.8802.1.1.2.1.3.7.1.2.")
219 + p.portIDSubtypeByNum[portNum] = value
220 + case strings.HasPrefix(oid, "1.0.8802.1.1.2.1.3.7.1.4."):
221 + portNum := strings.TrimPrefix(oid, "1.0.8802.1.1.2.1.3.7.1.4.")
222 + p.portDescByNum[portNum] = value
223 + case strings.HasPrefix(oid, "1.3.6.1.4.1.6527.3.1.2.59.3.1.1.4."):
224 + if localPort, ok := nokiaLocalPortIndex(oid, "1.3.6.1.4.1.6527.3.1.2.59.3.1.1.4."); ok {
225 + if portID := normalizeNokiaPortLabel(value); portID != "" {
226 + if _, exists := p.portIDByNum[localPort]; !exists {
227 + p.portIDByNum[localPort] = portID
228 + }
229 + }
230 + if strings.TrimSpace(value) != "" {
231 + if _, exists := p.portDescByNum[localPort]; !exists {
232 + p.portDescByNum[localPort] = strings.TrimSpace(value)
233 + }
234 + }
235 + }
236 + case strings.HasPrefix(oid, "1.0.8802.1.1.2.1.4.1.1.5."):
237 + if localPort, remIndex, ok := lldpRemoteIndex(oid, "1.0.8802.1.1.2.1.4.1.1.5."); ok {
238 + entry := p.lldpRemote(localPort, remIndex)
239 + entry.chassisID = normalizeHexToken(value)
240 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
241 + }
242 + case strings.HasPrefix(oid, "1.0.8802.1.1.2.1.4.1.1.7."):
243 + if localPort, remIndex, ok := lldpRemoteIndex(oid, "1.0.8802.1.1.2.1.4.1.1.7."); ok {
244 + entry := p.lldpRemote(localPort, remIndex)
245 + entry.portID = value
246 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
247 + }
248 + case strings.HasPrefix(oid, "1.0.8802.1.1.2.1.4.1.1.6."):
249 + if localPort, remIndex, ok := lldpRemoteIndex(oid, "1.0.8802.1.1.2.1.4.1.1.6."); ok {
250 + entry := p.lldpRemote(localPort, remIndex)
251 + entry.portIDSubtype = value
252 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
253 + }
254 + case strings.HasPrefix(oid, "1.0.8802.1.1.2.1.4.1.1.8."):
255 + if localPort, remIndex, ok := lldpRemoteIndex(oid, "1.0.8802.1.1.2.1.4.1.1.8."); ok {
256 + entry := p.lldpRemote(localPort, remIndex)
257 + entry.portDesc = value
258 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
259 + }
260 + case strings.HasPrefix(oid, "1.0.8802.1.1.2.1.4.1.1.9."):
261 + if localPort, remIndex, ok := lldpRemoteIndex(oid, "1.0.8802.1.1.2.1.4.1.1.9."); ok {
262 + entry := p.lldpRemote(localPort, remIndex)
263 + entry.sysName = value
264 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
265 + }
266 + case strings.HasPrefix(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.5."):
267 + if localPort, remIndex, ok := nokiaLldpRemoteIndex(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.5."); ok {
268 + entry := p.lldpRemote(localPort, remIndex)
269 + entry.chassisID = normalizeHexToken(value)
270 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
271 + }
272 + case strings.HasPrefix(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.7."):
273 + if localPort, remIndex, ok := nokiaLldpRemoteIndex(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.7."); ok {
274 + entry := p.lldpRemote(localPort, remIndex)
275 + entry.portID = value
276 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
277 + }
278 + case strings.HasPrefix(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.6."):
279 + if localPort, remIndex, ok := nokiaLldpRemoteIndex(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.6."); ok {
280 + entry := p.lldpRemote(localPort, remIndex)
281 + entry.portIDSubtype = value
282 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
283 + }
284 + case strings.HasPrefix(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.8."):
285 + if localPort, remIndex, ok := nokiaLldpRemoteIndex(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.8."); ok {
286 + entry := p.lldpRemote(localPort, remIndex)
287 + entry.portDesc = value
288 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
289 + }
290 + case strings.HasPrefix(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.9."):
291 + if localPort, remIndex, ok := nokiaLldpRemoteIndex(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.9."); ok {
292 + entry := p.lldpRemote(localPort, remIndex)
293 + entry.sysName = value
294 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
295 + }
296 + case strings.HasPrefix(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.2.1.3."):
297 + if localPort, remIndex, mgmtIP, ok := nokiaLldpRemoteMgmtIndex(oid, "1.3.6.1.4.1.6527.3.1.2.59.4.2.1.3."); ok {
298 + entry := p.lldpRemote(localPort, remIndex)
299 + entry.mgmtIP = mgmtIP
300 + p.lldpRemotes[lldpRemoteKey(localPort, remIndex)] = entry
301 + }
302 + case strings.HasPrefix(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.6."):
303 + if ifIndex, devIndex, ok := cdpRemoteIndex(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.6."); ok {
304 + entry := p.cdpRemote(ifIndex, devIndex)
305 + entry.deviceID = value
306 + p.cdpRemotes[cdpRemoteKey(ifIndex, devIndex)] = entry
307 + }
308 + case strings.HasPrefix(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.7."):
309 + if ifIndex, devIndex, ok := cdpRemoteIndex(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.7."); ok {
310 + entry := p.cdpRemote(ifIndex, devIndex)
311 + entry.devicePort = value
312 + p.cdpRemotes[cdpRemoteKey(ifIndex, devIndex)] = entry
313 + }
314 + case strings.HasPrefix(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.4."):
315 + if ifIndex, devIndex, ok := cdpRemoteIndex(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.4."); ok {
316 + entry := p.cdpRemote(ifIndex, devIndex)
317 + entry.address = value
318 + p.cdpRemotes[cdpRemoteKey(ifIndex, devIndex)] = entry
319 + }
320 + case strings.HasPrefix(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.3."):
321 + if ifIndex, devIndex, ok := cdpRemoteIndex(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.3."); ok {
322 + entry := p.cdpRemote(ifIndex, devIndex)
323 + entry.addressType = value
324 + p.cdpRemotes[cdpRemoteKey(ifIndex, devIndex)] = entry
325 + }
326 + }
327 + }
328 +
329 + if p.hostname == "" {
330 + p.hostname = p.deviceID
331 + }
332 + return p
333 +}
334 +
335 +func (p parsedFixture) toObservation() engine.L2Observation {
336 + obs := engine.L2Observation{
337 + DeviceID: p.deviceID,
338 + Hostname: p.hostname,
339 + ManagementIP: p.mgmtIP,
340 + SysObjectID: p.sysObjectID,
341 + ChassisID: p.chassisID,
342 + Interfaces: p.toInterfaces(),
343 + BridgePorts: p.toBridgePorts(),
344 + FDBEntries: p.toFDBEntries(),
345 + ARPNDEntries: p.toARPNDEntries(),
346 + LLDPRemotes: p.toLLDPRemotes(),
347 + CDPRemotes: p.toCDPRemotes(),
348 + }
349 + return obs
350 +}
351 +
352 +func (p parsedFixture) toInterfaces() []engine.ObservedInterface {
353 + if len(p.ifNameByIndex) == 0 {
354 + return nil
355 + }
356 + out := make([]engine.ObservedInterface, 0, len(p.ifNameByIndex))
357 + for idx, name := range p.ifNameByIndex {
358 + n, err := strconv.Atoi(idx)
359 + if err != nil {
360 + continue
361 + }
362 + ifName := strings.TrimSpace(name)
363 + if ifName == "" {
364 + continue
365 + }
366 + out = append(out, engine.ObservedInterface{IfIndex: n, IfName: ifName, IfDescr: ifName})
367 + }
368 + sort.Slice(out, func(i, j int) bool {
369 + if out[i].IfIndex != out[j].IfIndex {
370 + return out[i].IfIndex < out[j].IfIndex
371 + }
372 + return out[i].IfName < out[j].IfName
373 + })
374 + return out
375 +}
376 +
377 +func (p parsedFixture) toBridgePorts() []engine.BridgePortObservation {
378 + if len(p.bridgePortToIfIndex) == 0 {
379 + return nil
380 + }
381 + out := make([]engine.BridgePortObservation, 0, len(p.bridgePortToIfIndex))
382 + for basePort, ifIndexRaw := range p.bridgePortToIfIndex {
383 + ifIndex, err := strconv.Atoi(strings.TrimSpace(ifIndexRaw))
384 + if err != nil || ifIndex <= 0 {
385 + continue
386 + }
387 + basePort = strings.TrimSpace(basePort)
388 + if basePort == "" {
389 + continue
390 + }
391 + out = append(out, engine.BridgePortObservation{
392 + BasePort: basePort,
393 + IfIndex: ifIndex,
394 + })
395 + }
396 + sort.Slice(out, func(i, j int) bool {
397 + a, b := out[i], out[j]
398 + if a.BasePort != b.BasePort {
399 + return a.BasePort < b.BasePort
400 + }
401 + return a.IfIndex < b.IfIndex
402 + })
403 + return out
404 +}
405 +
406 +func (p parsedFixture) toFDBEntries() []engine.FDBObservation {
407 + if len(p.fdbEntries) == 0 {
408 + return nil
409 + }
410 + out := make([]engine.FDBObservation, 0, len(p.fdbEntries))
411 + for _, entry := range p.fdbEntries {
412 + mac := normalizeHexToken(entry.mac)
413 + if mac == "" {
414 + continue
415 + }
416 + out = append(out, engine.FDBObservation{
417 + MAC: mac,
418 + BridgePort: strings.TrimSpace(entry.bridgePort),
419 + Status: strings.TrimSpace(entry.status),
420 + })
421 + }
422 + sort.Slice(out, func(i, j int) bool {
423 + a, b := out[i], out[j]
424 + if a.BridgePort != b.BridgePort {
425 + return a.BridgePort < b.BridgePort
426 + }
427 + if a.MAC != b.MAC {
428 + return a.MAC < b.MAC
429 + }
430 + return a.Status < b.Status
431 + })
432 + return out
433 +}
434 +
435 +func (p parsedFixture) toARPNDEntries() []engine.ARPNDObservation {
436 + if len(p.arpEntries) == 0 {
437 + return nil
438 + }
439 + out := make([]engine.ARPNDObservation, 0, len(p.arpEntries))
440 + for _, entry := range p.sortedARPEntries() {
441 + ifIndex, err := strconv.Atoi(strings.TrimSpace(entry.ifIndex))
442 + if err != nil {
443 + ifIndex = 0
444 + }
445 + out = append(out, engine.ARPNDObservation{
446 + Protocol: "arp",
447 + IfIndex: ifIndex,
448 + IfName: p.ifNameByIndex[entry.ifIndex],
449 + IP: strings.TrimSpace(entry.ip),
450 + MAC: normalizeHexToken(entry.mac),
451 + State: strings.TrimSpace(entry.state),
452 + AddrType: strings.TrimSpace(entry.addrType),
453 + })
454 + }
455 + return out
456 +}
457 +
458 +func (p parsedFixture) toLLDPRemotes() []engine.LLDPRemoteObservation {
459 + if len(p.lldpRemotes) == 0 {
460 + return nil
461 + }
462 + obs := make([]engine.LLDPRemoteObservation, 0, len(p.lldpRemotes))
463 + for _, remote := range p.sortedLLDPRemotes() {
464 + obs = append(obs, engine.LLDPRemoteObservation{
465 + LocalPortNum: remote.localPortNum,
466 + RemoteIndex: remote.remIndex,
467 + LocalPortID: p.portIDByNum[remote.localPortNum],
468 + LocalPortIDSubtype: p.portIDSubtypeByNum[remote.localPortNum],
469 + LocalPortDesc: p.portDescByNum[remote.localPortNum],
470 + ChassisID: remote.chassisID,
471 + SysName: remote.sysName,
472 + PortID: remote.portID,
473 + PortIDSubtype: remote.portIDSubtype,
474 + PortDesc: remote.portDesc,
475 + ManagementIP: remote.mgmtIP,
476 + })
477 + }
478 + return obs
479 +}
480 +
481 +func (p parsedFixture) toCDPRemotes() []engine.CDPRemoteObservation {
482 + if len(p.cdpRemotes) == 0 {
483 + return nil
484 + }
485 + obs := make([]engine.CDPRemoteObservation, 0, len(p.cdpRemotes))
486 + for _, remote := range p.sortedCDPRemotes() {
487 + ifIndex, err := strconv.Atoi(remote.ifIndex)
488 + if err != nil {
489 + ifIndex = 0
490 + }
491 + localName := p.ifNameByIndex[remote.ifIndex]
492 + obs = append(obs, engine.CDPRemoteObservation{
493 + LocalIfIndex: ifIndex,
494 + LocalIfName: localName,
495 + DeviceIndex: remote.deviceIndex,
496 + DeviceID: remote.deviceID,
497 + DevicePort: remote.devicePort,
498 + Address: remote.address,
499 + })
500 + }
501 + return obs
502 +}
503 +
504 +func (p parsedFixture) lldpRemote(localPort, remIndex string) lldpRemoteObs {
505 + key := lldpRemoteKey(localPort, remIndex)
506 + entry := p.lldpRemotes[key]
507 + if entry.localPortNum == "" {
508 + entry.localPortNum = localPort
509 + entry.remIndex = remIndex
510 + }
511 + return entry
512 +}
513 +
514 +func (p parsedFixture) cdpRemote(ifIndex, devIndex string) cdpRemoteObs {
515 + key := cdpRemoteKey(ifIndex, devIndex)
516 + entry := p.cdpRemotes[key]
517 + if entry.ifIndex == "" {
518 + entry.ifIndex = ifIndex
519 + entry.deviceIndex = devIndex
520 + }
521 + return entry
522 +}
523 +
524 +func (p parsedFixture) sortedLLDPRemotes() []lldpRemoteObs {
525 + out := make([]lldpRemoteObs, 0, len(p.lldpRemotes))
526 + for _, rem := range p.lldpRemotes {
527 + if rem.chassisID == "" && rem.sysName == "" {
528 + continue
529 + }
530 + out = append(out, rem)
531 + }
532 + sort.Slice(out, func(i, j int) bool {
533 + a, b := out[i], out[j]
534 + if a.localPortNum != b.localPortNum {
535 + return a.localPortNum < b.localPortNum
536 + }
537 + if a.remIndex != b.remIndex {
538 + return a.remIndex < b.remIndex
539 + }
540 + if a.sysName != b.sysName {
541 + return a.sysName < b.sysName
542 + }
543 + if a.chassisID != b.chassisID {
544 + return a.chassisID < b.chassisID
545 + }
546 + if a.portID != b.portID {
547 + return a.portID < b.portID
548 + }
549 + if a.portIDSubtype != b.portIDSubtype {
550 + return a.portIDSubtype < b.portIDSubtype
551 + }
552 + if a.portDesc != b.portDesc {
553 + return a.portDesc < b.portDesc
554 + }
555 + return a.mgmtIP < b.mgmtIP
556 + })
557 + return out
558 +}
559 +
560 +func (p parsedFixture) sortedCDPRemotes() []cdpRemoteObs {
561 + out := make([]cdpRemoteObs, 0, len(p.cdpRemotes))
562 + for _, rem := range p.cdpRemotes {
563 + if rem.deviceID == "" && rem.address == "" {
564 + continue
565 + }
566 + out = append(out, rem)
567 + }
568 + sort.Slice(out, func(i, j int) bool {
569 + a, b := out[i], out[j]
570 + if a.ifIndex != b.ifIndex {
571 + return a.ifIndex < b.ifIndex
572 + }
573 + if a.deviceIndex != b.deviceIndex {
574 + return a.deviceIndex < b.deviceIndex
575 + }
576 + return a.deviceID < b.deviceID
577 + })
578 + return out
579 +}
580 +
581 +func (p parsedFixture) sortedARPEntries() []arpObs {
582 + out := make([]arpObs, 0, len(p.arpEntries))
583 + for _, entry := range p.arpEntries {
584 + if strings.TrimSpace(entry.ip) == "" && strings.TrimSpace(entry.mac) == "" {
585 + continue
586 + }
587 + out = append(out, entry)
588 + }
589 + sort.Slice(out, func(i, j int) bool {
590 + a, b := out[i], out[j]
591 + if a.ifIndex != b.ifIndex {
592 + return a.ifIndex < b.ifIndex
593 + }
594 + if a.ip != b.ip {
595 + return a.ip < b.ip
596 + }
597 + if a.mac != b.mac {
598 + return a.mac < b.mac
599 + }
600 + return a.state < b.state
601 + })
602 + return out
603 +}
604 +
605 +func normalizeHexToken(v string) string {
606 + if decoded := decodeHexBytes(v); len(decoded) > 0 {
607 + if len(decoded) == 6 {
608 + parts := make([]string, 0, 6)
609 + for _, b := range decoded {
610 + parts = append(parts, fmt.Sprintf("%02x", b))
611 + }
612 + return strings.Join(parts, ":")
613 + }
614 + if asIP := decodeHexIP(v); asIP != "" {
615 + return asIP
616 + }
617 + }
618 + return strings.TrimSpace(v)
619 +}
620 +
621 +func decodeHexIP(v string) string {
622 + bs := decodeHexBytes(v)
623 + if len(bs) == 4 {
624 + addr, ok := netip.AddrFromSlice(bs)
625 + if ok {
626 + return addr.Unmap().String()
627 + }
628 + }
629 + if len(bs) == 16 {
630 + addr, ok := netip.AddrFromSlice(bs)
631 + if ok {
632 + return addr.String()
633 + }
634 + }
635 + return ""
636 +}
637 +
638 +func decodeHexBytes(v string) []byte {
639 + clean := strings.ToLower(strings.TrimSpace(v))
640 + clean = strings.TrimPrefix(clean, "0x")
641 + if clean == "" {
642 + return nil
643 + }
644 +
645 + if strings.ContainsAny(clean, ":-. \t") {
646 + parts := strings.FieldsFunc(clean, func(r rune) bool {
647 + return r == ':' || r == '-' || r == '.' || r == ' ' || r == '\t'
648 + })
649 + if len(parts) == 0 {
650 + return nil
651 + }
652 +
653 + out := make([]byte, 0, len(parts))
654 + for _, part := range parts {
655 + part = strings.TrimSpace(part)
656 + if part == "" {
657 + continue
658 + }
659 + if len(part) > 2 {
660 + return nil
661 + }
662 + if len(part) == 1 {
663 + part = "0" + part
664 + }
665 + b, err := hex.DecodeString(part)
666 + if err != nil || len(b) != 1 {
667 + return nil
668 + }
669 + out = append(out, b[0])
670 + }
671 + if len(out) == 0 {
672 + return nil
673 + }
674 + return out
675 + }
676 +
677 + if len(clean)%2 == 1 {
678 + clean = "0" + clean
679 + }
680 + bs, err := hex.DecodeString(clean)
681 + if err != nil {
682 + return nil
683 + }
684 + return bs
685 +}
686 +
687 +func lldpRemoteIndex(oid, prefix string) (localPort string, remIndex string, ok bool) {
688 + suffix := strings.TrimPrefix(oid, prefix)
689 + suffix = strings.TrimPrefix(suffix, ".")
690 + parts := strings.Split(suffix, ".")
691 + if len(parts) < 2 {
692 + return "", "", false
693 + }
694 + return parts[len(parts)-2], parts[len(parts)-1], true
695 +}
696 +
697 +func nokiaLocalPortIndex(oid, prefix string) (localPort string, ok bool) {
698 + suffix := strings.TrimPrefix(oid, prefix)
699 + suffix = strings.TrimPrefix(suffix, ".")
700 + parts := strings.Split(suffix, ".")
701 + if len(parts) < 2 {
702 + return "", false
703 + }
704 + localPort = strings.TrimSpace(parts[0])
705 + if localPort == "" {
706 + return "", false
707 + }
708 + return localPort, true
709 +}
710 +
711 +func normalizeNokiaPortLabel(v string) string {
712 + raw := strings.TrimSpace(v)
713 + if raw == "" {
714 + return ""
715 + }
716 + parts := strings.SplitN(raw, ",", 2)
717 + label := strings.TrimSpace(parts[0])
718 + if label != "" {
719 + return label
720 + }
721 + return raw
722 +}
723 +
724 +func nokiaLldpRemoteIndex(oid, prefix string) (localPort string, remIndex string, ok bool) {
725 + suffix := strings.TrimPrefix(oid, prefix)
726 + suffix = strings.TrimPrefix(suffix, ".")
727 + parts := strings.Split(suffix, ".")
728 + if len(parts) < 3 {
729 + return "", "", false
730 + }
731 +
732 + localPos := len(parts) - 2
733 + if len(parts) >= 4 {
734 + localPos = len(parts) - 3
735 + }
736 +
737 + localPort = strings.TrimSpace(parts[localPos])
738 + remIndex = strings.TrimSpace(parts[len(parts)-1])
739 + if localPort == "" || remIndex == "" {
740 + return "", "", false
741 + }
742 + return localPort, remIndex, true
743 +}
744 +
745 +func nokiaLldpRemoteMgmtIndex(oid, prefix string) (localPort string, remIndex string, mgmtIP string, ok bool) {
746 + suffix := strings.TrimPrefix(oid, prefix)
747 + suffix = strings.TrimPrefix(suffix, ".")
748 + parts := strings.Split(suffix, ".")
749 + if len(parts) < 10 {
750 + return "", "", "", false
751 + }
752 +
753 + localPort = strings.TrimSpace(parts[1])
754 + remIndex = strings.TrimSpace(parts[3])
755 + if localPort == "" || remIndex == "" {
756 + return "", "", "", false
757 + }
758 +
759 + if strings.TrimSpace(parts[len(parts)-5]) != "4" {
760 + return "", "", "", false
761 + }
762 +
763 + octets := make([]byte, 0, 4)
764 + for _, part := range parts[len(parts)-4:] {
765 + n, err := strconv.Atoi(strings.TrimSpace(part))
766 + if err != nil || n < 0 || n > 255 {
767 + return "", "", "", false
768 + }
769 + octets = append(octets, byte(n))
770 + }
771 +
772 + addr, addrOK := netip.AddrFromSlice(octets)
773 + if !addrOK {
774 + return "", "", "", false
775 + }
776 + return localPort, remIndex, addr.Unmap().String(), true
777 +}
778 +
779 +func cdpRemoteIndex(oid, prefix string) (ifIndex string, deviceIndex string, ok bool) {
780 + suffix := strings.TrimPrefix(oid, prefix)
781 + suffix = strings.TrimPrefix(suffix, ".")
782 + parts := strings.Split(suffix, ".")
783 + if len(parts) < 2 {
784 + return "", "", false
785 + }
786 + return parts[len(parts)-2], parts[len(parts)-1], true
787 +}
788 +
789 +func fdbIndexFromOID(oid, prefix string) (key string, mac string, ok bool) {
790 + suffix := strings.TrimPrefix(oid, prefix)
791 + suffix = strings.TrimPrefix(suffix, ".")
792 + parts := strings.Split(suffix, ".")
793 + if len(parts) != 6 {
794 + return "", "", false
795 + }
796 +
797 + octets := make([]byte, 0, 6)
798 + for _, part := range parts {
799 + n, err := strconv.Atoi(strings.TrimSpace(part))
800 + if err != nil || n < 0 || n > 255 {
801 + return "", "", false
802 + }
803 + octets = append(octets, byte(n))
804 + }
805 +
806 + macParts := make([]string, 0, 6)
807 + for _, octet := range octets {
808 + macParts = append(macParts, fmt.Sprintf("%02x", octet))
809 + }
810 +
811 + return strings.Join(parts, "."), strings.Join(macParts, ":"), true
812 +}
813 +
814 +func arpLegacyIndex(oid, prefix string) (key string, ifIndex string, ip string, ok bool) {
815 + suffix := strings.TrimPrefix(oid, prefix)
816 + suffix = strings.TrimPrefix(suffix, ".")
817 + parts := strings.Split(suffix, ".")
818 + if len(parts) != 5 {
819 + return "", "", "", false
820 + }
821 +
822 + ifIndex = strings.TrimSpace(parts[0])
823 + if ifIndex == "" {
824 + return "", "", "", false
825 + }
826 +
827 + octets := make([]byte, 0, 4)
828 + for _, part := range parts[1:] {
829 + n, err := strconv.Atoi(strings.TrimSpace(part))
830 + if err != nil || n < 0 || n > 255 {
831 + return "", "", "", false
832 + }
833 + octets = append(octets, byte(n))
834 + }
835 + addr, addrOK := netip.AddrFromSlice(octets)
836 + if !addrOK {
837 + return "", "", "", false
838 + }
839 + ip = addr.Unmap().String()
840 +
841 + return ifIndex + "|" + ip, ifIndex, ip, true
842 +}
843 +
844 +func lldpRemoteKey(localPort, remIndex string) string {
845 + return localPort + ":" + remIndex
846 +}
847 +
848 +func cdpRemoteKey(ifIndex, devIndex string) string {
849 + return ifIndex + ":" + devIndex
850 +}
src/go/pkg/topology/engine/parity/l2_builder_test.go new
+3972
@@ -0,0 +1,3972 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +//go:build topology_fixtures
4 +
5 +package parity
6 +
7 +import (
8 + "fmt"
9 + "net/netip"
10 + "sort"
11 + "strconv"
12 + "strings"
13 + "testing"
14 +
15 + "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
16 + "github.com/stretchr/testify/require"
17 +)
18 +
19 +func TestBuildL2ResultFromWalks_LLDP_NMS8003(t *testing.T) {
20 + manifestPath := "../../../../testdata/snmp/enlinkd/nms8003/manifest.yaml"
21 + manifest, err := LoadManifest(manifestPath)
22 + require.NoError(t, err)
23 +
24 + scenario, ok := manifest.FindScenario("nms8003_lldp")
25 + require.True(t, ok)
26 +
27 + resolved, err := ResolveScenario(manifestPath, scenario)
28 + require.NoError(t, err)
29 +
30 + walks, err := LoadScenarioWalks(resolved)
31 + require.NoError(t, err)
32 +
33 + result, err := BuildL2ResultFromWalks(walks, BuildOptions{EnableLLDP: true})
34 + require.NoError(t, err)
35 + require.Len(t, result.Devices, 5)
36 + require.Len(t, result.Adjacencies, 12)
37 + require.Equal(t, 12, result.Stats["links_lldp"])
38 + require.Equal(t, 0, result.Stats["links_cdp"])
39 +
40 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
41 + require.NoError(t, err)
42 +
43 + expected := make(map[string]struct{}, len(golden.Adjacencies))
44 + for _, adj := range golden.Adjacencies {
45 + expected[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
46 + }
47 +
48 + actual := make(map[string]struct{}, len(result.Adjacencies))
49 + for _, adj := range result.Adjacencies {
50 + actual[adj.Protocol+"|"+adj.SourceID+"|"+adj.SourcePort+"|"+adj.TargetID+"|"+adj.TargetPort] = struct{}{}
51 + }
52 +
53 + require.Equal(t, expected, actual)
54 +}
55 +
56 +func TestBuildL2ResultFromWalks_CDP_NMS8000(t *testing.T) {
57 + manifestPath := "../../../../testdata/snmp/enlinkd/nms8000/manifest.yaml"
58 + manifest, err := LoadManifest(manifestPath)
59 + require.NoError(t, err)
60 +
61 + scenario, ok := manifest.FindScenario("nms8000_cdp")
62 + require.True(t, ok)
63 +
64 + require.False(t, scenario.Protocols.LLDP)
65 + require.True(t, scenario.Protocols.CDP)
66 + require.False(t, scenario.Protocols.Bridge)
67 + require.False(t, scenario.Protocols.ARPND)
68 + require.Equal(t, ManifestProtocols{CDP: true}, scenario.Protocols)
69 +
70 + resolved, err := ResolveScenario(manifestPath, scenario)
71 + require.NoError(t, err)
72 + require.Len(t, resolved.Fixtures, 5)
73 +
74 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableCDP: true})
75 + require.NoError(t, errStep1)
76 + require.Equal(t, 3, step1.Stats["links_cdp"])
77 +
78 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableCDP: true})
79 + require.NoError(t, errStep2)
80 + require.Equal(t, 6, step2.Stats["links_cdp"])
81 +
82 + step3, errStep3 := buildResultFromScenarioPrefix(resolved, 3, BuildOptions{EnableCDP: true})
83 + require.NoError(t, errStep3)
84 + require.Equal(t, 9, step3.Stats["links_cdp"])
85 +
86 + step4, errStep4 := buildResultFromScenarioPrefix(resolved, 4, BuildOptions{EnableCDP: true})
87 + require.NoError(t, errStep4)
88 + require.Equal(t, 11, step4.Stats["links_cdp"])
89 +
90 + step5, errStep5 := buildResultFromScenarioPrefix(resolved, 5, BuildOptions{EnableCDP: true})
91 + require.NoError(t, errStep5)
92 + require.Equal(t, 13, step5.Stats["links_cdp"])
93 +
94 + final := step5
95 + require.Len(t, final.Adjacencies, 13)
96 + require.Equal(t, 13, final.Stats["links_cdp"])
97 + require.Equal(t, 0, final.Stats["links_lldp"])
98 +
99 + expected := map[string]struct{}{
100 + "cdp|nmmr1|Gi0/0|nmmr3|GigabitEthernet0/1": {},
101 + "cdp|nmmr1|Gi0/1|nmmsw1|FastEthernet0/1": {},
102 + "cdp|nmmr1|Gi0/2|nmmsw2|FastEthernet0/2": {},
103 + "cdp|nmmr2|Gi0/0|nmmr3|GigabitEthernet0/2": {},
104 + "cdp|nmmr2|Gi0/1|nmmsw2|FastEthernet0/1": {},
105 + "cdp|nmmr2|Gi0/2|nmmsw1|FastEthernet0/2": {},
106 + "cdp|nmmr3|Gi0/0|netlabsw03.informatik.hs-fulda.de|GigabitEthernet2/0/18": {},
107 + "cdp|nmmr3|Gi0/1|nmmr1|GigabitEthernet0/0": {},
108 + "cdp|nmmr3|Gi0/2|nmmr2|GigabitEthernet0/0": {},
109 + "cdp|nmmsw1|Fa0/1|nmmr1|GigabitEthernet0/1": {},
110 + "cdp|nmmsw1|Fa0/2|nmmr2|GigabitEthernet0/2": {},
111 + "cdp|nmmsw2|Fa0/1|nmmr2|GigabitEthernet0/1": {},
112 + "cdp|nmmsw2|Fa0/2|nmmr1|GigabitEthernet0/2": {},
113 + }
114 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
115 +
116 + for _, adj := range final.Adjacencies {
117 + require.Equal(t, "cdp", adj.Protocol)
118 + raw := strings.TrimSpace(adj.Labels["remote_address_raw"])
119 + require.NotEmpty(t, raw)
120 + decoded := decodeHexIP(raw)
121 + require.NotEmpty(t, decoded)
122 + _, parseErr := netip.ParseAddr(decoded)
123 + require.NoError(t, parseErr)
124 + }
125 +}
126 +
127 +func TestBuildL2ResultFromWalks_LLDP_NMS8000(t *testing.T) {
128 + manifestPath := "../../../../testdata/snmp/enlinkd/nms8000/manifest.yaml"
129 + manifest, err := LoadManifest(manifestPath)
130 + require.NoError(t, err)
131 +
132 + scenario, ok := manifest.FindScenario("nms8000_lldp")
133 + require.True(t, ok)
134 +
135 + require.True(t, scenario.Protocols.LLDP)
136 + require.False(t, scenario.Protocols.CDP)
137 + require.False(t, scenario.Protocols.Bridge)
138 + require.False(t, scenario.Protocols.ARPND)
139 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
140 +
141 + resolved, err := ResolveScenario(manifestPath, scenario)
142 + require.NoError(t, err)
143 + require.Len(t, resolved.Fixtures, 5)
144 +
145 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
146 + require.NoError(t, errStep1)
147 + require.Equal(t, 3, step1.Stats["links_lldp"])
148 +
149 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
150 + require.NoError(t, errStep2)
151 + require.Equal(t, 6, step2.Stats["links_lldp"])
152 +
153 + step3, errStep3 := buildResultFromScenarioPrefix(resolved, 3, BuildOptions{EnableLLDP: true})
154 + require.NoError(t, errStep3)
155 + require.Equal(t, 8, step3.Stats["links_lldp"])
156 +
157 + step4, errStep4 := buildResultFromScenarioPrefix(resolved, 4, BuildOptions{EnableLLDP: true})
158 + require.NoError(t, errStep4)
159 + require.Equal(t, 10, step4.Stats["links_lldp"])
160 +
161 + step5, errStep5 := buildResultFromScenarioPrefix(resolved, 5, BuildOptions{EnableLLDP: true})
162 + require.NoError(t, errStep5)
163 + require.Equal(t, 12, step5.Stats["links_lldp"])
164 +
165 + final := step5
166 + require.Len(t, final.Adjacencies, 12)
167 + require.Equal(t, 12, final.Stats["links_lldp"])
168 + require.Equal(t, 0, final.Stats["links_cdp"])
169 +
170 + expected := map[string]struct{}{
171 + "lldp|nmmr1|Gi0/0|nmmr3|Gi0/1": {},
172 + "lldp|nmmr1|Gi0/1|nmmsw1|Fa0/1": {},
173 + "lldp|nmmr1|Gi0/2|nmmsw2|Fa0/2": {},
174 + "lldp|nmmr2|Gi0/0|nmmr3|Gi0/2": {},
175 + "lldp|nmmr2|Gi0/1|nmmsw2|Fa0/1": {},
176 + "lldp|nmmr2|Gi0/2|nmmsw1|Fa0/2": {},
177 + "lldp|nmmr3|Gi0/1|nmmr1|Gi0/0": {},
178 + "lldp|nmmr3|Gi0/2|nmmr2|Gi0/0": {},
179 + "lldp|nmmsw1|Fa0/1|nmmr1|Gi0/1": {},
180 + "lldp|nmmsw1|Fa0/2|nmmr2|Gi0/2": {},
181 + "lldp|nmmsw2|Fa0/1|nmmr2|Gi0/1": {},
182 + "lldp|nmmsw2|Fa0/2|nmmr1|Gi0/2": {},
183 + }
184 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
185 + require.Equal(t, 6, countBidirectionalPairs(final.Adjacencies, "lldp"))
186 +}
187 +
188 +func TestBuildL2ResultFromWalks_LLDP_NMS13637(t *testing.T) {
189 + manifestPath := "../../../../testdata/snmp/enlinkd/nms13637/manifest.yaml"
190 + manifest, err := LoadManifest(manifestPath)
191 + require.NoError(t, err)
192 +
193 + scenario, ok := manifest.FindScenario("nms13637_lldp")
194 + require.True(t, ok)
195 +
196 + require.True(t, scenario.Protocols.LLDP)
197 + require.False(t, scenario.Protocols.CDP)
198 + require.False(t, scenario.Protocols.Bridge)
199 + require.False(t, scenario.Protocols.ARPND)
200 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
201 +
202 + resolved, err := ResolveScenario(manifestPath, scenario)
203 + require.NoError(t, err)
204 + require.Len(t, resolved.Fixtures, 3)
205 +
206 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
207 + require.NoError(t, errStep1)
208 + require.Equal(t, 5, step1.Stats["links_lldp"])
209 +
210 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
211 + require.NoError(t, errStep2)
212 + require.Equal(t, 10, step2.Stats["links_lldp"])
213 +
214 + step3, errStep3 := buildResultFromScenarioPrefix(resolved, 3, BuildOptions{EnableLLDP: true})
215 + require.NoError(t, errStep3)
216 + require.Equal(t, 19, step3.Stats["links_lldp"])
217 +
218 + final := step3
219 + require.Len(t, final.Adjacencies, 19)
220 + require.Equal(t, 19, final.Stats["links_lldp"])
221 + require.Equal(t, 0, final.Stats["links_cdp"])
222 +
223 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
224 + require.NoError(t, err)
225 +
226 + expected := make(map[string]struct{}, len(golden.Adjacencies))
227 + for _, adj := range golden.Adjacencies {
228 + expected[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
229 + }
230 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
231 +
232 + localDevices := map[string]struct{}{
233 + "router-1": {},
234 + "router-2": {},
235 + "sw01-office": {},
236 + }
237 + require.Equal(t, 3, countUndirectedLocalDevicePairs(final.Adjacencies, "lldp", localDevices))
238 + require.Equal(t, 3, countLocalTopologyVertices(final.Adjacencies, "lldp", localDevices))
239 +}
240 +
241 +func TestBuildL2ResultFromWalks_LLDP_NMS10205B(t *testing.T) {
242 + manifestPath := "../../../../testdata/snmp/enlinkd/nms10205b/manifest.yaml"
243 + manifest, err := LoadManifest(manifestPath)
244 + require.NoError(t, err)
245 +
246 + scenario, ok := manifest.FindScenario("nms10205b_lldp")
247 + require.True(t, ok)
248 +
249 + require.True(t, scenario.Protocols.LLDP)
250 + require.False(t, scenario.Protocols.CDP)
251 + require.False(t, scenario.Protocols.Bridge)
252 + require.False(t, scenario.Protocols.ARPND)
253 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
254 +
255 + resolved, err := ResolveScenario(manifestPath, scenario)
256 + require.NoError(t, err)
257 + require.Len(t, resolved.Fixtures, 9)
258 +
259 + type stepExpectation struct {
260 + fixtureID string
261 + totalLinks int
262 + sourceLinks int
263 + lldpElementCnt int
264 + }
265 +
266 + expectations := []stepExpectation{
267 + {fixtureID: "Mumbai", totalLinks: 0, sourceLinks: 0, lldpElementCnt: 0},
268 + {fixtureID: "Delhi", totalLinks: 2, sourceLinks: 2, lldpElementCnt: 1},
269 + {fixtureID: "Bangalore", totalLinks: 2, sourceLinks: 0, lldpElementCnt: 1},
270 + {fixtureID: "Bagmane", totalLinks: 5, sourceLinks: 3, lldpElementCnt: 2},
271 + {fixtureID: "Mysore", totalLinks: 5, sourceLinks: 0, lldpElementCnt: 2},
272 + {fixtureID: "Space-EX-SW1", totalLinks: 8, sourceLinks: 3, lldpElementCnt: 3},
273 + {fixtureID: "Space-EX-SW2", totalLinks: 10, sourceLinks: 2, lldpElementCnt: 4},
274 + {fixtureID: "J6350-42", totalLinks: 10, sourceLinks: 0, lldpElementCnt: 5},
275 + {fixtureID: "SRX-100", totalLinks: 10, sourceLinks: 0, lldpElementCnt: 6},
276 + }
277 +
278 + var final engine.Result
279 + for i, expected := range expectations {
280 + walks, walkErr := loadScenarioWalkPrefix(resolved, i+1)
281 + require.NoError(t, walkErr)
282 +
283 + result, buildErr := BuildL2ResultFromWalks(walks, BuildOptions{EnableLLDP: true})
284 + require.NoError(t, buildErr)
285 +
286 + sourceCounts := countAdjacenciesBySource(result.Adjacencies, "lldp")
287 + require.Equal(t, expected.sourceLinks, sourceCounts[expected.fixtureID])
288 + require.Equal(t, expected.totalLinks, result.Stats["links_lldp"])
289 + require.Equal(t, expected.lldpElementCnt, countFixturesWithLLDPLocalElements(walks))
290 + final = result
291 + }
292 +
293 + require.Equal(t, 10, final.Stats["links_lldp"])
294 + require.Equal(t, 0, final.Stats["links_cdp"])
295 + allWalks, err := loadScenarioWalkPrefix(resolved, len(resolved.Fixtures))
296 + require.NoError(t, err)
297 + require.Equal(t, 6, countFixturesWithLLDPLocalElements(allWalks))
298 + require.Len(t, final.Adjacencies, 10)
299 + require.Equal(t, 3, countBidirectionalPairs(final.Adjacencies, "lldp"))
300 +
301 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
302 + require.NoError(t, err)
303 +
304 + expected := make(map[string]struct{}, len(golden.Adjacencies))
305 + for _, adj := range golden.Adjacencies {
306 + expected[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
307 + }
308 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
309 +
310 + finalKeys := adjacencyKeySet(final.Adjacencies)
311 + require.Contains(t, finalKeys, "lldp|Delhi|28519|Bagmane|513")
312 + require.Contains(t, finalKeys, "lldp|Delhi|28520|Space-EX-SW1|528")
313 + require.Contains(t, finalKeys, "lldp|Space-EX-SW1|1361|Space-EX-SW2|531")
314 +
315 + topologyEdges := make(map[string]engine.Adjacency, len(final.Adjacencies))
316 + for _, adj := range final.Adjacencies {
317 + if adj.Protocol != "lldp" {
318 + continue
319 + }
320 + topologyEdges[adj.SourceID+"|"+adj.TargetID] = adj
321 + }
322 +
323 + edgeDelhiBagmane, ok := topologyEdges["Delhi|Bagmane"]
324 + require.True(t, ok)
325 + require.Equal(t, "28519", edgeDelhiBagmane.SourcePort)
326 + require.Equal(t, "513", edgeDelhiBagmane.TargetPort)
327 +
328 + edgeDelhiSpaceEXSW1, ok := topologyEdges["Delhi|Space-EX-SW1"]
329 + require.True(t, ok)
330 + require.Equal(t, "28520", edgeDelhiSpaceEXSW1.SourcePort)
331 + require.Equal(t, "528", edgeDelhiSpaceEXSW1.TargetPort)
332 +
333 + edgeSpaceEXSW1SpaceEXSW2, ok := topologyEdges["Space-EX-SW1|Space-EX-SW2"]
334 + require.True(t, ok)
335 + require.Equal(t, "1361", edgeSpaceEXSW1SpaceEXSW2.SourcePort)
336 + require.Equal(t, "531", edgeSpaceEXSW1SpaceEXSW2.TargetPort)
337 +}
338 +
339 +func TestBuildL2ResultFromWalks_LLDP_NMS17216(t *testing.T) {
340 + manifestPath := "../../../../testdata/snmp/enlinkd/nms17216/manifest.yaml"
341 + manifest, err := LoadManifest(manifestPath)
342 + require.NoError(t, err)
343 +
344 + scenario, ok := manifest.FindScenario("nms17216_lldp")
345 + require.True(t, ok)
346 +
347 + require.True(t, scenario.Protocols.LLDP)
348 + require.False(t, scenario.Protocols.CDP)
349 + require.False(t, scenario.Protocols.Bridge)
350 + require.False(t, scenario.Protocols.ARPND)
351 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
352 +
353 + resolved, err := ResolveScenario(manifestPath, scenario)
354 + require.NoError(t, err)
355 + require.Len(t, resolved.Fixtures, 5)
356 +
357 + type stepExpectation struct {
358 + fixtureID string
359 + totalLinks int
360 + sourceLinks int
361 + lldpElementCnt int
362 + }
363 +
364 + expectations := []stepExpectation{
365 + {fixtureID: "Switch1", totalLinks: 4, sourceLinks: 4, lldpElementCnt: 1},
366 + {fixtureID: "Switch2", totalLinks: 10, sourceLinks: 6, lldpElementCnt: 2},
367 + {fixtureID: "Switch3", totalLinks: 12, sourceLinks: 2, lldpElementCnt: 3},
368 + {fixtureID: "Switch4", totalLinks: 12, sourceLinks: 0, lldpElementCnt: 4},
369 + {fixtureID: "Switch5", totalLinks: 12, sourceLinks: 0, lldpElementCnt: 5},
370 + }
371 +
372 + var final engine.Result
373 + for i, expected := range expectations {
374 + walks, walkErr := loadScenarioWalkPrefix(resolved, i+1)
375 + require.NoError(t, walkErr)
376 +
377 + result, buildErr := BuildL2ResultFromWalks(walks, BuildOptions{EnableLLDP: true})
378 + require.NoError(t, buildErr)
379 +
380 + sourceCounts := countAdjacenciesBySource(result.Adjacencies, "lldp")
381 + require.Equal(t, expected.totalLinks, result.Stats["links_lldp"])
382 + require.Equal(t, expected.sourceLinks, sourceCounts[expected.fixtureID])
383 + require.Equal(t, expected.lldpElementCnt, countFixturesWithLLDPLocalElements(walks))
384 + final = result
385 + }
386 +
387 + require.Len(t, final.Devices, 5)
388 + require.Len(t, final.Adjacencies, 12)
389 + require.Equal(t, 12, final.Stats["links_lldp"])
390 + require.Equal(t, 0, final.Stats["links_cdp"])
391 + require.Equal(t, 6, countBidirectionalPairs(final.Adjacencies, "lldp"))
392 +
393 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
394 + require.NoError(t, err)
395 +
396 + expected := make(map[string]struct{}, len(golden.Adjacencies))
397 + for _, adj := range golden.Adjacencies {
398 + expected[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
399 + }
400 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
401 +}
402 +
403 +func TestBuildL2ResultFromWalks_CDP_NMS17216(t *testing.T) {
404 + manifestPath := "../../../../testdata/snmp/enlinkd/nms17216/manifest.yaml"
405 + manifest, err := LoadManifest(manifestPath)
406 + require.NoError(t, err)
407 +
408 + scenario, ok := manifest.FindScenario("nms17216_cdp")
409 + require.True(t, ok)
410 +
411 + require.False(t, scenario.Protocols.LLDP)
412 + require.True(t, scenario.Protocols.CDP)
413 + require.False(t, scenario.Protocols.Bridge)
414 + require.False(t, scenario.Protocols.ARPND)
415 + require.Equal(t, ManifestProtocols{CDP: true}, scenario.Protocols)
416 +
417 + resolved, err := ResolveScenario(manifestPath, scenario)
418 + require.NoError(t, err)
419 + require.Len(t, resolved.Fixtures, 9)
420 +
421 + type stepExpectation struct {
422 + fixtureID string
423 + totalLinks int
424 + sourceLinks int
425 + }
426 +
427 + expectations := []stepExpectation{
428 + {fixtureID: "Switch1", totalLinks: 5, sourceLinks: 5},
429 + {fixtureID: "Switch2", totalLinks: 11, sourceLinks: 6},
430 + {fixtureID: "Switch3", totalLinks: 15, sourceLinks: 4},
431 + {fixtureID: "Switch4", totalLinks: 16, sourceLinks: 1},
432 + {fixtureID: "Switch5", totalLinks: 18, sourceLinks: 2},
433 + {fixtureID: "Router1", totalLinks: 20, sourceLinks: 2},
434 + {fixtureID: "Router2", totalLinks: 22, sourceLinks: 2},
435 + {fixtureID: "Router3", totalLinks: 25, sourceLinks: 3},
436 + {fixtureID: "Router4", totalLinks: 26, sourceLinks: 1},
437 + }
438 +
439 + var final engine.Result
440 + for i, expected := range expectations {
441 + walks, walkErr := loadScenarioWalkPrefix(resolved, i+1)
442 + require.NoError(t, walkErr)
443 +
444 + result, buildErr := BuildL2ResultFromWalks(walks, BuildOptions{EnableCDP: true})
445 + require.NoError(t, buildErr)
446 +
447 + sourceCounts := countAdjacenciesBySource(result.Adjacencies, "cdp")
448 + require.Equal(t, expected.totalLinks, result.Stats["links_cdp"])
449 + require.Equal(t, expected.sourceLinks, sourceCounts[expected.fixtureID])
450 + final = result
451 + }
452 +
453 + require.Len(t, final.Devices, 9)
454 + require.Len(t, final.Adjacencies, 26)
455 + require.Equal(t, 26, final.Stats["links_cdp"])
456 + require.Equal(t, 0, final.Stats["links_lldp"])
457 +
458 + require.Equal(t, map[string]int{
459 + "Switch1": 5,
460 + "Switch2": 6,
461 + "Switch3": 4,
462 + "Switch4": 1,
463 + "Switch5": 2,
464 + "Router1": 2,
465 + "Router2": 2,
466 + "Router3": 3,
467 + "Router4": 1,
468 + }, countAdjacenciesBySource(final.Adjacencies, "cdp"))
469 +
470 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
471 + require.NoError(t, err)
472 +
473 + expected := make(map[string]struct{}, len(golden.Adjacencies))
474 + for _, adj := range golden.Adjacencies {
475 + expected[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
476 + }
477 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
478 +}
479 +
480 +func TestBuildL2ResultFromWalks_CDP_NMS17216_TopologyProjection(t *testing.T) {
481 + manifestPath := "../../../../testdata/snmp/enlinkd/nms17216/manifest.yaml"
482 + manifest, err := LoadManifest(manifestPath)
483 + require.NoError(t, err)
484 +
485 + scenario, ok := manifest.FindScenario("nms17216_cdp")
486 + require.True(t, ok)
487 +
488 + require.False(t, scenario.Protocols.LLDP)
489 + require.True(t, scenario.Protocols.CDP)
490 + require.False(t, scenario.Protocols.Bridge)
491 + require.False(t, scenario.Protocols.ARPND)
492 + require.Equal(t, ManifestProtocols{CDP: true}, scenario.Protocols)
493 +
494 + resolved, err := ResolveScenario(manifestPath, scenario)
495 + require.NoError(t, err)
496 + require.Len(t, resolved.Fixtures, 9)
497 +
498 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableCDP: true})
499 + require.NoError(t, errStep1)
500 + require.Equal(t, 5, step1.Stats["links_cdp"])
501 + require.Equal(t, 0, step1.Stats["links_lldp"])
502 +
503 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableCDP: true})
504 + require.NoError(t, errStep2)
505 + require.Equal(t, 11, step2.Stats["links_cdp"])
506 + require.Equal(t, 0, step2.Stats["links_lldp"])
507 + require.Equal(t, map[string]int{
508 + "Switch1": 5,
509 + "Switch2": 6,
510 + }, countAdjacenciesBySource(step2.Adjacencies, "cdp"))
511 +
512 + localDevices := map[string]struct{}{
513 + "Switch1": {},
514 + "Switch2": {},
515 + }
516 + require.Equal(t, 2, countLocalTopologyVertices(step2.Adjacencies, "cdp", localDevices))
517 + require.Equal(t, 1, countUndirectedLocalDevicePairs(step2.Adjacencies, "cdp", localDevices))
518 + require.Equal(t, 4, countMutualLocalAdjacencyEdges(step2.Adjacencies, "cdp", localDevices))
519 +}
520 +
521 +func TestBuildL2ResultFromWalks_LLDP_NMS17216_TopologyProjection(t *testing.T) {
522 + manifestPath := "../../../../testdata/snmp/enlinkd/nms17216/manifest.yaml"
523 + manifest, err := LoadManifest(manifestPath)
524 + require.NoError(t, err)
525 +
526 + scenario, ok := manifest.FindScenario("nms17216_lldp")
527 + require.True(t, ok)
528 +
529 + require.True(t, scenario.Protocols.LLDP)
530 + require.False(t, scenario.Protocols.CDP)
531 + require.False(t, scenario.Protocols.Bridge)
532 + require.False(t, scenario.Protocols.ARPND)
533 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
534 +
535 + resolved, err := ResolveScenario(manifestPath, scenario)
536 + require.NoError(t, err)
537 + require.Len(t, resolved.Fixtures, 5)
538 +
539 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
540 + require.NoError(t, errStep1)
541 + require.Equal(t, 4, step1.Stats["links_lldp"])
542 + require.Equal(t, 0, step1.Stats["links_cdp"])
543 +
544 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
545 + require.NoError(t, errStep2)
546 + require.Equal(t, 10, step2.Stats["links_lldp"])
547 + require.Equal(t, 0, step2.Stats["links_cdp"])
548 +
549 + localStep2 := map[string]struct{}{
550 + "Switch1": {},
551 + "Switch2": {},
552 + }
553 + require.Equal(t, 2, countLocalTopologyVertices(step2.Adjacencies, "lldp", localStep2))
554 + require.Equal(t, 1, countUndirectedLocalDevicePairs(step2.Adjacencies, "lldp", localStep2))
555 + require.Equal(t, 4, countMutualLocalAdjacencyEdges(step2.Adjacencies, "lldp", localStep2))
556 +
557 + step3Walks, errStep3Walks := loadScenarioWalkPrefix(resolved, 3)
558 + require.NoError(t, errStep3Walks)
559 + require.Equal(t, 3, countFixturesWithLLDPLocalElements(step3Walks))
560 +
561 + step3, errStep3 := BuildL2ResultFromWalks(step3Walks, BuildOptions{EnableLLDP: true})
562 + require.NoError(t, errStep3)
563 + require.Equal(t, 12, step3.Stats["links_lldp"])
564 + require.Equal(t, 0, step3.Stats["links_cdp"])
565 +
566 + localStep3 := map[string]struct{}{
567 + "Switch1": {},
568 + "Switch2": {},
569 + "Switch3": {},
570 + }
571 + require.Equal(t, 3, countLocalTopologyVertices(step3.Adjacencies, "lldp", localStep3))
572 + require.Equal(t, 2, countUndirectedLocalDevicePairs(step3.Adjacencies, "lldp", localStep3))
573 + require.Equal(t, 6, countMutualLocalAdjacencyEdges(step3.Adjacencies, "lldp", localStep3))
574 +}
575 +
576 +func TestBuildL2ResultFromWalks_LLDP_NMS0123(t *testing.T) {
577 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0123/manifest.yaml"
578 + manifest, err := LoadManifest(manifestPath)
579 + require.NoError(t, err)
580 +
581 + scenario, ok := manifest.FindScenario("nms0123_lldp")
582 + require.True(t, ok)
583 +
584 + require.True(t, scenario.Protocols.LLDP)
585 + require.False(t, scenario.Protocols.CDP)
586 + require.False(t, scenario.Protocols.Bridge)
587 + require.False(t, scenario.Protocols.ARPND)
588 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
589 +
590 + resolved, err := ResolveScenario(manifestPath, scenario)
591 + require.NoError(t, err)
592 + require.Len(t, resolved.Fixtures, 8)
593 +
594 + type stepExpectation struct {
595 + fixtureID string
596 + totalLinks int
597 + }
598 +
599 + expectations := []stepExpectation{
600 + {fixtureID: "ITPN0111", totalLinks: 4},
601 + {fixtureID: "ITPN0112", totalLinks: 6},
602 + {fixtureID: "ITPN0113", totalLinks: 8},
603 + {fixtureID: "ITPN0114", totalLinks: 10},
604 + {fixtureID: "ITPN0121", totalLinks: 11},
605 + {fixtureID: "ITPN0123", totalLinks: 14},
606 + {fixtureID: "ITPN0201", totalLinks: 20},
607 + {fixtureID: "ITPN0202", totalLinks: 23},
608 + }
609 +
610 + var final engine.Result
611 + for i, expected := range expectations {
612 + walks, walkErr := loadScenarioWalkPrefix(resolved, i+1)
613 + require.NoError(t, walkErr)
614 +
615 + result, buildErr := BuildL2ResultFromWalks(walks, BuildOptions{EnableLLDP: true})
616 + require.NoError(t, buildErr)
617 +
618 + require.Equal(t, expected.totalLinks, result.Stats["links_lldp"])
619 + final = result
620 + }
621 +
622 + allWalks, err := loadScenarioWalkPrefix(resolved, len(resolved.Fixtures))
623 + require.NoError(t, err)
624 +
625 + require.Len(t, final.Adjacencies, 23)
626 + require.Equal(t, 23, final.Stats["links_lldp"])
627 + require.Equal(t, 0, final.Stats["links_cdp"])
628 + require.Equal(t, 8, countFixturesWithLLDPLocalElements(allWalks))
629 +
630 + localDevices := map[string]struct{}{
631 + "ITPN0111": {},
632 + "ITPN0112": {},
633 + "ITPN0113": {},
634 + "ITPN0114": {},
635 + "ITPN0121": {},
636 + "ITPN0123": {},
637 + "ITPN0201": {},
638 + "ITPN0202": {},
639 + }
640 + require.Equal(t, 8, countLocalTopologyVertices(final.Adjacencies, "lldp", localDevices))
641 + require.Equal(t, 8, countUndirectedLocalDevicePairs(final.Adjacencies, "lldp", localDevices))
642 +
643 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
644 + require.NoError(t, err)
645 +
646 + expected := make(map[string]struct{}, len(golden.Adjacencies))
647 + for _, adj := range golden.Adjacencies {
648 + expected[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
649 + }
650 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
651 +}
652 +
653 +func TestBuildL2ResultFromWalks_LLDP_NMS0002_CISCO_JUNIPER(t *testing.T) {
654 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0002/manifest.yaml"
655 + manifest, err := LoadManifest(manifestPath)
656 + require.NoError(t, err)
657 +
658 + scenario, ok := manifest.FindScenario("nms0002_cisco_juniper_lldp")
659 + require.True(t, ok)
660 +
661 + enableOSPF := false
662 + enableISIS := false
663 + require.True(t, scenario.Protocols.LLDP)
664 + require.False(t, scenario.Protocols.CDP)
665 + require.False(t, enableOSPF)
666 + require.False(t, scenario.Protocols.Bridge)
667 + require.False(t, enableISIS)
668 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
669 +
670 + resolved, err := ResolveScenario(manifestPath, scenario)
671 + require.NoError(t, err)
672 + require.Len(t, resolved.Fixtures, 2)
673 +
674 + preCollectionLinks := 0
675 + require.Equal(t, 0, preCollectionLinks)
676 +
677 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
678 + require.NoError(t, errStep1)
679 + require.Equal(t, 1, step1.Stats["links_lldp"])
680 +
681 + step2First, errStep2First := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
682 + require.NoError(t, errStep2First)
683 +
684 + step2Second, errStep2Second := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
685 + require.NoError(t, errStep2Second)
686 + require.Equal(t, step2First.Stats["links_lldp"], step2Second.Stats["links_lldp"])
687 +
688 + final := step2Second
689 + require.Len(t, final.Adjacencies, 2)
690 + require.Equal(t, 2, final.Stats["links_lldp"])
691 + require.Equal(t, 0, final.Stats["links_cdp"])
692 +
693 + require.Equal(t, map[string]int{
694 + "Rluck001": 1,
695 + "Sluck001": 1,
696 + }, countAdjacenciesBySource(final.Adjacencies, "lldp"))
697 +
698 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
699 + require.NoError(t, err)
700 +
701 + expected := make(map[string]struct{}, len(golden.Adjacencies))
702 + for _, adj := range golden.Adjacencies {
703 + expected[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
704 + }
705 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
706 +}
707 +
708 +func TestBuildL2ResultFromWalks_LLDP_NMS0002_CISCO_ALCATEL(t *testing.T) {
709 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0002/manifest.yaml"
710 + manifest, err := LoadManifest(manifestPath)
711 + require.NoError(t, err)
712 +
713 + scenario, ok := manifest.FindScenario("nms0002_cisco_alcatel_lldp")
714 + require.True(t, ok)
715 +
716 + enableOSPF := false
717 + enableISIS := false
718 + require.True(t, scenario.Protocols.LLDP)
719 + require.False(t, scenario.Protocols.CDP)
720 + require.False(t, enableOSPF)
721 + require.False(t, scenario.Protocols.Bridge)
722 + require.False(t, enableISIS)
723 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
724 +
725 + resolved, err := ResolveScenario(manifestPath, scenario)
726 + require.NoError(t, err)
727 + require.Len(t, resolved.Fixtures, 5)
728 +
729 + preCollectionLinks := 0
730 + require.Equal(t, 0, preCollectionLinks)
731 +
732 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
733 + require.NoError(t, errStep1)
734 + require.Equal(t, 0, step1.Stats["links_lldp"])
735 +
736 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
737 + require.NoError(t, errStep2)
738 + require.Equal(t, 1, step2.Stats["links_lldp"])
739 +
740 + step3, errStep3 := buildResultFromScenarioPrefix(resolved, 3, BuildOptions{EnableLLDP: true})
741 + require.NoError(t, errStep3)
742 + require.Equal(t, 2, step3.Stats["links_lldp"])
743 +
744 + step4, errStep4 := buildResultFromScenarioPrefix(resolved, 4, BuildOptions{EnableLLDP: true})
745 + require.NoError(t, errStep4)
746 + require.Equal(t, 4, step4.Stats["links_lldp"])
747 +
748 + step5, errStep5 := buildResultFromScenarioPrefix(resolved, 5, BuildOptions{EnableLLDP: true})
749 + require.NoError(t, errStep5)
750 +
751 + final := step5
752 + require.Len(t, final.Adjacencies, 6)
753 + require.Equal(t, 6, final.Stats["links_lldp"])
754 + require.Equal(t, 0, final.Stats["links_cdp"])
755 +
756 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
757 + require.NoError(t, err)
758 +
759 + expected := make(map[string]struct{}, len(golden.Adjacencies))
760 + for _, adj := range golden.Adjacencies {
761 + expected[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
762 + }
763 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
764 +}
765 +
766 +func TestBuildL2ResultFromWalks_LLDP_NMS0000_NETWORK_ALL(t *testing.T) {
767 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0000/manifest.yaml"
768 + manifest, err := LoadManifest(manifestPath)
769 + require.NoError(t, err)
770 +
771 + scenario, ok := manifest.FindScenario("nms0000_network_all_lldp")
772 + require.True(t, ok)
773 +
774 + enableOSPF := false
775 + enableISIS := false
776 + require.True(t, scenario.Protocols.LLDP)
777 + require.False(t, scenario.Protocols.CDP)
778 + require.False(t, enableOSPF)
779 + require.False(t, scenario.Protocols.Bridge)
780 + require.False(t, enableISIS)
781 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
782 +
783 + resolved, err := ResolveScenario(manifestPath, scenario)
784 + require.NoError(t, err)
785 +
786 + expectedFixtureIDs := []string{
787 + "ms01",
788 + "ms02",
789 + "ms03",
790 + "ms04",
791 + "ms05",
792 + "ms06",
793 + "ms07",
794 + "ms08",
795 + "ms09",
796 + "ms10",
797 + "ms11",
798 + "ms12",
799 + "ms14",
800 + "ms15",
801 + "ms16",
802 + "ms17",
803 + "ms18",
804 + "ms19",
805 + }
806 + require.Len(t, resolved.Fixtures, len(expectedFixtureIDs))
807 + for i, fixture := range resolved.Fixtures {
808 + require.Equal(t, expectedFixtureIDs[i], fixture.DeviceID)
809 + }
810 +
811 + allWalks, err := LoadScenarioWalks(resolved)
812 + require.NoError(t, err)
813 + require.Len(t, allWalks, len(expectedFixtureIDs))
814 +
815 + preCollectionLinks := 0
816 + preCollectionElements := 0
817 + require.Equal(t, 0, preCollectionLinks)
818 + require.Equal(t, 0, preCollectionElements)
819 +
820 + type stepExpectation struct {
821 + fixtureID string
822 + totalLinks int
823 + sourceLinks int
824 + lldpElementCnt int
825 + }
826 +
827 + expectations := []stepExpectation{
828 + {fixtureID: "ms01", totalLinks: 4, sourceLinks: 4, lldpElementCnt: 1},
829 + {fixtureID: "ms02", totalLinks: 8, sourceLinks: 4, lldpElementCnt: 2},
830 + {fixtureID: "ms03", totalLinks: 11, sourceLinks: 3, lldpElementCnt: 3},
831 + {fixtureID: "ms04", totalLinks: 15, sourceLinks: 4, lldpElementCnt: 4},
832 + {fixtureID: "ms05", totalLinks: 19, sourceLinks: 4, lldpElementCnt: 5},
833 + {fixtureID: "ms06", totalLinks: 23, sourceLinks: 4, lldpElementCnt: 6},
834 + {fixtureID: "ms07", totalLinks: 26, sourceLinks: 3, lldpElementCnt: 7},
835 + {fixtureID: "ms08", totalLinks: 31, sourceLinks: 5, lldpElementCnt: 8},
836 + {fixtureID: "ms09", totalLinks: 36, sourceLinks: 5, lldpElementCnt: 9},
837 + {fixtureID: "ms10", totalLinks: 41, sourceLinks: 5, lldpElementCnt: 10},
838 + {fixtureID: "ms11", totalLinks: 45, sourceLinks: 4, lldpElementCnt: 11},
839 + {fixtureID: "ms12", totalLinks: 51, sourceLinks: 6, lldpElementCnt: 12},
840 + {fixtureID: "ms14", totalLinks: 55, sourceLinks: 4, lldpElementCnt: 13},
841 + {fixtureID: "ms15", totalLinks: 56, sourceLinks: 1, lldpElementCnt: 14},
842 + {fixtureID: "ms16", totalLinks: 61, sourceLinks: 5, lldpElementCnt: 15},
843 + {fixtureID: "ms17", totalLinks: 65, sourceLinks: 4, lldpElementCnt: 16},
844 + {fixtureID: "ms18", totalLinks: 70, sourceLinks: 5, lldpElementCnt: 17},
845 + {fixtureID: "ms19", totalLinks: 73, sourceLinks: 3, lldpElementCnt: 18},
846 + }
847 +
848 + var final engine.Result
849 + for i, expected := range expectations {
850 + walks, walkErr := loadScenarioWalkPrefix(resolved, i+1)
851 + require.NoError(t, walkErr)
852 +
853 + result, buildErr := BuildL2ResultFromWalks(walks, BuildOptions{EnableLLDP: true})
854 + require.NoError(t, buildErr)
855 +
856 + sourceCounts := countAdjacenciesBySource(result.Adjacencies, "lldp")
857 + require.Equal(t, expected.totalLinks, result.Stats["links_lldp"])
858 + require.Equal(t, expected.sourceLinks, sourceCounts[expected.fixtureID])
859 + require.Equal(t, expected.lldpElementCnt, countFixturesWithLLDPLocalElements(allWalks[:i+1]))
860 + final = result
861 + }
862 +
863 + require.Len(t, final.Adjacencies, 73)
864 + require.Equal(t, 73, final.Stats["links_lldp"])
865 + require.Equal(t, 0, final.Stats["links_cdp"])
866 + require.Equal(t, 18, countFixturesWithLLDPLocalElements(allWalks))
867 +
868 + localDevices := map[string]struct{}{
869 + "ms01": {},
870 + "ms02": {},
871 + "ms03": {},
872 + "ms04": {},
873 + "ms05": {},
874 + "ms06": {},
875 + "ms07": {},
876 + "ms08": {},
877 + "ms09": {},
878 + "ms10": {},
879 + "ms11": {},
880 + "ms12": {},
881 + "ms14": {},
882 + "ms15": {},
883 + "ms16": {},
884 + "ms17": {},
885 + "ms18": {},
886 + "ms19": {},
887 + }
888 + require.Equal(t, 18, countLocalTopologyVertices(final.Adjacencies, "lldp", localDevices))
889 + require.Equal(t, 17, countUndirectedLocalDevicePairs(final.Adjacencies, "lldp", localDevices))
890 +
891 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
892 + require.NoError(t, err)
893 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(final.Adjacencies))
894 +}
895 +
896 +func TestBuildL2ResultFromWalks_LLDP_NMS0000_NETWORK_TWO_CONNECTED(t *testing.T) {
897 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0000/manifest.yaml"
898 + manifest, err := LoadManifest(manifestPath)
899 + require.NoError(t, err)
900 +
901 + scenario, ok := manifest.FindScenario("nms0000_network_two_connected_lldp")
902 + require.True(t, ok)
903 +
904 + enableOSPF := false
905 + enableISIS := false
906 + require.True(t, scenario.Protocols.LLDP)
907 + require.False(t, scenario.Protocols.CDP)
908 + require.False(t, enableOSPF)
909 + require.False(t, scenario.Protocols.Bridge)
910 + require.False(t, enableISIS)
911 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
912 +
913 + resolved, err := ResolveScenario(manifestPath, scenario)
914 + require.NoError(t, err)
915 + require.Len(t, resolved.Fixtures, 2)
916 + require.Equal(t, "ms07", resolved.Fixtures[0].DeviceID)
917 + require.Equal(t, "ms08", resolved.Fixtures[1].DeviceID)
918 +
919 + allWalks, err := LoadScenarioWalks(resolved)
920 + require.NoError(t, err)
921 + require.Len(t, allWalks, 2)
922 +
923 + preCollectionLinks := 0
924 + preCollectionElements := 0
925 + require.Equal(t, 0, preCollectionLinks)
926 + require.Equal(t, 0, preCollectionElements)
927 +
928 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
929 + require.NoError(t, errStep1)
930 + require.Equal(t, 3, step1.Stats["links_lldp"])
931 + require.Equal(t, map[string]int{"ms07": 3}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
932 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks[:1]))
933 +
934 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
935 + require.NoError(t, errStep2)
936 + require.Equal(t, 8, step2.Stats["links_lldp"])
937 + require.Equal(t, map[string]int{"ms07": 3, "ms08": 5}, countAdjacenciesBySource(step2.Adjacencies, "lldp"))
938 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
939 +
940 + final := step2
941 + require.Len(t, final.Adjacencies, 8)
942 + require.Equal(t, 8, final.Stats["links_lldp"])
943 + require.Equal(t, 0, final.Stats["links_cdp"])
944 +
945 + localDevices := map[string]struct{}{
946 + "ms07": {},
947 + "ms08": {},
948 + }
949 + require.Equal(t, 2, countLocalTopologyVertices(final.Adjacencies, "lldp", localDevices))
950 + require.Equal(t, 1, countUndirectedLocalDevicePairs(final.Adjacencies, "lldp", localDevices))
951 +
952 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
953 + require.NoError(t, err)
954 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(final.Adjacencies))
955 +}
956 +
957 +func TestBuildL2ResultFromWalks_LLDP_NMS0000_NETWORK_THREE_CONNECTED(t *testing.T) {
958 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0000/manifest.yaml"
959 + manifest, err := LoadManifest(manifestPath)
960 + require.NoError(t, err)
961 +
962 + scenario, ok := manifest.FindScenario("nms0000_network_three_connected_lldp")
963 + require.True(t, ok)
964 +
965 + enableOSPF := false
966 + enableISIS := false
967 + require.True(t, scenario.Protocols.LLDP)
968 + require.False(t, scenario.Protocols.CDP)
969 + require.False(t, enableOSPF)
970 + require.False(t, scenario.Protocols.Bridge)
971 + require.False(t, enableISIS)
972 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
973 +
974 + resolved, err := ResolveScenario(manifestPath, scenario)
975 + require.NoError(t, err)
976 + require.Len(t, resolved.Fixtures, 2)
977 + require.Equal(t, "ms08", resolved.Fixtures[0].DeviceID)
978 + require.Equal(t, "ms10", resolved.Fixtures[1].DeviceID)
979 +
980 + allWalks, err := LoadScenarioWalks(resolved)
981 + require.NoError(t, err)
982 + require.Len(t, allWalks, 2)
983 +
984 + preCollectionLinks := 0
985 + preCollectionElements := 0
986 + require.Equal(t, 0, preCollectionLinks)
987 + require.Equal(t, 0, preCollectionElements)
988 +
989 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
990 + require.NoError(t, errStep1)
991 + require.Equal(t, 5, step1.Stats["links_lldp"])
992 + require.Equal(t, map[string]int{"ms08": 5}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
993 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks[:1]))
994 +
995 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
996 + require.NoError(t, errStep2)
997 + require.Equal(t, 10, step2.Stats["links_lldp"])
998 + require.Equal(t, map[string]int{"ms08": 5, "ms10": 5}, countAdjacenciesBySource(step2.Adjacencies, "lldp"))
999 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1000 +
1001 + final := step2
1002 + require.Len(t, final.Adjacencies, 10)
1003 + require.Equal(t, 10, final.Stats["links_lldp"])
1004 + require.Equal(t, 0, final.Stats["links_cdp"])
1005 +
1006 + localDevices := map[string]struct{}{
1007 + "ms08": {},
1008 + "ms09": {},
1009 + "ms10": {},
1010 + }
1011 + require.Equal(t, 0, countUndirectedLocalDevicePairs(final.Adjacencies, "lldp", localDevices))
1012 + localSourceCounts := countAdjacenciesBySource(final.Adjacencies, "lldp")
1013 + require.Equal(t, 2, len(localSourceCounts))
1014 +
1015 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1016 + require.NoError(t, err)
1017 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(final.Adjacencies))
1018 +}
1019 +
1020 +func TestBuildL2ResultFromWalks_LLDP_NMS0000_MICROSENSE(t *testing.T) {
1021 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0000/manifest.yaml"
1022 + manifest, err := LoadManifest(manifestPath)
1023 + require.NoError(t, err)
1024 +
1025 + scenario, ok := manifest.FindScenario("nms0000_microsense_lldp")
1026 + require.True(t, ok)
1027 +
1028 + enableOSPF := false
1029 + enableISIS := false
1030 + require.True(t, scenario.Protocols.LLDP)
1031 + require.False(t, scenario.Protocols.CDP)
1032 + require.False(t, enableOSPF)
1033 + require.False(t, scenario.Protocols.Bridge)
1034 + require.False(t, enableISIS)
1035 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1036 +
1037 + resolved, err := ResolveScenario(manifestPath, scenario)
1038 + require.NoError(t, err)
1039 + require.Len(t, resolved.Fixtures, 1)
1040 +
1041 + allWalks, err := LoadScenarioWalks(resolved)
1042 + require.NoError(t, err)
1043 + require.Len(t, allWalks, 1)
1044 +
1045 + preCollectionLinks := 0
1046 + preCollectionElements := 0
1047 + require.Equal(t, 0, preCollectionLinks)
1048 + require.Equal(t, 0, preCollectionElements)
1049 +
1050 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1051 + require.NoError(t, errStep1)
1052 + require.Len(t, step1.Adjacencies, 5)
1053 + require.Equal(t, 5, step1.Stats["links_lldp"])
1054 + require.Equal(t, 0, step1.Stats["links_cdp"])
1055 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks))
1056 + require.Equal(t, map[string]int{"ms08": 5}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
1057 +
1058 + deviceByID := make(map[string]engine.Device, len(step1.Devices))
1059 + for _, dev := range step1.Devices {
1060 + deviceByID[dev.ID] = dev
1061 + }
1062 + require.Contains(t, deviceByID, "ms08")
1063 + require.Equal(t, "SW_A1_BDA_08_M", deviceByID["ms08"].Hostname)
1064 + require.Equal(t, "00:60:a7:0a:7f:6c", deviceByID["ms08"].ChassisID)
1065 +
1066 + expected := map[string]struct{}{
1067 + "lldp|ms08|2/4|microsens-g6-mac-00:60:a7:0a:7f:26|2/5": {},
1068 + "lldp|ms08|2/6|axis-accc8eef78f9|ac:cc:8e:ef:78:f9": {},
1069 + "lldp|ms08|3/1|axis-accc8eef78e4|ac:cc:8e:ef:78:e4": {},
1070 + "lldp|ms08|3/2|axis-b8a44f502ddd|b8:a4:4f:50:2d:dd": {},
1071 + "lldp|ms08|3/4|microsens-g6-mac-00:60:a7:0a:7f:10|3/5": {},
1072 + }
1073 + require.Equal(t, expected, adjacencyKeySet(step1.Adjacencies))
1074 +
1075 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1076 + require.NoError(t, err)
1077 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
1078 +}
1079 +
1080 +func TestBuildL2ResultFromWalks_LLDP_NMS0000_MS16(t *testing.T) {
1081 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0000/manifest.yaml"
1082 + manifest, err := LoadManifest(manifestPath)
1083 + require.NoError(t, err)
1084 +
1085 + scenario, ok := manifest.FindScenario("nms0000_ms16_lldp")
1086 + require.True(t, ok)
1087 +
1088 + enableOSPF := false
1089 + enableISIS := false
1090 + require.True(t, scenario.Protocols.LLDP)
1091 + require.False(t, scenario.Protocols.CDP)
1092 + require.False(t, enableOSPF)
1093 + require.False(t, scenario.Protocols.Bridge)
1094 + require.False(t, enableISIS)
1095 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1096 +
1097 + resolved, err := ResolveScenario(manifestPath, scenario)
1098 + require.NoError(t, err)
1099 + require.Len(t, resolved.Fixtures, 1)
1100 +
1101 + allWalks, err := LoadScenarioWalks(resolved)
1102 + require.NoError(t, err)
1103 + require.Len(t, allWalks, 1)
1104 +
1105 + preCollectionLinks := 0
1106 + preCollectionElements := 0
1107 + require.Equal(t, 0, preCollectionLinks)
1108 + require.Equal(t, 0, preCollectionElements)
1109 +
1110 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1111 + require.NoError(t, errStep1)
1112 + require.Len(t, step1.Adjacencies, 5)
1113 + require.Equal(t, 5, step1.Stats["links_lldp"])
1114 + require.Equal(t, 0, step1.Stats["links_cdp"])
1115 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks))
1116 + require.Equal(t, map[string]int{"ms16": 5}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
1117 +
1118 + deviceByID := make(map[string]engine.Device, len(step1.Devices))
1119 + for _, dev := range step1.Devices {
1120 + deviceByID[dev.ID] = dev
1121 + }
1122 + require.Contains(t, deviceByID, "ms16")
1123 + require.Equal(t, "SW_A1_BDA_16_M", deviceByID["ms16"].Hostname)
1124 + require.Equal(t, "00:60:a7:0c:43:ff", deviceByID["ms16"].ChassisID)
1125 +
1126 + expected := map[string]struct{}{
1127 + "lldp|ms16|2/5|microsens-g6-mac-00:60:a7:0a:cc:1b|2/6": {},
1128 + "lldp|ms16|3/1|axis-accc8eef7a21|ac:cc:8e:ef:7a:21": {},
1129 + "lldp|ms16|3/2|axis-accc8eef78f8|ac:cc:8e:ef:78:f8": {},
1130 + "lldp|ms16|3/5|microsens-g6-mac-00:60:a7:0a:cb:d3|2/5": {},
1131 + "lldp|ms16|3/6|microsens-g6-mac-00:60:a7:0a:cc:09|2/5": {},
1132 + }
1133 + require.Equal(t, expected, adjacencyKeySet(step1.Adjacencies))
1134 +
1135 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1136 + require.NoError(t, err)
1137 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
1138 +}
1139 +
1140 +func TestBuildL2ResultFromWalks_LLDP_NMS0000_PLANET(t *testing.T) {
1141 + manifestPath := "../../../../testdata/snmp/enlinkd/nms0000/manifest.yaml"
1142 + manifest, err := LoadManifest(manifestPath)
1143 + require.NoError(t, err)
1144 +
1145 + scenario, ok := manifest.FindScenario("nms0000_planet_lldp")
1146 + require.True(t, ok)
1147 +
1148 + enableOSPF := false
1149 + enableISIS := false
1150 + require.True(t, scenario.Protocols.LLDP)
1151 + require.False(t, scenario.Protocols.CDP)
1152 + require.False(t, enableOSPF)
1153 + require.False(t, scenario.Protocols.Bridge)
1154 + require.False(t, enableISIS)
1155 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1156 +
1157 + resolved, err := ResolveScenario(manifestPath, scenario)
1158 + require.NoError(t, err)
1159 + require.Len(t, resolved.Fixtures, 1)
1160 +
1161 + allWalks, err := LoadScenarioWalks(resolved)
1162 + require.NoError(t, err)
1163 + require.Len(t, allWalks, 1)
1164 +
1165 + preCollectionLinks := 0
1166 + preCollectionElements := 0
1167 + require.Equal(t, 0, preCollectionLinks)
1168 + require.Equal(t, 0, preCollectionElements)
1169 +
1170 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1171 + require.NoError(t, errStep1)
1172 + require.Len(t, step1.Adjacencies, 3)
1173 + require.Equal(t, 3, step1.Stats["links_lldp"])
1174 + require.Equal(t, 0, step1.Stats["links_cdp"])
1175 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks))
1176 + require.Equal(t, map[string]int{"planet": 3}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
1177 +
1178 + deviceByID := make(map[string]engine.Device, len(step1.Devices))
1179 + for _, dev := range step1.Devices {
1180 + deviceByID[dev.ID] = dev
1181 + }
1182 + require.Contains(t, deviceByID, "planet")
1183 + require.Equal(t, "V177", deviceByID["planet"].Hostname)
1184 + require.Equal(t, "a8:f7:e0:6c:3b:d8", deviceByID["planet"].ChassisID)
1185 +
1186 + adjByLocalPort := make(map[string]engine.Adjacency, len(step1.Adjacencies))
1187 + for _, adj := range step1.Adjacencies {
1188 + if adj.SourceID != "planet" {
1189 + continue
1190 + }
1191 + adjByLocalPort[adj.SourcePort] = adj
1192 + }
1193 +
1194 + adj06, ok06 := adjByLocalPort["06"]
1195 + require.True(t, ok06)
1196 + require.Equal(t, "epmp1000_64aaec", adj06.TargetID)
1197 + require.Equal(t, "00-04-56-6F-82-1E", adj06.TargetPort)
1198 +
1199 + adj10, ok10 := adjByLocalPort["10"]
1200 + require.True(t, ok10)
1201 + require.Equal(t, "microsens-g6-mac-00:60:a7:0c:8e:33", adj10.TargetID)
1202 + require.Equal(t, "2/5", adj10.TargetPort)
1203 +
1204 + adj09, ok09 := adjByLocalPort["09"]
1205 + require.True(t, ok09)
1206 + require.Equal(t, "switch area b - v178", adj09.TargetID)
1207 + require.Equal(t, "9", adj09.TargetPort)
1208 +
1209 + expected := map[string]struct{}{
1210 + "lldp|planet|06|epmp1000_64aaec|00-04-56-6F-82-1E": {},
1211 + "lldp|planet|09|switch area b - v178|9": {},
1212 + "lldp|planet|10|microsens-g6-mac-00:60:a7:0c:8e:33|2/5": {},
1213 + }
1214 + require.Equal(t, expected, adjacencyKeySet(step1.Adjacencies))
1215 +
1216 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1217 + require.NoError(t, err)
1218 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
1219 +}
1220 +
1221 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_NETWORK_ALL(t *testing.T) {
1222 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1223 + manifest, err := LoadManifest(manifestPath)
1224 + require.NoError(t, err)
1225 +
1226 + scenario, ok := manifest.FindScenario("nms18541_network_all_lldp")
1227 + require.True(t, ok)
1228 +
1229 + enableOSPF := false
1230 + enableISIS := false
1231 + require.True(t, scenario.Protocols.LLDP)
1232 + require.False(t, scenario.Protocols.CDP)
1233 + require.False(t, enableOSPF)
1234 + require.False(t, scenario.Protocols.Bridge)
1235 + require.False(t, enableISIS)
1236 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1237 +
1238 + resolved, err := ResolveScenario(manifestPath, scenario)
1239 + require.NoError(t, err)
1240 +
1241 + expectedFixtureIDs := []string{
1242 + "SW_D6_01_M",
1243 + "SW_D6_02_M",
1244 + "SW_D6_03_M",
1245 + "SW_D6_04_M",
1246 + "SW_D6_08_M",
1247 + "SW_D6_09_M",
1248 + "E0281L-ScALBENGA2-QFX",
1249 + }
1250 + require.Len(t, resolved.Fixtures, len(expectedFixtureIDs))
1251 +
1252 + fixturesByID := make(map[string]ResolvedFixture, len(resolved.Fixtures))
1253 + for _, fixture := range resolved.Fixtures {
1254 + fixturesByID[fixture.DeviceID] = fixture
1255 + }
1256 + for _, fixtureID := range expectedFixtureIDs {
1257 + _, exists := fixturesByID[fixtureID]
1258 + require.True(t, exists)
1259 + }
1260 +
1261 + allWalks, err := LoadScenarioWalks(resolved)
1262 + require.NoError(t, err)
1263 + require.Len(t, allWalks, len(expectedFixtureIDs))
1264 +
1265 + preCollectionLinks := 0
1266 + preCollectionElements := 0
1267 + require.Equal(t, 0, preCollectionLinks)
1268 + require.Equal(t, 0, preCollectionElements)
1269 +
1270 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1271 + require.NoError(t, errStep1)
1272 + require.Equal(t, 2, step1.Stats["links_lldp"])
1273 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks[:1]))
1274 +
1275 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
1276 + require.NoError(t, errStep2)
1277 + require.Equal(t, 4, step2.Stats["links_lldp"])
1278 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks[:2]))
1279 +
1280 + step3, errStep3 := buildResultFromScenarioPrefix(resolved, 3, BuildOptions{EnableLLDP: true})
1281 + require.NoError(t, errStep3)
1282 + require.Equal(t, 5, step3.Stats["links_lldp"])
1283 + require.Equal(t, 3, countFixturesWithLLDPLocalElements(allWalks[:3]))
1284 +
1285 + step4, errStep4 := buildResultFromScenarioPrefix(resolved, 4, BuildOptions{EnableLLDP: true})
1286 + require.NoError(t, errStep4)
1287 + require.Equal(t, 9, step4.Stats["links_lldp"])
1288 + require.Equal(t, 4, countFixturesWithLLDPLocalElements(allWalks[:4]))
1289 +
1290 + step5, errStep5 := buildResultFromScenarioPrefix(resolved, 5, BuildOptions{EnableLLDP: true})
1291 + require.NoError(t, errStep5)
1292 + require.Equal(t, 12, step5.Stats["links_lldp"])
1293 + require.Equal(t, 5, countFixturesWithLLDPLocalElements(allWalks[:5]))
1294 +
1295 + step6, errStep6 := buildResultFromScenarioPrefix(resolved, 6, BuildOptions{EnableLLDP: true})
1296 + require.NoError(t, errStep6)
1297 + require.Equal(t, 19, step6.Stats["links_lldp"])
1298 + require.Equal(t, 6, countFixturesWithLLDPLocalElements(allWalks[:6]))
1299 +
1300 + localPreQFX := map[string]struct{}{
1301 + "SW_D6_01_M": {},
1302 + "SW_D6_02_M": {},
1303 + "SW_D6_03_M": {},
1304 + "SW_D6_04_M": {},
1305 + "SW_D6_08_M": {},
1306 + "SW_D6_09_M": {},
1307 + }
1308 + require.NotNil(t, step6.Adjacencies)
1309 + require.Equal(t, 6, countFixturesWithLLDPLocalElements(allWalks[:6]))
1310 + require.Equal(t, 2, countUndirectedLocalDevicePairs(step6.Adjacencies, "lldp", localPreQFX))
1311 +
1312 + step7, errStep7 := buildResultFromScenarioPrefix(resolved, 7, BuildOptions{EnableLLDP: true})
1313 + require.NoError(t, errStep7)
1314 + require.Equal(t, 34, step7.Stats["links_lldp"])
1315 + require.Equal(t, 7, countFixturesWithLLDPLocalElements(allWalks))
1316 +
1317 + localAll := map[string]struct{}{
1318 + "SW_D6_01_M": {},
1319 + "SW_D6_02_M": {},
1320 + "SW_D6_03_M": {},
1321 + "SW_D6_04_M": {},
1322 + "SW_D6_08_M": {},
1323 + "SW_D6_09_M": {},
1324 + "E0281L-ScALBENGA2-QFX": {},
1325 + }
1326 + require.Equal(t, 7, countFixturesWithLLDPLocalElements(allWalks))
1327 + require.Equal(t, 6, countUndirectedLocalDevicePairs(step7.Adjacencies, "lldp", localAll))
1328 +}
1329 +
1330 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_TOPO_QFX_SW01_SW02_SW03(t *testing.T) {
1331 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1332 + manifest, err := LoadManifest(manifestPath)
1333 + require.NoError(t, err)
1334 +
1335 + scenario, ok := manifest.FindScenario("nms18541_topoqfx_sw01_sw02_sw03_lldp")
1336 + require.True(t, ok)
1337 +
1338 + require.True(t, scenario.Protocols.LLDP)
1339 + require.False(t, scenario.Protocols.CDP)
1340 + require.False(t, scenario.Protocols.Bridge)
1341 + require.False(t, scenario.Protocols.ARPND)
1342 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1343 +
1344 + resolved, err := ResolveScenario(manifestPath, scenario)
1345 + require.NoError(t, err)
1346 + require.Len(t, resolved.Fixtures, 4)
1347 +
1348 + allWalks, err := LoadScenarioWalks(resolved)
1349 + require.NoError(t, err)
1350 + require.Len(t, allWalks, 4)
1351 +
1352 + preCollectionLinks := 0
1353 + preCollectionElements := 0
1354 + require.Equal(t, 0, preCollectionLinks)
1355 + require.Equal(t, 0, preCollectionElements)
1356 +
1357 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1358 + require.NoError(t, errStep1)
1359 + require.Equal(t, 15, step1.Stats["links_lldp"])
1360 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks[:1]))
1361 +
1362 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
1363 + require.NoError(t, errStep2)
1364 + require.Equal(t, 17, step2.Stats["links_lldp"])
1365 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks[:2]))
1366 +
1367 + step3, errStep3 := buildResultFromScenarioPrefix(resolved, 3, BuildOptions{EnableLLDP: true})
1368 + require.NoError(t, errStep3)
1369 + require.Equal(t, 19, step3.Stats["links_lldp"])
1370 + require.Equal(t, 3, countFixturesWithLLDPLocalElements(allWalks[:3]))
1371 +
1372 + step4, errStep4 := buildResultFromScenarioPrefix(resolved, 4, BuildOptions{EnableLLDP: true})
1373 + require.NoError(t, errStep4)
1374 + require.Equal(t, 20, step4.Stats["links_lldp"])
1375 + require.Equal(t, 4, countFixturesWithLLDPLocalElements(allWalks))
1376 +
1377 + localDevices := map[string]struct{}{
1378 + "E0281L-ScALBENGA2-QFX": {},
1379 + "SW_D6_01_M": {},
1380 + "SW_D6_02_M": {},
1381 + "SW_D6_03_M": {},
1382 + }
1383 + require.NotNil(t, step4.Adjacencies)
1384 + require.Equal(t, 4, countFixturesWithLLDPLocalElements(allWalks))
1385 + require.Equal(t, 3, countUndirectedLocalDevicePairs(step4.Adjacencies, "lldp", localDevices))
1386 +
1387 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1388 + require.NoError(t, err)
1389 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step4.Adjacencies))
1390 +}
1391 +
1392 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_TOPO_QFX_SW01(t *testing.T) {
1393 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1394 + manifest, err := LoadManifest(manifestPath)
1395 + require.NoError(t, err)
1396 +
1397 + scenario, ok := manifest.FindScenario("nms18541_topoqfx_sw01_lldp")
1398 + require.True(t, ok)
1399 +
1400 + require.True(t, scenario.Protocols.LLDP)
1401 + require.False(t, scenario.Protocols.CDP)
1402 + require.False(t, scenario.Protocols.Bridge)
1403 + require.False(t, scenario.Protocols.ARPND)
1404 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1405 +
1406 + resolved, err := ResolveScenario(manifestPath, scenario)
1407 + require.NoError(t, err)
1408 + require.Len(t, resolved.Fixtures, 2)
1409 +
1410 + allWalks, err := LoadScenarioWalks(resolved)
1411 + require.NoError(t, err)
1412 + require.Len(t, allWalks, 2)
1413 +
1414 + preCollectionLinks := 0
1415 + preCollectionElements := 0
1416 + require.Equal(t, 0, preCollectionLinks)
1417 + require.Equal(t, 0, preCollectionElements)
1418 +
1419 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1420 + require.NoError(t, errStep1)
1421 + require.Equal(t, 15, step1.Stats["links_lldp"])
1422 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks[:1]))
1423 +
1424 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
1425 + require.NoError(t, errStep2)
1426 + require.Equal(t, 17, step2.Stats["links_lldp"])
1427 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1428 +
1429 + localDevices := map[string]struct{}{
1430 + "E0281L-ScALBENGA2-QFX": {},
1431 + "SW_D6_01_M": {},
1432 + }
1433 + require.NotNil(t, step2.Adjacencies)
1434 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1435 + require.Equal(t, 0, countUndirectedLocalDevicePairs(step2.Adjacencies, "lldp", localDevices))
1436 +
1437 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1438 + require.NoError(t, err)
1439 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step2.Adjacencies))
1440 +}
1441 +
1442 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_TOPO_QFX_SW02(t *testing.T) {
1443 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1444 + manifest, err := LoadManifest(manifestPath)
1445 + require.NoError(t, err)
1446 +
1447 + scenario, ok := manifest.FindScenario("nms18541_topoqfx_sw02_lldp")
1448 + require.True(t, ok)
1449 +
1450 + require.True(t, scenario.Protocols.LLDP)
1451 + require.False(t, scenario.Protocols.CDP)
1452 + require.False(t, scenario.Protocols.Bridge)
1453 + require.False(t, scenario.Protocols.ARPND)
1454 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1455 +
1456 + resolved, err := ResolveScenario(manifestPath, scenario)
1457 + require.NoError(t, err)
1458 + require.Len(t, resolved.Fixtures, 2)
1459 +
1460 + allWalks, err := LoadScenarioWalks(resolved)
1461 + require.NoError(t, err)
1462 + require.Len(t, allWalks, 2)
1463 +
1464 + preCollectionLinks := 0
1465 + preCollectionElements := 0
1466 + require.Equal(t, 0, preCollectionLinks)
1467 + require.Equal(t, 0, preCollectionElements)
1468 +
1469 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1470 + require.NoError(t, errStep1)
1471 + require.Equal(t, 15, step1.Stats["links_lldp"])
1472 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks[:1]))
1473 +
1474 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
1475 + require.NoError(t, errStep2)
1476 + require.Equal(t, 17, step2.Stats["links_lldp"])
1477 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1478 +
1479 + localDevices := map[string]struct{}{
1480 + "E0281L-ScALBENGA2-QFX": {},
1481 + "SW_D6_02_M": {},
1482 + }
1483 + require.NotNil(t, step2.Adjacencies)
1484 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1485 + require.Equal(t, 1, countUndirectedLocalDevicePairs(step2.Adjacencies, "lldp", localDevices))
1486 +
1487 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1488 + require.NoError(t, err)
1489 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step2.Adjacencies))
1490 +}
1491 +
1492 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_TOPO_QFX_SW03(t *testing.T) {
1493 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1494 + manifest, err := LoadManifest(manifestPath)
1495 + require.NoError(t, err)
1496 +
1497 + scenario, ok := manifest.FindScenario("nms18541_topoqfx_sw03_lldp")
1498 + require.True(t, ok)
1499 +
1500 + require.True(t, scenario.Protocols.LLDP)
1501 + require.False(t, scenario.Protocols.CDP)
1502 + require.False(t, scenario.Protocols.Bridge)
1503 + require.False(t, scenario.Protocols.ARPND)
1504 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1505 +
1506 + resolved, err := ResolveScenario(manifestPath, scenario)
1507 + require.NoError(t, err)
1508 + require.Len(t, resolved.Fixtures, 2)
1509 +
1510 + allWalks, err := LoadScenarioWalks(resolved)
1511 + require.NoError(t, err)
1512 + require.Len(t, allWalks, 2)
1513 +
1514 + preCollectionLinks := 0
1515 + preCollectionElements := 0
1516 + require.Equal(t, 0, preCollectionLinks)
1517 + require.Equal(t, 0, preCollectionElements)
1518 +
1519 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1520 + require.NoError(t, errStep1)
1521 + require.Equal(t, 15, step1.Stats["links_lldp"])
1522 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks[:1]))
1523 +
1524 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
1525 + require.NoError(t, errStep2)
1526 + require.Equal(t, 16, step2.Stats["links_lldp"])
1527 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1528 +
1529 + localDevices := map[string]struct{}{
1530 + "E0281L-ScALBENGA2-QFX": {},
1531 + "SW_D6_03_M": {},
1532 + }
1533 + require.NotNil(t, step2.Adjacencies)
1534 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1535 + require.Equal(t, 0, countUndirectedLocalDevicePairs(step2.Adjacencies, "lldp", localDevices))
1536 +
1537 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1538 + require.NoError(t, err)
1539 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step2.Adjacencies))
1540 +}
1541 +
1542 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_TOPO_QFX_SW04(t *testing.T) {
1543 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1544 + manifest, err := LoadManifest(manifestPath)
1545 + require.NoError(t, err)
1546 +
1547 + scenario, ok := manifest.FindScenario("nms18541_topoqfx_sw04_lldp")
1548 + require.True(t, ok)
1549 +
1550 + require.True(t, scenario.Protocols.LLDP)
1551 + require.False(t, scenario.Protocols.CDP)
1552 + require.False(t, scenario.Protocols.Bridge)
1553 + require.False(t, scenario.Protocols.ARPND)
1554 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1555 +
1556 + resolved, err := ResolveScenario(manifestPath, scenario)
1557 + require.NoError(t, err)
1558 + require.Len(t, resolved.Fixtures, 2)
1559 +
1560 + allWalks, err := LoadScenarioWalks(resolved)
1561 + require.NoError(t, err)
1562 + require.Len(t, allWalks, 2)
1563 +
1564 + preCollectionLinks := 0
1565 + preCollectionElements := 0
1566 + require.Equal(t, 0, preCollectionLinks)
1567 + require.Equal(t, 0, preCollectionElements)
1568 +
1569 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1570 + require.NoError(t, errStep1)
1571 + require.Equal(t, 15, step1.Stats["links_lldp"])
1572 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks[:1]))
1573 +
1574 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
1575 + require.NoError(t, errStep2)
1576 + require.Equal(t, 19, step2.Stats["links_lldp"])
1577 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1578 +
1579 + localDevices := map[string]struct{}{
1580 + "E0281L-ScALBENGA2-QFX": {},
1581 + "SW_D6_04_M": {},
1582 + }
1583 + require.NotNil(t, step2.Adjacencies)
1584 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1585 + require.Equal(t, 1, countUndirectedLocalDevicePairs(step2.Adjacencies, "lldp", localDevices))
1586 +
1587 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1588 + require.NoError(t, err)
1589 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step2.Adjacencies))
1590 +}
1591 +
1592 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_TOPO_QFX_SW08(t *testing.T) {
1593 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1594 + manifest, err := LoadManifest(manifestPath)
1595 + require.NoError(t, err)
1596 +
1597 + scenario, ok := manifest.FindScenario("nms18541_topoqfx_sw08_lldp")
1598 + require.True(t, ok)
1599 +
1600 + require.True(t, scenario.Protocols.LLDP)
1601 + require.False(t, scenario.Protocols.CDP)
1602 + require.False(t, scenario.Protocols.Bridge)
1603 + require.False(t, scenario.Protocols.ARPND)
1604 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1605 +
1606 + resolved, err := ResolveScenario(manifestPath, scenario)
1607 + require.NoError(t, err)
1608 + require.Len(t, resolved.Fixtures, 2)
1609 +
1610 + allWalks, err := LoadScenarioWalks(resolved)
1611 + require.NoError(t, err)
1612 + require.Len(t, allWalks, 2)
1613 +
1614 + preCollectionLinks := 0
1615 + preCollectionElements := 0
1616 + require.Equal(t, 0, preCollectionLinks)
1617 + require.Equal(t, 0, preCollectionElements)
1618 +
1619 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1620 + require.NoError(t, errStep1)
1621 + require.Equal(t, 15, step1.Stats["links_lldp"])
1622 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks[:1]))
1623 +
1624 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
1625 + require.NoError(t, errStep2)
1626 + require.Equal(t, 18, step2.Stats["links_lldp"])
1627 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1628 +
1629 + localDevices := map[string]struct{}{
1630 + "E0281L-ScALBENGA2-QFX": {},
1631 + "SW_D6_08_M": {},
1632 + }
1633 + require.NotNil(t, step2.Adjacencies)
1634 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1635 + require.Equal(t, 1, countUndirectedLocalDevicePairs(step2.Adjacencies, "lldp", localDevices))
1636 +
1637 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1638 + require.NoError(t, err)
1639 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step2.Adjacencies))
1640 +}
1641 +
1642 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_TOPO_QFX_SW09(t *testing.T) {
1643 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1644 + manifest, err := LoadManifest(manifestPath)
1645 + require.NoError(t, err)
1646 +
1647 + scenario, ok := manifest.FindScenario("nms18541_topoqfx_sw09_lldp")
1648 + require.True(t, ok)
1649 +
1650 + require.True(t, scenario.Protocols.LLDP)
1651 + require.False(t, scenario.Protocols.CDP)
1652 + require.False(t, scenario.Protocols.Bridge)
1653 + require.False(t, scenario.Protocols.ARPND)
1654 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1655 +
1656 + resolved, err := ResolveScenario(manifestPath, scenario)
1657 + require.NoError(t, err)
1658 + require.Len(t, resolved.Fixtures, 2)
1659 +
1660 + allWalks, err := LoadScenarioWalks(resolved)
1661 + require.NoError(t, err)
1662 + require.Len(t, allWalks, 2)
1663 +
1664 + require.Equal(t, 0, 0)
1665 + require.Equal(t, 0, 0)
1666 +
1667 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1668 + require.NoError(t, errStep1)
1669 + require.Equal(t, 15, step1.Stats["links_lldp"])
1670 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks[:1]))
1671 +
1672 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
1673 + require.NoError(t, errStep2)
1674 + require.Equal(t, 22, step2.Stats["links_lldp"])
1675 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1676 +
1677 + localDevices := map[string]struct{}{
1678 + "E0281L-ScALBENGA2-QFX": {},
1679 + "SW_D6_09_M": {},
1680 + }
1681 + require.NotNil(t, step2.Adjacencies)
1682 + require.Equal(t, 2, countFixturesWithLLDPLocalElements(allWalks))
1683 + require.Equal(t, 1, countUndirectedLocalDevicePairs(step2.Adjacencies, "lldp", localDevices))
1684 +
1685 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1686 + require.NoError(t, err)
1687 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step2.Adjacencies))
1688 +}
1689 +
1690 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_QFX(t *testing.T) {
1691 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1692 + manifest, err := LoadManifest(manifestPath)
1693 + require.NoError(t, err)
1694 +
1695 + scenario, ok := manifest.FindScenario("nms18541_qfx_lldp")
1696 + require.True(t, ok)
1697 +
1698 + require.True(t, scenario.Protocols.LLDP)
1699 + require.False(t, scenario.Protocols.CDP)
1700 + require.False(t, scenario.Protocols.Bridge)
1701 + require.False(t, scenario.Protocols.ARPND)
1702 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1703 +
1704 + resolved, err := ResolveScenario(manifestPath, scenario)
1705 + require.NoError(t, err)
1706 + require.Len(t, resolved.Fixtures, 1)
1707 +
1708 + allWalks, err := LoadScenarioWalks(resolved)
1709 + require.NoError(t, err)
1710 + require.Len(t, allWalks, 1)
1711 +
1712 + preCollectionLinks := 0
1713 + preCollectionElements := 0
1714 + require.Equal(t, 0, preCollectionLinks)
1715 + require.Equal(t, 0, preCollectionElements)
1716 +
1717 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1718 + require.NoError(t, errStep1)
1719 + require.Len(t, step1.Adjacencies, 15)
1720 + require.Equal(t, 15, step1.Stats["links_lldp"])
1721 + require.Equal(t, 0, step1.Stats["links_cdp"])
1722 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks))
1723 + require.Equal(t, map[string]int{"E0281L-ScALBENGA2-QFX": 15}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
1724 +
1725 + expected := map[string]struct{}{
1726 + "lldp|E0281L-ScALBENGA2-QFX|et-0/0/48|e0281l-scalbenga2-r1|581": {},
1727 + "lldp|E0281L-ScALBENGA2-QFX|et-0/0/49|e0281l-scalbenga2-r2|581": {},
1728 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/0|kwe0095p0i_1sgiusto65|28": {},
1729 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/1|kje0406p0i_1mbaldo11|28": {},
1730 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/10|arunarcangeli01|EC FC C6 C8 3B 80": {},
1731 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/13|microsens-g6-mac-00-60-a7-0a-81-13|2/5": {},
1732 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/15|microsens-g6-mac-00-60-a7-0a-7f-16|2/5": {},
1733 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/16|microsens-g6-mac-00:60:a7:0d:9e:15|3/6": {},
1734 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/2|kke0482p0i_1fleming15|28": {},
1735 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/3|wke0564p0i_1gnocchi8|26": {},
1736 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/4|wye0596p0i_1mbaldo15|28": {},
1737 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/5|wxe0729p0t_1pioii3|28": {},
1738 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/6|wje0588p0i_1marx2|27": {},
1739 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/7|microsens-g6-mac-00:60:a7:0c:27:fd|3/5": {},
1740 + "lldp|E0281L-ScALBENGA2-QFX|ge-0/0/9|arunbellaria01|50 E4 E0 CE 3B 8E": {},
1741 + }
1742 + require.Equal(t, expected, adjacencyKeySet(step1.Adjacencies))
1743 +
1744 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1745 + require.NoError(t, err)
1746 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
1747 +}
1748 +
1749 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_MICROSENS_SW01(t *testing.T) {
1750 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1751 + manifest, err := LoadManifest(manifestPath)
1752 + require.NoError(t, err)
1753 +
1754 + scenario, ok := manifest.FindScenario("nms18541_microsens_sw01_lldp")
1755 + require.True(t, ok)
1756 +
1757 + require.True(t, scenario.Protocols.LLDP)
1758 + require.False(t, scenario.Protocols.CDP)
1759 + require.False(t, scenario.Protocols.Bridge)
1760 + require.False(t, scenario.Protocols.ARPND)
1761 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1762 +
1763 + resolved, err := ResolveScenario(manifestPath, scenario)
1764 + require.NoError(t, err)
1765 + require.Len(t, resolved.Fixtures, 1)
1766 +
1767 + allWalks, err := LoadScenarioWalks(resolved)
1768 + require.NoError(t, err)
1769 + require.Len(t, allWalks, 1)
1770 +
1771 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1772 + require.NoError(t, errStep1)
1773 + require.Len(t, step1.Adjacencies, 2)
1774 + require.Equal(t, 2, step1.Stats["links_lldp"])
1775 + require.Equal(t, 0, step1.Stats["links_cdp"])
1776 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks))
1777 + require.Equal(t, map[string]int{"SW_D6_01_M": 2}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
1778 +
1779 + expected := map[string]struct{}{
1780 + "lldp|SW_D6_01_M|2/4|microsens-g6-mac-00:60:a7:0c:27:fd|2/5": {},
1781 + "lldp|SW_D6_01_M|3/4|microsens-g6-mac-00:60:a7:0a:80:5e|2/5": {},
1782 + }
1783 + require.Equal(t, expected, adjacencyKeySet(step1.Adjacencies))
1784 +
1785 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1786 + require.NoError(t, err)
1787 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
1788 +}
1789 +
1790 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_MICROSENS_SW02(t *testing.T) {
1791 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1792 + manifest, err := LoadManifest(manifestPath)
1793 + require.NoError(t, err)
1794 +
1795 + scenario, ok := manifest.FindScenario("nms18541_microsens_sw02_lldp")
1796 + require.True(t, ok)
1797 +
1798 + require.True(t, scenario.Protocols.LLDP)
1799 + require.False(t, scenario.Protocols.CDP)
1800 + require.False(t, scenario.Protocols.Bridge)
1801 + require.False(t, scenario.Protocols.ARPND)
1802 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1803 +
1804 + resolved, err := ResolveScenario(manifestPath, scenario)
1805 + require.NoError(t, err)
1806 + require.Len(t, resolved.Fixtures, 1)
1807 +
1808 + allWalks, err := LoadScenarioWalks(resolved)
1809 + require.NoError(t, err)
1810 + require.Len(t, allWalks, 1)
1811 +
1812 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1813 + require.NoError(t, errStep1)
1814 + require.Len(t, step1.Adjacencies, 2)
1815 + require.Equal(t, 2, step1.Stats["links_lldp"])
1816 + require.Equal(t, 0, step1.Stats["links_cdp"])
1817 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks))
1818 + require.Equal(t, map[string]int{"SW_D6_02_M": 2}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
1819 +
1820 + expected := map[string]struct{}{
1821 + "lldp|SW_D6_02_M|2/4|microsens-g6-mac-00:60:a7:0a:80:4e|2/5": {},
1822 + "lldp|SW_D6_02_M|3/4|e0281l-scalbenga2-qfx|ge-0/0/7": {},
1823 + }
1824 + require.Equal(t, expected, adjacencyKeySet(step1.Adjacencies))
1825 +
1826 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1827 + require.NoError(t, err)
1828 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
1829 +}
1830 +
1831 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_MICROSENS_SW03(t *testing.T) {
1832 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1833 + manifest, err := LoadManifest(manifestPath)
1834 + require.NoError(t, err)
1835 +
1836 + scenario, ok := manifest.FindScenario("nms18541_microsens_sw03_lldp")
1837 + require.True(t, ok)
1838 +
1839 + require.True(t, scenario.Protocols.LLDP)
1840 + require.False(t, scenario.Protocols.CDP)
1841 + require.False(t, scenario.Protocols.Bridge)
1842 + require.False(t, scenario.Protocols.ARPND)
1843 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1844 +
1845 + resolved, err := ResolveScenario(manifestPath, scenario)
1846 + require.NoError(t, err)
1847 + require.Len(t, resolved.Fixtures, 1)
1848 +
1849 + allWalks, err := LoadScenarioWalks(resolved)
1850 + require.NoError(t, err)
1851 + require.Len(t, allWalks, 1)
1852 +
1853 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1854 + require.NoError(t, errStep1)
1855 + require.Len(t, step1.Adjacencies, 1)
1856 + require.Equal(t, 1, step1.Stats["links_lldp"])
1857 + require.Equal(t, 0, step1.Stats["links_cdp"])
1858 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks))
1859 + require.Equal(t, map[string]int{"SW_D6_03_M": 1}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
1860 +
1861 + expected := map[string]struct{}{
1862 + "lldp|SW_D6_03_M|2/4|microsens-g6-mac-00:60:a7:0a:80:4e|3/5": {},
1863 + }
1864 + require.Equal(t, expected, adjacencyKeySet(step1.Adjacencies))
1865 +
1866 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1867 + require.NoError(t, err)
1868 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
1869 +}
1870 +
1871 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_MICROSENS_SW04(t *testing.T) {
1872 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1873 + manifest, err := LoadManifest(manifestPath)
1874 + require.NoError(t, err)
1875 +
1876 + scenario, ok := manifest.FindScenario("nms18541_microsens_sw04_lldp")
1877 + require.True(t, ok)
1878 +
1879 + require.True(t, scenario.Protocols.LLDP)
1880 + require.False(t, scenario.Protocols.CDP)
1881 + require.False(t, scenario.Protocols.Bridge)
1882 + require.False(t, scenario.Protocols.ARPND)
1883 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1884 +
1885 + resolved, err := ResolveScenario(manifestPath, scenario)
1886 + require.NoError(t, err)
1887 + require.Len(t, resolved.Fixtures, 1)
1888 +
1889 + allWalks, err := LoadScenarioWalks(resolved)
1890 + require.NoError(t, err)
1891 + require.Len(t, allWalks, 1)
1892 +
1893 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1894 + require.NoError(t, errStep1)
1895 + require.Len(t, step1.Adjacencies, 4)
1896 + require.Equal(t, 4, step1.Stats["links_lldp"])
1897 + require.Equal(t, 0, step1.Stats["links_cdp"])
1898 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks))
1899 + require.Equal(t, map[string]int{"SW_D6_04_M": 4}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
1900 +
1901 + expected := map[string]struct{}{
1902 + "lldp|SW_D6_04_M|0|e0281l-scalbenga2-qfx|ge-0/0/13": {},
1903 + "lldp|SW_D6_04_M|0|i0504-su-tlc01|e8:27:25:07:83:43": {},
1904 + "lldp|SW_D6_04_M|0|i0504-su-tlc02|b8:a4:4f:b2:bb:2b": {},
1905 + "lldp|SW_D6_04_M|0|i0504-su-tlc03|e8:27:25:07:96:9a": {},
1906 + }
1907 + require.Equal(t, expected, adjacencyKeySet(step1.Adjacencies))
1908 +
1909 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1910 + require.NoError(t, err)
1911 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
1912 +}
1913 +
1914 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_MICROSENS_SW08(t *testing.T) {
1915 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1916 + manifest, err := LoadManifest(manifestPath)
1917 + require.NoError(t, err)
1918 +
1919 + scenario, ok := manifest.FindScenario("nms18541_microsens_sw08_lldp")
1920 + require.True(t, ok)
1921 +
1922 + require.True(t, scenario.Protocols.LLDP)
1923 + require.False(t, scenario.Protocols.CDP)
1924 + require.False(t, scenario.Protocols.Bridge)
1925 + require.False(t, scenario.Protocols.ARPND)
1926 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1927 +
1928 + resolved, err := ResolveScenario(manifestPath, scenario)
1929 + require.NoError(t, err)
1930 + require.Len(t, resolved.Fixtures, 1)
1931 +
1932 + allWalks, err := LoadScenarioWalks(resolved)
1933 + require.NoError(t, err)
1934 + require.Len(t, allWalks, 1)
1935 +
1936 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1937 + require.NoError(t, errStep1)
1938 + require.Len(t, step1.Adjacencies, 3)
1939 + require.Equal(t, 3, step1.Stats["links_lldp"])
1940 + require.Equal(t, 0, step1.Stats["links_cdp"])
1941 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks))
1942 + require.Equal(t, map[string]int{"SW_D6_08_M": 3}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
1943 +
1944 + expected := map[string]struct{}{
1945 + "lldp|SW_D6_08_M|0|e0281l-scalbenga2-qfx|ge-0/0/15": {},
1946 + "lldp|SW_D6_08_M|0|i0506-su-tlc01|e8:27:25:07:96:63": {},
1947 + "lldp|SW_D6_08_M|0|i0506-su-tlc02|e8:27:25:07:83:27": {},
1948 + }
1949 + require.Equal(t, expected, adjacencyKeySet(step1.Adjacencies))
1950 +
1951 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1952 + require.NoError(t, err)
1953 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
1954 +}
1955 +
1956 +func TestBuildL2ResultFromWalks_LLDP_NMS18541_MICROSENS_SW09(t *testing.T) {
1957 + manifestPath := "../../../../testdata/snmp/enlinkd/nms18541/manifest.yaml"
1958 + manifest, err := LoadManifest(manifestPath)
1959 + require.NoError(t, err)
1960 +
1961 + scenario, ok := manifest.FindScenario("nms18541_microsens_sw09_lldp")
1962 + require.True(t, ok)
1963 +
1964 + require.True(t, scenario.Protocols.LLDP)
1965 + require.False(t, scenario.Protocols.CDP)
1966 + require.False(t, scenario.Protocols.Bridge)
1967 + require.False(t, scenario.Protocols.ARPND)
1968 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
1969 +
1970 + resolved, err := ResolveScenario(manifestPath, scenario)
1971 + require.NoError(t, err)
1972 + require.Len(t, resolved.Fixtures, 1)
1973 +
1974 + allWalks, err := LoadScenarioWalks(resolved)
1975 + require.NoError(t, err)
1976 + require.Len(t, allWalks, 1)
1977 +
1978 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
1979 + require.NoError(t, errStep1)
1980 + require.Len(t, step1.Adjacencies, 7)
1981 + require.Equal(t, 7, step1.Stats["links_lldp"])
1982 + require.Equal(t, 0, step1.Stats["links_cdp"])
1983 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(allWalks))
1984 + require.Equal(t, map[string]int{"SW_D6_09_M": 7}, countAdjacenciesBySource(step1.Adjacencies, "lldp"))
1985 +
1986 + expected := map[string]struct{}{
1987 + "lldp|SW_D6_09_M|1/1|axis-accc8ef9c0a3|ac:cc:8e:f9:c0:a3": {},
1988 + "lldp|SW_D6_09_M|2/1|axis-accc8e53f5fb|ac:cc:8e:53:f5:fb": {},
1989 + "lldp|SW_D6_09_M|2/2|axis-accc8e536f3c|ac:cc:8e:53:6f:3c": {},
1990 + "lldp|SW_D6_09_M|2/6|axis-accc8eaaeb7f|ac:cc:8e:aa:eb:7f": {},
1991 + "lldp|SW_D6_09_M|3/1|axis-accc8ef9c09e|ac:cc:8e:f9:c0:9e": {},
1992 + "lldp|SW_D6_09_M|3/2|axis camera|eth0": {},
1993 + "lldp|SW_D6_09_M|3/5|e0281l-scalbenga2-qfx|ge-0/0/16": {},
1994 + }
1995 + require.Equal(t, expected, adjacencyKeySet(step1.Adjacencies))
1996 +
1997 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
1998 + require.NoError(t, err)
1999 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
2000 +}
2001 +
2002 +func TestBuildL2ResultFromWalks_CDP_NMS7467(t *testing.T) {
2003 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7467/manifest.yaml"
2004 + manifest, err := LoadManifest(manifestPath)
2005 + require.NoError(t, err)
2006 +
2007 + scenario, ok := manifest.FindScenario("nms7467_cdp")
2008 + require.True(t, ok)
2009 +
2010 + require.False(t, scenario.Protocols.LLDP)
2011 + require.True(t, scenario.Protocols.CDP)
2012 + require.False(t, scenario.Protocols.Bridge)
2013 + require.False(t, scenario.Protocols.ARPND)
2014 + require.Equal(t, ManifestProtocols{CDP: true}, scenario.Protocols)
2015 +
2016 + resolved, err := ResolveScenario(manifestPath, scenario)
2017 + require.NoError(t, err)
2018 + require.Len(t, resolved.Fixtures, 1)
2019 +
2020 + ds, err := LoadWalkFile(resolved.Fixtures[0].WalkFile)
2021 + require.NoError(t, err)
2022 + require.Equal(t, 1, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.1.0")))
2023 + require.Equal(t, "JAB043408B7", strings.TrimSpace(mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.4.0")))
2024 + require.Equal(t, 3, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.7.0")))
2025 +
2026 + walks, walkErr := loadScenarioWalkPrefix(resolved, 1)
2027 + require.NoError(t, walkErr)
2028 +
2029 + final, buildErr := BuildL2ResultFromWalks(walks, BuildOptions{EnableCDP: true})
2030 + require.NoError(t, buildErr)
2031 + require.Len(t, final.Adjacencies, 5)
2032 + require.Equal(t, 5, final.Stats["links_cdp"])
2033 + require.Equal(t, 0, final.Stats["links_lldp"])
2034 + require.Equal(t, map[string]int{"ciscoswitch": 5}, countAdjacenciesBySource(final.Adjacencies, "cdp"))
2035 +
2036 + for _, adj := range final.Adjacencies {
2037 + require.NotEmpty(t, adj.TargetID)
2038 + require.NotEmpty(t, adj.SourcePort)
2039 + }
2040 +
2041 + expected := map[string]struct{}{
2042 + "cdp|ciscoswitch|2/1|sep0004f22ad83e|Port 1": {},
2043 + "cdp|ciscoswitch|2/39|mrgarrison.internal.opennms.com|GigabitEthernet0": {},
2044 + "cdp|ciscoswitch|2/4|sip000628f0fb0a|Port 1": {},
2045 + "cdp|ciscoswitch|2/44|mrmakay.internal.opennms.com|FastEthernet2": {},
2046 + "cdp|ciscoswitch|2/46|sip000ccea217a7|Port 1": {},
2047 + }
2048 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
2049 +
2050 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
2051 + require.NoError(t, err)
2052 +
2053 + expectedFromGolden := make(map[string]struct{}, len(golden.Adjacencies))
2054 + for _, adj := range golden.Adjacencies {
2055 + expectedFromGolden[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
2056 + }
2057 + require.Equal(t, expectedFromGolden, adjacencyKeySet(final.Adjacencies))
2058 +}
2059 +
2060 +func TestBuildL2ResultFromWalks_MIXED_NMS7563_CISCO01(t *testing.T) {
2061 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7563/manifest.yaml"
2062 + manifest, err := LoadManifest(manifestPath)
2063 + require.NoError(t, err)
2064 +
2065 + scenario, ok := manifest.FindScenario("nms7563_cisco01")
2066 + require.True(t, ok)
2067 +
2068 + require.True(t, scenario.Protocols.LLDP)
2069 + require.True(t, scenario.Protocols.CDP)
2070 + require.False(t, scenario.Protocols.Bridge)
2071 + require.False(t, scenario.Protocols.ARPND)
2072 + require.Equal(t, ManifestProtocols{LLDP: true, CDP: true}, scenario.Protocols)
2073 +
2074 + resolved, err := ResolveScenario(manifestPath, scenario)
2075 + require.NoError(t, err)
2076 + require.Len(t, resolved.Fixtures, 1)
2077 +
2078 + ds, err := LoadWalkFile(resolved.Fixtures[0].WalkFile)
2079 + require.NoError(t, err)
2080 + require.Equal(t, 1, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.1.0")))
2081 + require.Equal(t, "cisco01", strings.ToLower(strings.TrimSpace(mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.4.0"))))
2082 +
2083 + walks, walkErr := loadScenarioWalkPrefix(resolved, 1)
2084 + require.NoError(t, walkErr)
2085 +
2086 + final, buildErr := BuildL2ResultFromWalks(walks, BuildOptions{EnableLLDP: true, EnableCDP: true})
2087 + require.NoError(t, buildErr)
2088 + require.Len(t, final.Devices, 2)
2089 + require.Len(t, final.Adjacencies, 1)
2090 + require.Equal(t, 1, final.Stats["links_lldp"])
2091 + require.Equal(t, 0, final.Stats["links_cdp"])
2092 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(walks))
2093 + require.Equal(t, 0, countBidirectionalPairs(final.Adjacencies, "lldp"))
2094 +
2095 + deviceByID := make(map[string]engine.Device, len(final.Devices))
2096 + for _, dev := range final.Devices {
2097 + deviceByID[dev.ID] = dev
2098 + }
2099 + require.Contains(t, deviceByID, "cisco01")
2100 + require.Contains(t, deviceByID, "procurve switch 2510b-24")
2101 + require.Equal(t, "ac:a0:16:bf:02:00", deviceByID["cisco01"].ChassisID)
2102 + require.Equal(t, "00:1d:b3:c5:09:60", deviceByID["procurve switch 2510b-24"].ChassisID)
2103 + require.Equal(t, "ProCurve Switch 2510B-24", deviceByID["procurve switch 2510b-24"].Hostname)
2104 +
2105 + expected := map[string]struct{}{
2106 + "lldp|cisco01|Fa0/8|procurve switch 2510b-24|24": {},
2107 + }
2108 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
2109 +
2110 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
2111 + require.NoError(t, err)
2112 +
2113 + expectedFromGolden := make(map[string]struct{}, len(golden.Adjacencies))
2114 + for _, adj := range golden.Adjacencies {
2115 + expectedFromGolden[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
2116 + }
2117 + require.Equal(t, expectedFromGolden, adjacencyKeySet(final.Adjacencies))
2118 +}
2119 +
2120 +func TestBuildL2ResultFromWalks_LLDP_NMS7563_HOMESERVER(t *testing.T) {
2121 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7563/manifest.yaml"
2122 + manifest, err := LoadManifest(manifestPath)
2123 + require.NoError(t, err)
2124 +
2125 + scenario, ok := manifest.FindScenario("nms7563_homeserver_lldp")
2126 + require.True(t, ok)
2127 +
2128 + require.True(t, scenario.Protocols.LLDP)
2129 + require.False(t, scenario.Protocols.CDP)
2130 + require.False(t, scenario.Protocols.Bridge)
2131 + require.False(t, scenario.Protocols.ARPND)
2132 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
2133 +
2134 + resolved, err := ResolveScenario(manifestPath, scenario)
2135 + require.NoError(t, err)
2136 + require.Len(t, resolved.Fixtures, 1)
2137 +
2138 + walks, walkErr := loadScenarioWalkPrefix(resolved, 1)
2139 + require.NoError(t, walkErr)
2140 +
2141 + final, buildErr := BuildL2ResultFromWalks(walks, BuildOptions{EnableLLDP: true})
2142 + require.NoError(t, buildErr)
2143 + require.Len(t, final.Devices, 2)
2144 + require.Len(t, final.Adjacencies, 1)
2145 + require.Equal(t, 1, final.Stats["links_lldp"])
2146 + require.Equal(t, 0, final.Stats["links_cdp"])
2147 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(walks))
2148 +
2149 + deviceByID := make(map[string]engine.Device, len(final.Devices))
2150 + for _, dev := range final.Devices {
2151 + deviceByID[dev.ID] = dev
2152 + }
2153 + require.Contains(t, deviceByID, "homeserver")
2154 + require.Contains(t, deviceByID, "procurve switch 2510b-24")
2155 + require.Equal(t, "00:1f:f2:07:99:4f", deviceByID["homeserver"].ChassisID)
2156 + require.Equal(t, "server.home.schwartzkopff.org", deviceByID["homeserver"].Hostname)
2157 + require.Equal(t, "00:1d:b3:c5:09:60", deviceByID["procurve switch 2510b-24"].ChassisID)
2158 + require.Equal(t, "ProCurve Switch 2510B-24", deviceByID["procurve switch 2510b-24"].Hostname)
2159 +
2160 + expected := map[string]struct{}{
2161 + "lldp|homeserver|00 1F F2 07 99 4F|procurve switch 2510b-24|7": {},
2162 + }
2163 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
2164 +
2165 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
2166 + require.NoError(t, err)
2167 +
2168 + expectedFromGolden := make(map[string]struct{}, len(golden.Adjacencies))
2169 + for _, adj := range golden.Adjacencies {
2170 + expectedFromGolden[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
2171 + }
2172 + require.Equal(t, expectedFromGolden, adjacencyKeySet(final.Adjacencies))
2173 +}
2174 +
2175 +func TestBuildL2ResultFromWalks_CDP_NMS7563_SWITCH02(t *testing.T) {
2176 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7563/manifest.yaml"
2177 + manifest, err := LoadManifest(manifestPath)
2178 + require.NoError(t, err)
2179 +
2180 + scenario, ok := manifest.FindScenario("nms7563_switch02_cdp")
2181 + require.True(t, ok)
2182 +
2183 + require.False(t, scenario.Protocols.LLDP)
2184 + require.True(t, scenario.Protocols.CDP)
2185 + require.False(t, scenario.Protocols.Bridge)
2186 + require.False(t, scenario.Protocols.ARPND)
2187 + require.Equal(t, ManifestProtocols{CDP: true}, scenario.Protocols)
2188 +
2189 + resolved, err := ResolveScenario(manifestPath, scenario)
2190 + require.NoError(t, err)
2191 + require.Len(t, resolved.Fixtures, 1)
2192 +
2193 + ds, err := LoadWalkFile(resolved.Fixtures[0].WalkFile)
2194 + require.NoError(t, err)
2195 + require.Equal(t, 1, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.1.0")))
2196 + require.Equal(t, "ProCurve Switch 2510B-24(001db3-c50960)", strings.TrimSpace(mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.4.0")))
2197 +
2198 + walks, walkErr := loadScenarioWalkPrefix(resolved, 1)
2199 + require.NoError(t, walkErr)
2200 +
2201 + final, buildErr := BuildL2ResultFromWalks(walks, BuildOptions{EnableCDP: true})
2202 + require.NoError(t, buildErr)
2203 + require.Len(t, final.Devices, 3)
2204 + require.Len(t, final.Adjacencies, 3)
2205 + require.Equal(t, 3, final.Stats["links_cdp"])
2206 + require.Equal(t, 0, final.Stats["links_lldp"])
2207 + require.Equal(t, map[string]int{"switch02": 3}, countAdjacenciesBySource(final.Adjacencies, "cdp"))
2208 +
2209 + deviceByID := make(map[string]engine.Device, len(final.Devices))
2210 + for _, dev := range final.Devices {
2211 + deviceByID[dev.ID] = dev
2212 + }
2213 + require.Contains(t, deviceByID, "switch02")
2214 + require.Contains(t, deviceByID, "cisco01")
2215 + require.Contains(t, deviceByID, "00 1f f2 07 99 4f")
2216 + require.Equal(t, "cisco01", deviceByID["cisco01"].Hostname)
2217 + require.Equal(t, "00 1F F2 07 99 4F", deviceByID["00 1f f2 07 99 4f"].Hostname)
2218 +
2219 + for _, adj := range final.Adjacencies {
2220 + require.Equal(t, "cdp", adj.Protocol)
2221 + raw := strings.TrimSpace(adj.Labels["remote_address_raw"])
2222 + require.NotEmpty(t, raw)
2223 + decoded := decodeHexIP(raw)
2224 + require.NotEmpty(t, decoded)
2225 + _, parseErr := netip.ParseAddr(decoded)
2226 + require.NoError(t, parseErr)
2227 + }
2228 +
2229 + ipByAdjacency := make(map[string]string, len(final.Adjacencies))
2230 + for _, adj := range final.Adjacencies {
2231 + raw := strings.TrimSpace(adj.Labels["remote_address_raw"])
2232 + ipByAdjacency[adj.SourcePort+"|"+adj.TargetID+"|"+adj.TargetPort] = decodeHexIP(raw)
2233 + }
2234 + require.Equal(t, "192.168.88.240", ipByAdjacency["24|cisco01|Fa0/8"])
2235 + require.Equal(t, "192.168.88.240", ipByAdjacency["24|cisco01|FastEthernet0/8"])
2236 + require.Equal(t, "192.168.87.16", ipByAdjacency["7|00 1f f2 07 99 4f|00 1F F2 07 99 4F"])
2237 +
2238 + expected := map[string]struct{}{
2239 + "cdp|switch02|24|cisco01|Fa0/8": {},
2240 + "cdp|switch02|24|cisco01|FastEthernet0/8": {},
2241 + "cdp|switch02|7|00 1f f2 07 99 4f|00 1F F2 07 99 4F": {},
2242 + }
2243 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
2244 +
2245 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
2246 + require.NoError(t, err)
2247 +
2248 + expectedFromGolden := make(map[string]struct{}, len(golden.Adjacencies))
2249 + for _, adj := range golden.Adjacencies {
2250 + expectedFromGolden[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
2251 + }
2252 + require.Equal(t, expectedFromGolden, adjacencyKeySet(final.Adjacencies))
2253 +}
2254 +
2255 +func TestBuildL2ResultFromWalks_LLDP_NMS7777DW_NO_LINKS(t *testing.T) {
2256 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7777dw/manifest.yaml"
2257 + manifest, err := LoadManifest(manifestPath)
2258 + require.NoError(t, err)
2259 +
2260 + scenario, ok := manifest.FindScenario("nms7777dw_lldp_no_links")
2261 + require.True(t, ok)
2262 +
2263 + enableOSPF := false
2264 + enableISIS := false
2265 + require.True(t, scenario.Protocols.LLDP)
2266 + require.False(t, scenario.Protocols.CDP)
2267 + require.False(t, enableOSPF)
2268 + require.False(t, scenario.Protocols.Bridge)
2269 + require.False(t, enableISIS)
2270 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
2271 +
2272 + resolved, err := ResolveScenario(manifestPath, scenario)
2273 + require.NoError(t, err)
2274 + require.Len(t, resolved.Fixtures, 1)
2275 +
2276 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
2277 + require.NoError(t, errStep1)
2278 +
2279 + final := step1
2280 + require.Len(t, final.Devices, 1)
2281 + require.Len(t, final.Adjacencies, 0)
2282 + require.Equal(t, 0, final.Stats["links_lldp"])
2283 + require.Equal(t, 0, final.Stats["links_cdp"])
2284 + require.Equal(t, 0, countBidirectionalPairs(final.Adjacencies, "lldp"))
2285 +
2286 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
2287 + require.NoError(t, err)
2288 +
2289 + expected := make(map[string]struct{}, len(golden.Adjacencies))
2290 + for _, adj := range golden.Adjacencies {
2291 + expected[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
2292 + }
2293 + require.Equal(t, expected, adjacencyKeySet(final.Adjacencies))
2294 +
2295 + expectedDevices := make(map[string]string, len(golden.Devices))
2296 + for _, dev := range golden.Devices {
2297 + expectedDevices[dev.ID] = dev.Hostname
2298 + }
2299 + actualDevices := make(map[string]string, len(final.Devices))
2300 + for _, dev := range final.Devices {
2301 + actualDevices[dev.ID] = dev.Hostname
2302 + }
2303 + require.Equal(t, expectedDevices, actualDevices)
2304 +}
2305 +
2306 +func TestBuildL2ResultFromWalks_LLDP_NMS13923(t *testing.T) {
2307 + manifestPath := "../../../../testdata/snmp/enlinkd/nms13923/manifest.yaml"
2308 + manifest, err := LoadManifest(manifestPath)
2309 + require.NoError(t, err)
2310 +
2311 + scenario, ok := manifest.FindScenario("nms13923_lldp")
2312 + require.True(t, ok)
2313 +
2314 + enableOSPF := false
2315 + enableISIS := false
2316 + require.True(t, scenario.Protocols.LLDP)
2317 + require.False(t, scenario.Protocols.CDP)
2318 + require.False(t, enableOSPF)
2319 + require.False(t, scenario.Protocols.Bridge)
2320 + require.False(t, enableISIS)
2321 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
2322 +
2323 + resolved, err := ResolveScenario(manifestPath, scenario)
2324 + require.NoError(t, err)
2325 + require.Len(t, resolved.Fixtures, 1)
2326 + require.Equal(t, "srv005", resolved.Fixtures[0].DeviceID)
2327 +
2328 + walks, err := loadScenarioWalkPrefix(resolved, 1)
2329 + require.NoError(t, err)
2330 + require.Equal(t, 1, countFixturesWithLLDPLocalElements(walks))
2331 +
2332 + remoteChassisRows := 0
2333 + for _, rec := range walks[0].Records {
2334 + if !strings.HasPrefix(normalizeOID(rec.OID), "1.3.6.1.4.1.6527.3.1.2.59.4.1.1.5.") {
2335 + continue
2336 + }
2337 + remoteChassisRows++
2338 + require.Len(t, decodeHexBytes(rec.Value), 6)
2339 + }
2340 + require.Equal(t, 49, remoteChassisRows)
2341 +
2342 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
2343 + require.NoError(t, errStep1)
2344 + require.Equal(t, 49, step1.Stats["links_lldp"])
2345 + require.Len(t, step1.Adjacencies, 49)
2346 + require.Equal(t, 0, countBidirectionalPairs(step1.Adjacencies, "lldp"))
2347 +
2348 + sourceCounts := countAdjacenciesBySource(step1.Adjacencies, "lldp")
2349 + require.Equal(t, 1, len(sourceCounts))
2350 + require.Equal(t, 49, sourceCounts["srv005"])
2351 +
2352 + for _, adj := range step1.Adjacencies {
2353 + require.Equal(t, "lldp", adj.Protocol)
2354 + require.Equal(t, "srv005", adj.SourceID)
2355 + require.NotEmpty(t, adj.SourcePort)
2356 + require.NotEmpty(t, adj.TargetID)
2357 + }
2358 +
2359 + localDevices := map[string]struct{}{
2360 + "srv005": {},
2361 + }
2362 + require.Equal(t, 0, countUndirectedLocalDevicePairs(step1.Adjacencies, "lldp", localDevices))
2363 +
2364 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
2365 + require.NoError(t, err)
2366 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step1.Adjacencies))
2367 +}
2368 +
2369 +func TestBuildL2ResultFromWalks_LLDP_NMS13593(t *testing.T) {
2370 + manifestPath := "../../../../testdata/snmp/enlinkd/nms13593/manifest.yaml"
2371 + manifest, err := LoadManifest(manifestPath)
2372 + require.NoError(t, err)
2373 +
2374 + scenario, ok := manifest.FindScenario("nms13593_lldp")
2375 + require.True(t, ok)
2376 +
2377 + enableOSPF := false
2378 + enableISIS := false
2379 + require.True(t, scenario.Protocols.LLDP)
2380 + require.False(t, scenario.Protocols.CDP)
2381 + require.False(t, enableOSPF)
2382 + require.False(t, scenario.Protocols.Bridge)
2383 + require.False(t, enableISIS)
2384 + require.Equal(t, ManifestProtocols{LLDP: true}, scenario.Protocols)
2385 +
2386 + resolved, err := ResolveScenario(manifestPath, scenario)
2387 + require.NoError(t, err)
2388 + require.Len(t, resolved.Fixtures, 2)
2389 + require.Equal(t, "ZHBGO1Zsr001", resolved.Fixtures[0].DeviceID)
2390 + require.Equal(t, "ZHBGO1Zsr002", resolved.Fixtures[1].DeviceID)
2391 +
2392 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableLLDP: true})
2393 + require.NoError(t, errStep1)
2394 + require.Equal(t, 3, step1.Stats["links_lldp"])
2395 + require.Len(t, step1.Adjacencies, 3)
2396 +
2397 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 2, BuildOptions{EnableLLDP: true})
2398 + require.NoError(t, errStep2)
2399 + require.Equal(t, 7, step2.Stats["links_lldp"])
2400 + require.Len(t, step2.Adjacencies, 7)
2401 + require.Equal(t, 0, countBidirectionalPairs(step2.Adjacencies, "lldp"))
2402 +
2403 + localByID := make(map[string]engine.Device)
2404 + for _, dev := range step2.Devices {
2405 + if dev.ID != "ZHBGO1Zsr001" && dev.ID != "ZHBGO1Zsr002" {
2406 + continue
2407 + }
2408 + localByID[dev.ID] = dev
2409 + }
2410 + require.Len(t, localByID, 2)
2411 + require.Equal(t, "ZHBGO1Zsr001", localByID["ZHBGO1Zsr001"].Hostname)
2412 + require.Equal(t, "24:21:24:ec:e2:3f", localByID["ZHBGO1Zsr001"].ChassisID)
2413 + require.Equal(t, "ZHBGO1Zsr002", localByID["ZHBGO1Zsr002"].Hostname)
2414 + require.Equal(t, "24:21:24:da:f6:3f", localByID["ZHBGO1Zsr002"].ChassisID)
2415 +
2416 + expectedAdjacencies := map[string]struct{}{
2417 + "lldp|ZHBGO1Zsr001|104906753|esat-1|35700737": {},
2418 + "lldp|ZHBGO1Zsr001|105037825|ZHBGO1Zsr002|3/2/c5/1": {},
2419 + "lldp|ZHBGO1Zsr001|105070593|ZHBGO1Zsr002|3/2/c6/1": {},
2420 + "lldp|ZHBGO1Zsr002|3/2/c1/1|chassis-50e0ef005000|35700737": {},
2421 + "lldp|ZHBGO1Zsr002|3/2/c5/1|ZHBGO1Zsr001|3/2/c5/1": {},
2422 + "lldp|ZHBGO1Zsr002|3/2/c6/1|ZHBGO1Zsr001|3/2/c6/1": {},
2423 + "lldp|ZHBGO1Zsr002|esat-1/1/27|chassis-e48184acbf34|1610901763": {},
2424 + }
2425 + require.Equal(t, expectedAdjacencies, adjacencyKeySet(step2.Adjacencies))
2426 +
2427 + sourceCounts := countAdjacenciesBySource(step2.Adjacencies, "lldp")
2428 + require.Equal(t, 3, sourceCounts["ZHBGO1Zsr001"])
2429 + require.Equal(t, 4, sourceCounts["ZHBGO1Zsr002"])
2430 +
2431 + localDevices := map[string]struct{}{
2432 + "ZHBGO1Zsr001": {},
2433 + "ZHBGO1Zsr002": {},
2434 + }
2435 + require.Equal(t, 2, countLocalTopologyVertices(step2.Adjacencies, "lldp", localDevices))
2436 + require.Equal(t, 1, countUndirectedLocalDevicePairs(step2.Adjacencies, "lldp", localDevices))
2437 +
2438 + ifNamesByDevice := make(map[string]map[int]string)
2439 + for _, iface := range step2.Interfaces {
2440 + name := strings.TrimSpace(iface.IfName)
2441 + if idx := strings.Index(name, ","); idx >= 0 {
2442 + name = strings.TrimSpace(name[:idx])
2443 + }
2444 + if name == "" {
2445 + continue
2446 + }
2447 + byIndex := ifNamesByDevice[iface.DeviceID]
2448 + if byIndex == nil {
2449 + byIndex = make(map[int]string)
2450 + ifNamesByDevice[iface.DeviceID] = byIndex
2451 + }
2452 + byIndex[iface.IfIndex] = name
2453 + }
2454 +
2455 + normalizePort := func(deviceID, port string) string {
2456 + port = strings.TrimSpace(port)
2457 + if port == "" {
2458 + return ""
2459 + }
2460 + ifIndex, convErr := strconv.Atoi(port)
2461 + if convErr != nil {
2462 + return port
2463 + }
2464 + if byIndex, ok := ifNamesByDevice[deviceID]; ok {
2465 + if ifName, found := byIndex[ifIndex]; found && strings.TrimSpace(ifName) != "" {
2466 + return strings.TrimSpace(ifName)
2467 + }
2468 + }
2469 + return port
2470 + }
2471 +
2472 + type projectedLocalEdge struct {
2473 + SourceID string
2474 + TargetID string
2475 + SourcePort string
2476 + TargetPort string
2477 + }
2478 +
2479 + projectedEdges := make([]projectedLocalEdge, 0, 2)
2480 + for _, adj := range step2.Adjacencies {
2481 + if adj.Protocol != "lldp" {
2482 + continue
2483 + }
2484 + if adj.SourceID != "ZHBGO1Zsr001" || adj.TargetID != "ZHBGO1Zsr002" {
2485 + continue
2486 + }
2487 + projectedEdges = append(projectedEdges, projectedLocalEdge{
2488 + SourceID: adj.SourceID,
2489 + TargetID: adj.TargetID,
2490 + SourcePort: normalizePort(adj.SourceID, adj.SourcePort),
2491 + TargetPort: normalizePort(adj.TargetID, adj.TargetPort),
2492 + })
2493 + }
2494 +
2495 + sort.Slice(projectedEdges, func(i, j int) bool {
2496 + return projectedEdges[i].SourcePort < projectedEdges[j].SourcePort
2497 + })
2498 +
2499 + require.Len(t, projectedEdges, 2)
2500 + for _, edge := range projectedEdges {
2501 + require.Equal(t, "ZHBGO1Zsr001", edge.SourceID)
2502 + require.Equal(t, "ZHBGO1Zsr002", edge.TargetID)
2503 + require.Equal(t, edge.SourcePort, edge.TargetPort)
2504 + }
2505 + require.Equal(t, "3/2/c5/1", projectedEdges[0].SourcePort)
2506 + require.Equal(t, "3/2/c5/1", projectedEdges[0].TargetPort)
2507 + require.Equal(t, "3/2/c6/1", projectedEdges[1].SourcePort)
2508 + require.Equal(t, "3/2/c6/1", projectedEdges[1].TargetPort)
2509 +
2510 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
2511 + require.NoError(t, err)
2512 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step2.Adjacencies))
2513 +}
2514 +
2515 +func TestBuildL2ResultFromWalks_CDP(t *testing.T) {
2516 + walks := []FixtureWalk{
2517 + {
2518 + DeviceID: "switch-a",
2519 + Hostname: "switch-a.example.net",
2520 + Records: []WalkRecord{
2521 + {OID: "1.3.6.1.2.1.1.5.0", Type: "STRING", Value: "switch-a.example.net"},
2522 + {OID: "1.3.6.1.2.1.31.1.1.1.1.8", Type: "STRING", Value: "Gi0/0"},
2523 + {OID: "1.3.6.1.4.1.9.9.23.1.2.1.1.6.8.1", Type: "STRING", Value: "switch-b.example.net"},
2524 + {OID: "1.3.6.1.4.1.9.9.23.1.2.1.1.7.8.1", Type: "STRING", Value: "Gi0/1"},
2525 + },
2526 + },
2527 + {
2528 + DeviceID: "switch-b",
2529 + Hostname: "switch-b.example.net",
2530 + Records: []WalkRecord{
2531 + {OID: "1.3.6.1.2.1.1.5.0", Type: "STRING", Value: "switch-b.example.net"},
2532 + },
2533 + },
2534 + }
2535 +
2536 + result, err := BuildL2ResultFromWalks(walks, BuildOptions{EnableCDP: true})
2537 + require.NoError(t, err)
2538 + require.Len(t, result.Adjacencies, 1)
2539 +
2540 + adj := result.Adjacencies[0]
2541 + require.Equal(t, "cdp", adj.Protocol)
2542 + require.Equal(t, "switch-a", adj.SourceID)
2543 + require.Equal(t, "Gi0/0", adj.SourcePort)
2544 + require.Equal(t, "switch-b", adj.TargetID)
2545 + require.Equal(t, "Gi0/1", adj.TargetPort)
2546 +}
2547 +
2548 +func TestBuildL2ResultFromWalks_FDB(t *testing.T) {
2549 + walks := []FixtureWalk{
2550 + {
2551 + DeviceID: "switch-a",
2552 + Hostname: "switch-a.example.net",
2553 + Records: []WalkRecord{
2554 + {OID: "1.3.6.1.2.1.1.5.0", Type: "STRING", Value: "switch-a.example.net"},
2555 + {OID: "1.3.6.1.2.1.31.1.1.1.1.3", Type: "STRING", Value: "Port3"},
2556 + {OID: "1.3.6.1.2.1.17.1.4.1.2.7", Type: "INTEGER", Value: "3"},
2557 + {OID: "1.3.6.1.2.1.17.4.3.1.2.112.73.162.101.114.205", Type: "INTEGER", Value: "7"},
2558 + {OID: "1.3.6.1.2.1.17.4.3.1.3.112.73.162.101.114.205", Type: "INTEGER", Value: "learned"},
2559 + },
2560 + },
2561 + }
2562 +
2563 + result, err := BuildL2ResultFromWalks(walks, BuildOptions{EnableBridge: true})
2564 + require.NoError(t, err)
2565 + require.Len(t, result.Attachments, 1)
2566 +
2567 + attachment := result.Attachments[0]
2568 + require.Equal(t, "switch-a", attachment.DeviceID)
2569 + require.Equal(t, 3, attachment.IfIndex)
2570 + require.Equal(t, "mac:70:49:a2:65:72:cd", attachment.EndpointID)
2571 + require.Equal(t, "fdb", attachment.Method)
2572 + require.Equal(t, "bridge-domain:switch-a:if:3", attachment.Labels["bridge_domain"])
2573 + require.Equal(t, "7", attachment.Labels["bridge_port"])
2574 + require.Equal(t, "learned", attachment.Labels["fdb_status"])
2575 + require.Equal(t, "Port3", attachment.Labels["if_name"])
2576 + require.Equal(t, 1, result.Stats["attachments_fdb"])
2577 +}
2578 +
2579 +func TestBuildL2ResultFromWalks_ARPEnrichment(t *testing.T) {
2580 + walks := []FixtureWalk{
2581 + {
2582 + DeviceID: "switch-a",
2583 + Hostname: "switch-a.example.net",
2584 + Records: []WalkRecord{
2585 + {OID: "1.3.6.1.2.1.31.1.1.1.1.3", Type: "STRING", Value: "Port3"},
2586 + {OID: "1.3.6.1.2.1.4.22.1.2.3.10.20.4.84", Type: "Hex-STRING", Value: "7049a26572cd"},
2587 + {OID: "1.3.6.1.2.1.4.22.1.3.3.10.20.4.84", Type: "IpAddress", Value: "10.20.4.84"},
2588 + {OID: "1.3.6.1.2.1.4.22.1.4.3.10.20.4.84", Type: "INTEGER", Value: "dynamic"},
2589 + },
2590 + },
2591 + }
2592 +
2593 + result, err := BuildL2ResultFromWalks(walks, BuildOptions{EnableARP: true})
2594 + require.NoError(t, err)
2595 + require.Empty(t, result.Adjacencies)
2596 + require.Empty(t, result.Attachments)
2597 + require.Len(t, result.Enrichments, 1)
2598 +
2599 + enrichment := result.Enrichments[0]
2600 + require.Equal(t, "mac:70:49:a2:65:72:cd", enrichment.EndpointID)
2601 + require.Equal(t, "70:49:a2:65:72:cd", enrichment.MAC)
2602 + require.Len(t, enrichment.IPs, 1)
2603 + require.Equal(t, "10.20.4.84", enrichment.IPs[0].String())
2604 + require.Equal(t, "arp", enrichment.Labels["sources"])
2605 + require.Equal(t, "switch-a", enrichment.Labels["device_ids"])
2606 + require.Equal(t, "3", enrichment.Labels["if_indexes"])
2607 + require.Equal(t, "Port3", enrichment.Labels["if_names"])
2608 + require.Equal(t, "dynamic", enrichment.Labels["states"])
2609 + require.Equal(t, "ipv4", enrichment.Labels["addr_types"])
2610 + require.Equal(t, 1, result.Stats["enrichments_arp_nd"])
2611 +}
2612 +
2613 +func TestParseCDPInterfaceGetter_NMS0002_RPICT001(t *testing.T) {
2614 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms0002UkRoFakeLink/r-ro-suce-pict-001.txt")
2615 + require.NoError(t, err)
2616 +
2617 + require.Equal(t, "FastEthernet0", mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.1.1.1.6.1"))
2618 + require.Equal(t, "FastEthernet1", mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.1.1.1.6.2"))
2619 + require.Equal(t, "FastEthernet2", mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.1.1.1.6.3"))
2620 + require.Equal(t, "FastEthernet3", mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.1.1.1.6.4"))
2621 + require.Equal(t, "FastEthernet4", mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.1.1.1.6.5"))
2622 + require.Equal(t, "Tunnel0", mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.1.1.1.6.9"))
2623 + require.Equal(t, "Tunnel3", mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.1.1.1.6.10"))
2624 +
2625 + require.Equal(t, "FastEthernet0", mustLookupWalkValue(t, ds, "1.3.6.1.2.1.2.2.1.2.1"))
2626 + require.Equal(t, "FastEthernet1", mustLookupWalkValue(t, ds, "1.3.6.1.2.1.2.2.1.2.2"))
2627 + require.Equal(t, "FastEthernet2", mustLookupWalkValue(t, ds, "1.3.6.1.2.1.2.2.1.2.3"))
2628 + require.Equal(t, "FastEthernet3", mustLookupWalkValue(t, ds, "1.3.6.1.2.1.2.2.1.2.4"))
2629 + require.Equal(t, "FastEthernet4", mustLookupWalkValue(t, ds, "1.3.6.1.2.1.2.2.1.2.5"))
2630 + require.Equal(t, "Tunnel0", mustLookupWalkValue(t, ds, "1.3.6.1.2.1.2.2.1.2.9"))
2631 + require.Equal(t, "Tunnel3", mustLookupWalkValue(t, ds, "1.3.6.1.2.1.2.2.1.2.10"))
2632 +}
2633 +
2634 +func TestParseCDPGlobalGroup_NMS0002_RPICT001(t *testing.T) {
2635 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms0002UkRoFakeLink/r-ro-suce-pict-001.txt")
2636 + require.NoError(t, err)
2637 +
2638 + require.Equal(t, "r-ro-suce-pict-001.infra.u-ssi.net", mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.4.0"))
2639 + require.Equal(t, 1, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.1.0")))
2640 + _, hasDeviceFormat := ds.Lookup("1.3.6.1.4.1.9.9.23.1.3.7.0")
2641 + require.False(t, hasDeviceFormat)
2642 +}
2643 +
2644 +func TestParseCDPGlobalGroupWithDeviceFormat_NMS7467_CISCO_SWITCH(t *testing.T) {
2645 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms7467/192.0.2.7-walk.txt")
2646 + require.NoError(t, err)
2647 +
2648 + require.Equal(t, "JAB043408B7", mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.4.0"))
2649 + require.Equal(t, 1, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.1.0")))
2650 + require.Equal(t, 3, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.4.1.9.9.23.1.3.7.0")))
2651 +}
2652 +
2653 +func TestParseCDPCacheTable_NMS0002_RPICT001(t *testing.T) {
2654 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms0002UkRoFakeLink/r-ro-suce-pict-001.txt")
2655 + require.NoError(t, err)
2656 +
2657 + require.Equal(t, 14, countCDPCacheRows(ds))
2658 +}
2659 +
2660 +func TestParseLLDPLocalGroup_NMS17216_SWITCH1(t *testing.T) {
2661 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms17216/switch1-walk.txt")
2662 + require.NoError(t, err)
2663 +
2664 + require.Equal(t, "0016c8bd4d80", compactHexToken(mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.2.0")))
2665 + require.Equal(t, 4, mustAtoi(t, mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.1.0")))
2666 + require.Equal(t, "Switch1", mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.3.0"))
2667 +}
2668 +
2669 +func TestParseLLDPLocGetter_NMS17216_SWITCH1(t *testing.T) {
2670 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms17216/switch1-walk.txt")
2671 + require.NoError(t, err)
2672 +
2673 + val9 := lldpLocPortTriplet(t, ds, "9")
2674 + require.Len(t, val9, 3)
2675 + require.Equal(t, 5, mustAtoi(t, val9[0]))
2676 + require.Equal(t, "Gi0/9", val9[1])
2677 + require.Equal(t, "GigabitEthernet0/9", val9[2])
2678 +
2679 + val10 := lldpLocPortTriplet(t, ds, "10")
2680 + require.Len(t, val10, 3)
2681 + require.Equal(t, 5, mustAtoi(t, val10[0]))
2682 + require.Equal(t, "Gi0/10", val10[1])
2683 + require.Equal(t, "GigabitEthernet0/10", val10[2])
2684 +}
2685 +
2686 +func TestParseLLDPLocGetter_NMS17216_SWITCH2(t *testing.T) {
2687 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms17216/switch2-walk.txt")
2688 + require.NoError(t, err)
2689 +
2690 + val1 := lldpLocPortTriplet(t, ds, "1")
2691 + require.Len(t, val1, 3)
2692 + require.Equal(t, 5, mustAtoi(t, val1[0]))
2693 + require.Equal(t, "Gi0/1", val1[1])
2694 + require.Equal(t, "GigabitEthernet0/1", val1[2])
2695 +
2696 + val2 := lldpLocPortTriplet(t, ds, "2")
2697 + require.Len(t, val2, 3)
2698 + require.Equal(t, 5, mustAtoi(t, val2[0]))
2699 + require.Equal(t, "Gi0/2", val2[1])
2700 + require.Equal(t, "GigabitEthernet0/2", val2[2])
2701 +}
2702 +
2703 +func TestParseLLDPRemTable_NMS17216_SWITCH1(t *testing.T) {
2704 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms17216/switch1-walk.txt")
2705 + require.NoError(t, err)
2706 +
2707 + rows := collectLLDPRemoteRows(ds)
2708 + require.NotEmpty(t, rows)
2709 +
2710 + for _, row := range rows {
2711 + require.Len(t, row, 6)
2712 + require.Equal(t, 4, mustAtoi(t, row[4]))
2713 + require.Equal(t, 5, mustAtoi(t, row[6]))
2714 + }
2715 +}
2716 +
2717 +func TestParseLLDPRemoteTableWithLocLookup_NMS17216_SWITCH2(t *testing.T) {
2718 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms17216/switch2-walk.txt")
2719 + require.NoError(t, err)
2720 +
2721 + rows := collectLLDPRemoteRows(ds)
2722 + require.NotEmpty(t, rows)
2723 +
2724 + for key := range rows {
2725 + parts := strings.SplitN(key, "|", 2)
2726 + localPortNum := strings.TrimSpace(parts[0])
2727 + remIndex := strings.TrimSpace(parts[1])
2728 +
2729 + require.NotEmpty(t, key)
2730 + require.NotEmpty(t, remIndex)
2731 + require.NotEmpty(t, localPortNum)
2732 +
2733 + localPortID := ""
2734 + localPortIDSubtype := ""
2735 + localPortDescr := ""
2736 + require.Empty(t, localPortID)
2737 + require.Empty(t, localPortIDSubtype)
2738 + require.Empty(t, localPortDescr)
2739 +
2740 + updatedPortIDSubtype := mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.7.1.2."+localPortNum)
2741 + updatedPortID := mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.7.1.3."+localPortNum)
2742 + updatedPortDescr := mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.7.1.4."+localPortNum)
2743 + require.NotEmpty(t, updatedPortID)
2744 + require.Equal(t, 5, mustAtoi(t, updatedPortIDSubtype))
2745 + require.NotEmpty(t, updatedPortDescr)
2746 + }
2747 +}
2748 +
2749 +func TestParseTimeTetraLLDPVendorRows_NMS13593(t *testing.T) {
2750 + ds1, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms13593/srv001-walk.txt")
2751 + require.NoError(t, err)
2752 +
2753 + ds2, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms13593/srv002-walk.txt")
2754 + require.NoError(t, err)
2755 +
2756 + require.Equal(t, "24:21:24:ec:e2:3f", normalizeHexToken(mustLookupWalkValue(t, ds1, "1.0.8802.1.1.2.1.3.2.0")))
2757 + require.Equal(t, 4, mustAtoi(t, mustLookupWalkValue(t, ds1, "1.0.8802.1.1.2.1.3.1.0")))
2758 + require.Equal(t, "ZHBGO1Zsr001", mustLookupWalkValue(t, ds1, "1.0.8802.1.1.2.1.3.3.0"))
2759 + require.Equal(t, "24:21:24:da:f6:3f", normalizeHexToken(mustLookupWalkValue(t, ds2, "1.0.8802.1.1.2.1.3.2.0")))
2760 + require.Equal(t, 4, mustAtoi(t, mustLookupWalkValue(t, ds2, "1.0.8802.1.1.2.1.3.1.0")))
2761 + require.Equal(t, "ZHBGO1Zsr002", mustLookupWalkValue(t, ds2, "1.0.8802.1.1.2.1.3.3.0"))
2762 +
2763 + require.Len(t, ds1.Prefix("1.0.8802.1.1.2.1.4.1.1."), 0)
2764 + require.Len(t, ds2.Prefix("1.0.8802.1.1.2.1.4.1.1."), 0)
2765 +
2766 + rows1 := collectTimeTetraRemoteRows(t, ds1)
2767 + rows2 := collectTimeTetraRemoteRows(t, ds2)
2768 + require.Equal(t, 3, len(rows1))
2769 + require.Equal(t, 4, len(rows2))
2770 +
2771 + for _, row := range rows1 {
2772 + require.NotZero(t, row.IfIndex)
2773 + require.NotZero(t, row.LocalPortNum)
2774 + require.NotZero(t, row.RemIndex)
2775 + require.NotEmpty(t, row.ChassisID)
2776 + require.NotEmpty(t, row.PortID)
2777 + require.NotEmpty(t, row.PortDescr)
2778 + require.Equal(t, 4, row.ChassisSubtype)
2779 + require.Equal(t, 1, row.LocalDestMACAddress)
2780 + }
2781 +
2782 + for _, row := range rows2 {
2783 + require.NotZero(t, row.IfIndex)
2784 + require.NotZero(t, row.LocalPortNum)
2785 + require.NotZero(t, row.RemIndex)
2786 + require.NotEmpty(t, row.ChassisID)
2787 + require.Equal(t, 4, row.ChassisSubtype)
2788 + require.Equal(t, 1, row.LocalDestMACAddress)
2789 +
2790 + localRows := timeTetraLocalPortRowsByIfIndex(t, ds2, row.IfIndex)
2791 + require.NotEmpty(t, localRows)
2792 + require.Equal(t, 7, localRows[0].PortSubtype)
2793 + require.NotEmpty(t, localRows[0].PortDescr)
2794 + }
2795 +}
2796 +
2797 +func TestParseTimeTetraLLDPVendorRows_NMS13923_SRV005(t *testing.T) {
2798 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms13923/srv005.txt")
2799 + require.NoError(t, err)
2800 +
2801 + require.Equal(t, "00:16:4d:dd:d5:5b", normalizeHexToken(mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.2.0")))
2802 + require.Equal(t, 4, mustAtoi(t, mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.1.0")))
2803 + require.Equal(t, "srv005", mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.3.0"))
2804 + require.Equal(t, "00:16:4d:dd:d5:5b", normalizeHexToken(mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.2.0")))
2805 + require.Equal(t, 4, mustAtoi(t, mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.1.0")))
2806 + require.Equal(t, "srv005", mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.3.0"))
2807 +
2808 + rows := collectTimeTetraRemoteRows(t, ds)
2809 + require.Equal(t, 49, len(rows))
2810 +
2811 + seen := make(map[string]bool, len(rows))
2812 + for _, row := range rows {
2813 + require.False(t, seen[row.IndexKey])
2814 + seen[row.IndexKey] = true
2815 +
2816 + require.NotZero(t, row.IfIndex)
2817 + require.NotZero(t, row.LocalPortNum)
2818 + require.NotZero(t, row.RemIndex)
2819 + require.NotEmpty(t, row.ChassisID)
2820 + require.NotEmpty(t, row.PortID)
2821 + require.NotEmpty(t, row.PortDescr)
2822 + require.Equal(t, 4, row.ChassisSubtype)
2823 + require.Equal(t, 7, row.PortSubtype)
2824 + }
2825 +}
2826 +
2827 +func TestBuildL2ResultFromWalks_ARP_NMS102(t *testing.T) {
2828 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms102/mikrotik-192.168.0.1-walk.txt")
2829 + require.NoError(t, err)
2830 +
2831 + result, err := BuildL2ResultFromWalks([]FixtureWalk{
2832 + {
2833 + DeviceID: "mikrotik",
2834 + Hostname: "ARS-AP",
2835 + Address: "192.168.0.1",
2836 + Records: ds.Records,
2837 + },
2838 + }, BuildOptions{EnableARP: true})
2839 + require.NoError(t, err)
2840 + require.Empty(t, result.Adjacencies)
2841 + require.Empty(t, result.Attachments)
2842 + require.Len(t, result.Enrichments, 6)
2843 + require.Equal(t, 6, result.Stats["enrichments_arp_nd"])
2844 +
2845 + expectedByIP := map[string]struct {
2846 + endpointID string
2847 + mac string
2848 + ifIndexes string
2849 + ifNames string
2850 + }{
2851 + "10.129.16.1": {
2852 + endpointID: "mac:00:90:1a:42:22:f8",
2853 + mac: "00:90:1a:42:22:f8",
2854 + ifIndexes: "1",
2855 + ifNames: "ether1",
2856 + },
2857 + "10.129.16.164": {
2858 + endpointID: "mac:00:13:c8:f1:d2:42",
2859 + mac: "00:13:c8:f1:d2:42",
2860 + ifIndexes: "1",
2861 + ifNames: "ether1",
2862 + },
2863 + "192.168.0.13": {
2864 + endpointID: "mac:f0:72:8c:99:99:4d",
2865 + mac: "f0:72:8c:99:99:4d",
2866 + ifIndexes: "2",
2867 + ifNames: "wlan1",
2868 + },
2869 + "192.168.0.14": {
2870 + endpointID: "mac:00:15:99:9f:07:ef",
2871 + mac: "00:15:99:9f:07:ef",
2872 + ifIndexes: "2",
2873 + ifNames: "wlan1",
2874 + },
2875 + "192.168.0.16": {
2876 + endpointID: "mac:60:33:4b:08:17:a8",
2877 + mac: "60:33:4b:08:17:a8",
2878 + ifIndexes: "2",
2879 + ifNames: "wlan1",
2880 + },
2881 + "192.168.0.17": {
2882 + endpointID: "mac:00:1b:63:cd:a9:fd",
2883 + mac: "00:1b:63:cd:a9:fd",
2884 + ifIndexes: "2",
2885 + ifNames: "wlan1",
2886 + },
2887 + }
2888 +
2889 + actualByIP := make(map[string]engine.Enrichment, len(result.Enrichments))
2890 + for _, enrichment := range result.Enrichments {
2891 + require.Len(t, enrichment.IPs, 1)
2892 + actualByIP[enrichment.IPs[0].String()] = enrichment
2893 + }
2894 + require.Len(t, actualByIP, 6)
2895 +
2896 + for ip, expected := range expectedByIP {
2897 + enrichment, ok := actualByIP[ip]
2898 + require.True(t, ok, "missing enrichment for %s", ip)
2899 + require.Equal(t, expected.endpointID, enrichment.EndpointID)
2900 + require.Equal(t, expected.mac, enrichment.MAC)
2901 + require.Equal(t, "arp", enrichment.Labels["sources"])
2902 + require.Equal(t, "mikrotik", enrichment.Labels["device_ids"])
2903 + require.Equal(t, expected.ifIndexes, enrichment.Labels["if_indexes"])
2904 + require.Equal(t, expected.ifNames, enrichment.Labels["if_names"])
2905 + require.Equal(t, "3", enrichment.Labels["states"])
2906 + require.Equal(t, "ipv4", enrichment.Labels["addr_types"])
2907 + }
2908 +}
2909 +
2910 +func TestParseBridgeBaseWalk_NMS4930(t *testing.T) {
2911 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms4930/dlink_DES-3026.properties")
2912 + require.NoError(t, err)
2913 +
2914 + require.Equal(t, "00:1e:58:a3:2f:cd", normalizeHexToken(mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.1.1.0")))
2915 + require.Equal(t, 26, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.1.2.0")))
2916 + require.Equal(t, 2, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.1.3.0")))
2917 + require.Equal(t, 3, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.1.0")))
2918 + require.Equal(t, 32768, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.2.0")))
2919 + require.Equal(t, "0000000000000000", compactHexToken(mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.5.0")))
2920 + require.Equal(t, 0, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.6.0")))
2921 + require.Equal(t, 0, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.7.0")))
2922 +}
2923 +
2924 +func TestParseBridgeBasePortTable_NMS4930(t *testing.T) {
2925 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms4930/dlink_DES-3026.properties")
2926 + require.NoError(t, err)
2927 +
2928 + rows := ds.Prefix("1.3.6.1.2.1.17.1.4.1.2.")
2929 + require.Len(t, rows, 26)
2930 +
2931 + const prefix = "1.3.6.1.2.1.17.1.4.1.2."
2932 + for _, row := range rows {
2933 + basePort, convErr := strconv.Atoi(strings.TrimPrefix(row.OID, prefix))
2934 + require.NoError(t, convErr)
2935 + ifIndex, parseErr := strconv.Atoi(strings.TrimSpace(row.Value))
2936 + require.NoError(t, parseErr)
2937 + require.Equal(t, basePort, ifIndex)
2938 + }
2939 +}
2940 +
2941 +func TestParseBridgeStpPortTable_NMS4930(t *testing.T) {
2942 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms4930/dlink_DES-3026.properties")
2943 + require.NoError(t, err)
2944 + require.Len(t, ds.Prefix("1.3.6.1.2.1.17.2.15.1.3."), 26)
2945 +
2946 + stateByPort := map[int]int{
2947 + 1: 5, 2: 5, 3: 5, 4: 5, 5: 5, 6: 5, 7: 1, 8: 1, 9: 1, 10: 1, 11: 1, 12: 1, 13: 1,
2948 + 14: 1, 15: 1, 16: 1, 17: 1, 18: 1, 19: 1, 20: 1, 21: 1, 22: 1, 23: 1, 24: 5, 25: 1, 26: 1,
2949 + }
2950 +
2951 + for i := 1; i <= 26; i++ {
2952 + idx := strconv.Itoa(i)
2953 + require.Equal(t, i, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.15.1.1."+idx)))
2954 + require.Equal(t, 128, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.15.1.2."+idx)))
2955 + require.Equal(t, stateByPort[i], mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.15.1.3."+idx)))
2956 + require.Equal(t, 1, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.15.1.4."+idx)))
2957 + require.Equal(t, 2000000, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.15.1.5."+idx)))
2958 + require.Equal(t, "0000000000000000", compactHexToken(mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.15.1.6."+idx)))
2959 + require.Equal(t, 0, mustAtoi(t, mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.15.1.7."+idx)))
2960 + require.Equal(t, "0000000000000000", compactHexToken(mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.15.1.8."+idx)))
2961 + require.Equal(t, "0000", compactHexToken(mustLookupWalkValue(t, ds, "1.3.6.1.2.1.17.2.15.1.9."+idx)))
2962 + }
2963 +}
2964 +
2965 +func TestBuildL2ResultFromWalks_FDB_NMS4930(t *testing.T) {
2966 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms4930/dlink_DES-3026.properties")
2967 + require.NoError(t, err)
2968 +
2969 + result, err := BuildL2ResultFromWalks([]FixtureWalk{
2970 + {
2971 + DeviceID: "dlink1",
2972 + Hostname: "dlink1",
2973 + Address: "10.1.1.2",
2974 + Records: ds.Records,
2975 + },
2976 + }, BuildOptions{EnableBridge: true})
2977 + require.NoError(t, err)
2978 + require.Empty(t, result.Adjacencies)
2979 + require.Len(t, result.Attachments, 17)
2980 + require.Equal(t, 17, result.Stats["attachments_fdb"])
2981 +
2982 + expectedPortByEndpoint := map[string]int{
2983 + "mac:00:0c:29:dc:c0:76": 24,
2984 + "mac:f0:7d:68:71:1f:89": 24,
2985 + "mac:f0:7d:68:76:c5:65": 24,
2986 + "mac:00:0f:fe:b1:0d:1e": 6,
2987 + "mac:00:0f:fe:b1:0e:26": 6,
2988 + "mac:00:1a:4b:80:27:90": 6,
2989 + "mac:00:1d:60:04:ac:bc": 6,
2990 + "mac:00:1e:58:86:5d:0f": 6,
2991 + "mac:00:21:91:3b:51:08": 6,
2992 + "mac:00:24:01:ad:34:16": 6,
2993 + "mac:00:24:8c:4c:8b:d0": 6,
2994 + "mac:00:24:d6:08:69:3e": 6,
2995 + "mac:1c:af:f7:37:cc:33": 6,
2996 + "mac:1c:af:f7:44:33:39": 6,
2997 + "mac:1c:bd:b9:b5:61:60": 6,
2998 + "mac:5c:d9:98:66:7a:bb": 6,
2999 + "mac:e0:cb:4e:3e:7f:c0": 6,
3000 + }
3001 +
3002 + actualPortByEndpoint := make(map[string]int, len(result.Attachments))
3003 + for _, attachment := range result.Attachments {
3004 + require.Equal(t, "dlink1", attachment.DeviceID)
3005 + require.Equal(t, "fdb", attachment.Method)
3006 + require.Equal(t, "3", attachment.Labels["fdb_status"])
3007 + bridgePort := mustAtoi(t, attachment.Labels["bridge_port"])
3008 + require.Equal(t, bridgePort, attachment.IfIndex)
3009 + actualPortByEndpoint[attachment.EndpointID] = bridgePort
3010 + }
3011 +
3012 + require.Equal(t, expectedPortByEndpoint, actualPortByEndpoint)
3013 +}
3014 +
3015 +func TestBuildL2ResultFromWalks_BRIDGE_NMS4930_DLINK1(t *testing.T) {
3016 + manifestPath := "../../../../testdata/snmp/enlinkd/nms4930/manifest.yaml"
3017 + manifest, err := LoadManifest(manifestPath)
3018 + require.NoError(t, err)
3019 +
3020 + scenario, ok := manifest.FindScenario("nms4930_dlink1_bridge_fdb")
3021 + require.True(t, ok)
3022 +
3023 + enableOSPF := false
3024 + enableISIS := false
3025 + require.False(t, scenario.Protocols.LLDP)
3026 + require.False(t, scenario.Protocols.CDP)
3027 + require.False(t, enableOSPF)
3028 + require.True(t, scenario.Protocols.Bridge)
3029 + require.False(t, enableISIS)
3030 + require.Equal(t, ManifestProtocols{Bridge: true}, scenario.Protocols)
3031 +
3032 + resolved, err := ResolveScenario(manifestPath, scenario)
3033 + require.NoError(t, err)
3034 + require.Len(t, resolved.Fixtures, 1)
3035 + require.Equal(t, "dlink1", resolved.Fixtures[0].DeviceID)
3036 +
3037 + preCollectionBridgeLinks := 0
3038 + preCollectionBridgeMacLinks := 0
3039 + require.Equal(t, 0, preCollectionBridgeLinks)
3040 + require.Equal(t, 0, preCollectionBridgeMacLinks)
3041 +
3042 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3043 + require.NoError(t, errStep1)
3044 + require.Empty(t, step1.Adjacencies)
3045 + require.Equal(t, 17, step1.Stats["attachments_fdb"])
3046 + require.Len(t, step1.Attachments, 17)
3047 +}
3048 +
3049 +func TestBuildL2ResultFromWalks_BRIDGE_NMS4930_DLINK2(t *testing.T) {
3050 + manifestPath := "../../../../testdata/snmp/enlinkd/nms4930/manifest.yaml"
3051 + manifest, err := LoadManifest(manifestPath)
3052 + require.NoError(t, err)
3053 +
3054 + scenario, ok := manifest.FindScenario("nms4930_dlink2_bridge_fdb")
3055 + require.True(t, ok)
3056 +
3057 + enableOSPF := false
3058 + enableISIS := false
3059 + require.False(t, scenario.Protocols.LLDP)
3060 + require.False(t, scenario.Protocols.CDP)
3061 + require.False(t, enableOSPF)
3062 + require.True(t, scenario.Protocols.Bridge)
3063 + require.False(t, enableISIS)
3064 + require.Equal(t, ManifestProtocols{Bridge: true}, scenario.Protocols)
3065 +
3066 + resolved, err := ResolveScenario(manifestPath, scenario)
3067 + require.NoError(t, err)
3068 + require.Len(t, resolved.Fixtures, 1)
3069 + require.Equal(t, "dlink2", resolved.Fixtures[0].DeviceID)
3070 +
3071 + preCollectionBridgeLinks := 0
3072 + preCollectionBridgeMacLinks := 0
3073 + require.Equal(t, 0, preCollectionBridgeLinks)
3074 + require.Equal(t, 0, preCollectionBridgeMacLinks)
3075 +
3076 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3077 + require.NoError(t, errStep1)
3078 + require.Empty(t, step1.Adjacencies)
3079 + require.Equal(t, 11, step1.Stats["attachments_fdb"])
3080 + require.Len(t, step1.Attachments, 11)
3081 +}
3082 +
3083 +func TestParseBridgeDot1qTpFdbTable_NMS4930(t *testing.T) {
3084 + ds1, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms4930/dlink_DES-3026.properties")
3085 + require.NoError(t, err)
3086 +
3087 + ds2, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/upstream/linkd/nms4930/dlink_DGS-3612G.properties")
3088 + require.NoError(t, err)
3089 +
3090 + macs1 := buildDot1qMacPortMap(ds1)
3091 + macs2 := buildDot1qMacPortMap(ds2)
3092 +
3093 + require.Equal(t, 59, len(macs1))
3094 + require.Equal(t, 979, len(macs2))
3095 + require.Equal(t, 0, macs2["000c6e3f9f3e"])
3096 + require.Equal(t, 0, macs2["a00bba158c8c"])
3097 +}
3098 +
3099 +func TestBuildL2ResultFromWalks_BRIDGE_NMS7918_ASW01(t *testing.T) {
3100 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7918/manifest.yaml"
3101 + manifest, err := LoadManifest(manifestPath)
3102 + require.NoError(t, err)
3103 +
3104 + scenario, ok := manifest.FindScenario("nms7918_asw01_bridge_fdb")
3105 + require.True(t, ok)
3106 +
3107 + enableOSPF := false
3108 + enableISIS := false
3109 + require.False(t, scenario.Protocols.LLDP)
3110 + require.False(t, scenario.Protocols.CDP)
3111 + require.False(t, enableOSPF)
3112 + require.True(t, scenario.Protocols.Bridge)
3113 + require.False(t, enableISIS)
3114 + require.Equal(t, ManifestProtocols{Bridge: true}, scenario.Protocols)
3115 +
3116 + resolved, err := ResolveScenario(manifestPath, scenario)
3117 + require.NoError(t, err)
3118 + require.Len(t, resolved.Fixtures, 1)
3119 + require.Equal(t, "asw01", resolved.Fixtures[0].DeviceID)
3120 +
3121 + preCollectionBridgeLinks := 0
3122 + preCollectionBridgeMacLinks := 0
3123 + require.Equal(t, 0, preCollectionBridgeLinks)
3124 + require.Equal(t, 0, preCollectionBridgeMacLinks)
3125 +
3126 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3127 + require.NoError(t, errStep1)
3128 + require.Len(t, step1.Devices, 1)
3129 + require.Empty(t, step1.Adjacencies)
3130 + require.Equal(t, 0, step1.Stats["links_lldp"])
3131 + require.Len(t, step1.Attachments, 40)
3132 + require.Equal(t, 40, step1.Stats["attachments_fdb"])
3133 +
3134 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3135 + require.NoError(t, errStep2)
3136 + require.Len(t, step2.Devices, 1)
3137 + require.Empty(t, step2.Adjacencies)
3138 + require.Equal(t, 0, step2.Stats["links_lldp"])
3139 + require.Len(t, step2.Attachments, 40)
3140 + require.Equal(t, 40, step2.Stats["attachments_fdb"])
3141 +
3142 + step3, errStep3 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3143 + require.NoError(t, errStep3)
3144 + require.Len(t, step3.Devices, 1)
3145 + require.Empty(t, step3.Adjacencies)
3146 + require.Equal(t, 0, step3.Stats["links_lldp"])
3147 + require.Len(t, step3.Attachments, 40)
3148 + require.Equal(t, 40, step3.Stats["attachments_fdb"])
3149 +
3150 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
3151 + require.NoError(t, err)
3152 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step3.Adjacencies))
3153 +}
3154 +
3155 +func TestBuildL2ResultFromWalks_BRIDGE_NMS7918_SAMASW01(t *testing.T) {
3156 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7918/manifest.yaml"
3157 + manifest, err := LoadManifest(manifestPath)
3158 + require.NoError(t, err)
3159 +
3160 + scenario, ok := manifest.FindScenario("nms7918_samasw01_bridge_fdb")
3161 + require.True(t, ok)
3162 +
3163 + enableOSPF := false
3164 + enableISIS := false
3165 + require.False(t, scenario.Protocols.LLDP)
3166 + require.False(t, scenario.Protocols.CDP)
3167 + require.False(t, enableOSPF)
3168 + require.True(t, scenario.Protocols.Bridge)
3169 + require.False(t, enableISIS)
3170 + require.Equal(t, ManifestProtocols{Bridge: true}, scenario.Protocols)
3171 +
3172 + resolved, err := ResolveScenario(manifestPath, scenario)
3173 + require.NoError(t, err)
3174 + require.Len(t, resolved.Fixtures, 1)
3175 + require.Equal(t, "samasw01", resolved.Fixtures[0].DeviceID)
3176 +
3177 + preCollectionBridgeLinks := 0
3178 + preCollectionBridgeMacLinks := 0
3179 + require.Equal(t, 0, preCollectionBridgeLinks)
3180 + require.Equal(t, 0, preCollectionBridgeMacLinks)
3181 +
3182 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3183 + require.NoError(t, errStep1)
3184 + require.Len(t, step1.Devices, 1)
3185 + require.Empty(t, step1.Adjacencies)
3186 + require.Equal(t, 0, step1.Stats["links_lldp"])
3187 + require.Len(t, step1.Attachments, 22)
3188 + require.Equal(t, 22, step1.Stats["attachments_fdb"])
3189 +
3190 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3191 + require.NoError(t, errStep2)
3192 + require.Len(t, step2.Devices, 1)
3193 + require.Empty(t, step2.Adjacencies)
3194 + require.Equal(t, 0, step2.Stats["links_lldp"])
3195 + require.Len(t, step2.Attachments, 22)
3196 + require.Equal(t, 22, step2.Stats["attachments_fdb"])
3197 +
3198 + step3, errStep3 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3199 + require.NoError(t, errStep3)
3200 + require.Len(t, step3.Devices, 1)
3201 + require.Empty(t, step3.Adjacencies)
3202 + require.Equal(t, 0, step3.Stats["links_lldp"])
3203 + require.Len(t, step3.Attachments, 22)
3204 + require.Equal(t, 22, step3.Stats["attachments_fdb"])
3205 +
3206 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
3207 + require.NoError(t, err)
3208 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step3.Adjacencies))
3209 +}
3210 +
3211 +func TestBuildL2ResultFromWalks_BRIDGE_NMS7918_STCASW01(t *testing.T) {
3212 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7918/manifest.yaml"
3213 + manifest, err := LoadManifest(manifestPath)
3214 + require.NoError(t, err)
3215 +
3216 + scenario, ok := manifest.FindScenario("nms7918_stcasw01_bridge_fdb")
3217 + require.True(t, ok)
3218 +
3219 + enableOSPF := false
3220 + enableISIS := false
3221 + require.False(t, scenario.Protocols.LLDP)
3222 + require.False(t, scenario.Protocols.CDP)
3223 + require.False(t, enableOSPF)
3224 + require.True(t, scenario.Protocols.Bridge)
3225 + require.False(t, enableISIS)
3226 + require.Equal(t, ManifestProtocols{Bridge: true}, scenario.Protocols)
3227 +
3228 + resolved, err := ResolveScenario(manifestPath, scenario)
3229 + require.NoError(t, err)
3230 + require.Len(t, resolved.Fixtures, 1)
3231 + require.Equal(t, "stcasw01", resolved.Fixtures[0].DeviceID)
3232 +
3233 + preCollectionBridgeLinks := 0
3234 + preCollectionBridgeMacLinks := 0
3235 + require.Equal(t, 0, preCollectionBridgeLinks)
3236 + require.Equal(t, 0, preCollectionBridgeMacLinks)
3237 +
3238 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3239 + require.NoError(t, errStep1)
3240 + require.Len(t, step1.Devices, 1)
3241 + require.Empty(t, step1.Adjacencies)
3242 + require.Equal(t, 0, step1.Stats["links_lldp"])
3243 + require.Len(t, step1.Attachments, 34)
3244 + require.Equal(t, 34, step1.Stats["attachments_fdb"])
3245 +
3246 + step2, errStep2 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3247 + require.NoError(t, errStep2)
3248 + require.Len(t, step2.Devices, 1)
3249 + require.Empty(t, step2.Adjacencies)
3250 + require.Equal(t, 0, step2.Stats["links_lldp"])
3251 + require.Len(t, step2.Attachments, 34)
3252 + require.Equal(t, 34, step2.Stats["attachments_fdb"])
3253 +
3254 + step3, errStep3 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableBridge: true})
3255 + require.NoError(t, errStep3)
3256 + require.Len(t, step3.Devices, 1)
3257 + require.Empty(t, step3.Adjacencies)
3258 + require.Equal(t, 0, step3.Stats["links_lldp"])
3259 + require.Len(t, step3.Attachments, 34)
3260 + require.Equal(t, 34, step3.Stats["attachments_fdb"])
3261 +
3262 + golden, err := LoadGoldenYAML(resolved.GoldenYAML)
3263 + require.NoError(t, err)
3264 + require.Equal(t, goldenAdjacencyKeySet(golden.Adjacencies), adjacencyKeySet(step3.Adjacencies))
3265 +}
3266 +
3267 +func TestBuildL2ResultFromWalks_ARP_NMS7918_OSPWL01(t *testing.T) {
3268 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7918/manifest.yaml"
3269 + manifest, err := LoadManifest(manifestPath)
3270 + require.NoError(t, err)
3271 +
3272 + scenario, ok := manifest.FindScenario("nms7918_ospwl01_arp")
3273 + require.True(t, ok)
3274 +
3275 + enableOSPF := false
3276 + enableISIS := false
3277 + require.False(t, scenario.Protocols.LLDP)
3278 + require.False(t, scenario.Protocols.CDP)
3279 + require.False(t, enableOSPF)
3280 + require.False(t, scenario.Protocols.Bridge)
3281 + require.False(t, enableISIS)
3282 + require.True(t, scenario.Protocols.ARPND)
3283 + require.Equal(t, ManifestProtocols{ARPND: true}, scenario.Protocols)
3284 +
3285 + resolved, err := ResolveScenario(manifestPath, scenario)
3286 + require.NoError(t, err)
3287 + require.Len(t, resolved.Fixtures, 1)
3288 + require.Equal(t, "ospwl01", resolved.Fixtures[0].DeviceID)
3289 +
3290 + preCollectionBridgeLinks := 0
3291 + preCollectionBridgeMacLinks := 0
3292 + preCollectionARPNDEntries := 0
3293 + require.Equal(t, 0, preCollectionBridgeLinks)
3294 + require.Equal(t, 0, preCollectionBridgeMacLinks)
3295 + require.Equal(t, 0, preCollectionARPNDEntries)
3296 +
3297 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableARP: true})
3298 + require.NoError(t, errStep1)
3299 + require.Len(t, step1.Devices, 1)
3300 + require.Empty(t, step1.Adjacencies)
3301 + require.Empty(t, step1.Attachments)
3302 + require.Len(t, step1.Enrichments, 1)
3303 + require.Equal(t, 1, step1.Stats["enrichments_arp_nd"])
3304 +
3305 + enrichment := step1.Enrichments[0]
3306 + require.Equal(t, "00:13:19:bd:b4:40", enrichment.MAC)
3307 + require.Empty(t, step1.Attachments)
3308 + require.Len(t, step1.Enrichments, 1)
3309 + require.Len(t, enrichment.IPs, 1)
3310 + require.Contains(t, enrichment.IPs[0].String(), "10.25.19.1")
3311 + require.Equal(t, "arp", enrichment.Labels["sources"])
3312 + require.Equal(t, "7", enrichment.Labels["if_indexes"])
3313 + require.Equal(t, "bridge1", enrichment.Labels["if_names"])
3314 + require.Equal(t, "3", enrichment.Labels["states"])
3315 + require.Equal(t, "ipv4", enrichment.Labels["addr_types"])
3316 +}
3317 +
3318 +func TestBuildL2ResultFromWalks_ARP_NMS7918_OSPESS01(t *testing.T) {
3319 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7918/manifest.yaml"
3320 + manifest, err := LoadManifest(manifestPath)
3321 + require.NoError(t, err)
3322 +
3323 + scenario, ok := manifest.FindScenario("nms7918_ospess01_arp")
3324 + require.True(t, ok)
3325 +
3326 + enableOSPF := false
3327 + enableISIS := false
3328 + require.False(t, scenario.Protocols.LLDP)
3329 + require.False(t, scenario.Protocols.CDP)
3330 + require.False(t, enableOSPF)
3331 + require.False(t, scenario.Protocols.Bridge)
3332 + require.False(t, enableISIS)
3333 + require.True(t, scenario.Protocols.ARPND)
3334 + require.Equal(t, ManifestProtocols{ARPND: true}, scenario.Protocols)
3335 +
3336 + resolved, err := ResolveScenario(manifestPath, scenario)
3337 + require.NoError(t, err)
3338 + require.Len(t, resolved.Fixtures, 1)
3339 + require.Equal(t, "ospess01", resolved.Fixtures[0].DeviceID)
3340 +
3341 + preCollectionBridgeLinks := 0
3342 + preCollectionBridgeMacLinks := 0
3343 + preCollectionARPNDEntries := 0
3344 + require.Equal(t, 0, preCollectionBridgeLinks)
3345 + require.Equal(t, 0, preCollectionBridgeMacLinks)
3346 + require.Equal(t, 0, preCollectionARPNDEntries)
3347 +
3348 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableARP: true})
3349 + require.NoError(t, errStep1)
3350 + require.Len(t, step1.Devices, 1)
3351 + require.Empty(t, step1.Adjacencies)
3352 + require.Empty(t, step1.Attachments)
3353 + require.Len(t, step1.Enrichments, 4)
3354 + require.Equal(t, 4, step1.Stats["enrichments_arp_nd"])
3355 + require.Equal(t, 5, countEnrichmentIPs(step1.Enrichments))
3356 +
3357 + byMAC := enrichmentByMAC(step1.Enrichments)
3358 + require.Len(t, byMAC, 4)
3359 +
3360 + pe01 := mustEnrichmentByMAC(t, byMAC, "00:13:19:bd:b4:40")
3361 + require.Equal(t, "mac:00:13:19:bd:b4:40", pe01.EndpointID)
3362 + require.Equal(t, "00:13:19:bd:b4:40", pe01.MAC)
3363 + require.Len(t, pe01.IPs, 2)
3364 + require.True(t, enrichmentContainsIP(pe01, "10.25.19.1"))
3365 + require.True(t, enrichmentContainsIP(pe01, "10.27.19.1"))
3366 + require.Equal(t, "arp", pe01.Labels["sources"])
3367 + require.Equal(t, "ipv4", pe01.Labels["addr_types"])
3368 + require.Contains(t, pe01.Labels["if_indexes"], "10")
3369 + require.Contains(t, pe01.Labels["if_indexes"], "11")
3370 +
3371 + nonPEWithSingleIP := 0
3372 + for mac, enrichment := range byMAC {
3373 + if mac == pe01.MAC {
3374 + continue
3375 + }
3376 + if len(enrichment.IPs) == 1 {
3377 + nonPEWithSingleIP++
3378 + }
3379 + }
3380 + require.Equal(t, 3, nonPEWithSingleIP)
3381 +}
3382 +
3383 +func TestBuildL2ResultFromWalks_ARP_NMS7918_PE01(t *testing.T) {
3384 + manifestPath := "../../../../testdata/snmp/enlinkd/nms7918/manifest.yaml"
3385 + manifest, err := LoadManifest(manifestPath)
3386 + require.NoError(t, err)
3387 +
3388 + scenario, ok := manifest.FindScenario("nms7918_pe01_arp")
3389 + require.True(t, ok)
3390 +
3391 + enableOSPF := false
3392 + enableISIS := false
3393 + require.False(t, scenario.Protocols.LLDP)
3394 + require.False(t, scenario.Protocols.CDP)
3395 + require.False(t, enableOSPF)
3396 + require.False(t, scenario.Protocols.Bridge)
3397 + require.False(t, enableISIS)
3398 + require.True(t, scenario.Protocols.ARPND)
3399 + require.Equal(t, ManifestProtocols{ARPND: true}, scenario.Protocols)
3400 +
3401 + resolved, err := ResolveScenario(manifestPath, scenario)
3402 + require.NoError(t, err)
3403 + require.Len(t, resolved.Fixtures, 1)
3404 + require.Equal(t, "pe01", resolved.Fixtures[0].DeviceID)
3405 +
3406 + preCollectionBridgeLinks := 0
3407 + preCollectionBridgeMacLinks := 0
3408 + preCollectionARPNDEntries := 0
3409 + require.Equal(t, 0, preCollectionBridgeLinks)
3410 + require.Equal(t, 0, preCollectionBridgeMacLinks)
3411 + require.Equal(t, 0, preCollectionARPNDEntries)
3412 +
3413 + step1, errStep1 := buildResultFromScenarioPrefix(resolved, 1, BuildOptions{EnableARP: true})
3414 + require.NoError(t, errStep1)
3415 + require.Len(t, step1.Devices, 1)
3416 + require.Empty(t, step1.Adjacencies)
3417 + require.Empty(t, step1.Attachments)
3418 + require.Len(t, step1.Enrichments, 37)
3419 + require.Equal(t, 37, step1.Stats["enrichments_arp_nd"])
3420 + require.Equal(t, 113, countEnrichmentIPs(step1.Enrichments))
3421 +
3422 + byMAC := enrichmentByMAC(step1.Enrichments)
3423 + require.Len(t, byMAC, 37)
3424 +
3425 + pe01 := mustEnrichmentByMAC(t, byMAC, "00:13:19:bd:b4:40")
3426 + require.Equal(t, "mac:00:13:19:bd:b4:40", pe01.EndpointID)
3427 + require.Len(t, pe01.IPs, 45)
3428 + require.True(t, enrichmentContainsIP(pe01, "10.25.19.1"))
3429 + require.True(t, enrichmentContainsIP(pe01, "10.27.19.1"))
3430 + require.Equal(t, "arp", pe01.Labels["sources"])
3431 + require.Equal(t, "ipv4", pe01.Labels["addr_types"])
3432 + require.Equal(t, "pe01", pe01.Labels["device_ids"])
3433 +
3434 + asw01 := mustEnrichmentByMAC(t, byMAC, "00:e0:b1:bd:2f:5c")
3435 + require.Equal(t, "mac:00:e0:b1:bd:2f:5c", asw01.EndpointID)
3436 + require.Len(t, asw01.IPs, 1)
3437 + require.True(t, enrichmentContainsIP(asw01, "10.25.19.2"))
3438 +
3439 + ospess01 := mustEnrichmentByMAC(t, byMAC, "00:17:63:01:0d:4f")
3440 + require.Equal(t, "mac:00:17:63:01:0d:4f", ospess01.EndpointID)
3441 + require.Len(t, ospess01.IPs, 5)
3442 + require.True(t, enrichmentContainsIP(ospess01, "10.25.19.3"))
3443 +
3444 + ospwl01 := mustEnrichmentByMAC(t, byMAC, "d4:ca:6d:ed:84:d6")
3445 + require.Equal(t, "mac:d4:ca:6d:ed:84:d6", ospwl01.EndpointID)
3446 + require.Len(t, ospwl01.IPs, 1)
3447 + require.True(t, enrichmentContainsIP(ospwl01, "10.25.19.4"))
3448 +
3449 + samasw01 := mustEnrichmentByMAC(t, byMAC, "00:12:cf:3f:4e:e0")
3450 + require.Equal(t, "mac:00:12:cf:3f:4e:e0", samasw01.EndpointID)
3451 + require.Len(t, samasw01.IPs, 2)
3452 + require.True(t, enrichmentContainsIP(samasw01, "10.25.19.211"))
3453 +
3454 + stcasw01 := mustEnrichmentByMAC(t, byMAC, "00:e0:b1:bd:26:52")
3455 + require.Equal(t, "mac:00:e0:b1:bd:26:52", stcasw01.EndpointID)
3456 + require.Len(t, stcasw01.IPs, 1)
3457 + require.True(t, enrichmentContainsIP(stcasw01, "10.25.19.216"))
3458 +}
3459 +
3460 +func buildDot1qMacPortMap(ds WalkDataset) map[string]int {
3461 + const dot1qPortPrefix = "1.3.6.1.2.1.17.7.1.2.2.1.2."
3462 + const dot1qStatusPrefix = "1.3.6.1.2.1.17.7.1.2.2.1.3."
3463 +
3464 + macPort := make(map[string]int)
3465 + portByIndex := make(map[string]int)
3466 + macByIndex := make(map[string]string)
3467 +
3468 + for _, rec := range ds.Records {
3469 + oid := normalizeOID(rec.OID)
3470 + if !strings.HasPrefix(oid, dot1qPortPrefix) {
3471 + continue
3472 + }
3473 +
3474 + index, mac, ok := dot1qIndexFromOID(oid, dot1qPortPrefix)
3475 + if !ok {
3476 + continue
3477 + }
3478 + port, err := strconv.Atoi(strings.TrimSpace(rec.Value))
3479 + if err != nil {
3480 + continue
3481 + }
3482 +
3483 + portByIndex[index] = port
3484 + macByIndex[index] = mac
3485 + macPort[mac] = port
3486 + }
3487 +
3488 + for _, rec := range ds.Records {
3489 + oid := normalizeOID(rec.OID)
3490 + if !strings.HasPrefix(oid, dot1qStatusPrefix) {
3491 + continue
3492 + }
3493 +
3494 + index, mac, ok := dot1qIndexFromOID(oid, dot1qStatusPrefix)
3495 + if !ok {
3496 + continue
3497 + }
3498 + if _, exists := macPort[mac]; exists {
3499 + continue
3500 + }
3501 +
3502 + if port, ok := portByIndex[index]; ok {
3503 + macPort[mac] = port
3504 + continue
3505 + }
3506 + if indexedMAC, ok := macByIndex[index]; ok {
3507 + macPort[indexedMAC] = 0
3508 + continue
3509 + }
3510 + macPort[mac] = 0
3511 + }
3512 +
3513 + return macPort
3514 +}
3515 +
3516 +func dot1qIndexFromOID(oid, prefix string) (key string, mac string, ok bool) {
3517 + suffix := strings.TrimPrefix(oid, prefix)
3518 + suffix = strings.TrimPrefix(suffix, ".")
3519 + parts := strings.Split(suffix, ".")
3520 + if len(parts) < 7 {
3521 + return "", "", false
3522 + }
3523 +
3524 + macParts := parts[len(parts)-6:]
3525 + octets := make([]byte, 0, 6)
3526 + for _, part := range macParts {
3527 + n, err := strconv.Atoi(strings.TrimSpace(part))
3528 + if err != nil || n < 0 || n > 255 {
3529 + return "", "", false
3530 + }
3531 + octets = append(octets, byte(n))
3532 + }
3533 +
3534 + macBuilder := strings.Builder{}
3535 + for _, octet := range octets {
3536 + _, _ = fmt.Fprintf(&macBuilder, "%02x", octet)
3537 + }
3538 +
3539 + return strings.Join(parts, "."), macBuilder.String(), true
3540 +}
3541 +
3542 +type timeTetraRemoteRow struct {
3543 + IndexKey string
3544 + LocalPortNum int
3545 + IfIndex int
3546 + LocalDestMACAddress int
3547 + RemIndex int
3548 + ChassisSubtype int
3549 + ChassisID string
3550 + PortSubtype int
3551 + PortID string
3552 + PortDescr string
3553 + SysName string
3554 +}
3555 +
3556 +type timeTetraLocalPortRow struct {
3557 + Key string
3558 + PortSubtype int
3559 + PortID string
3560 + PortDescr string
3561 +}
3562 +
3563 +func collectTimeTetraRemoteRows(t *testing.T, ds WalkDataset) []timeTetraRemoteRow {
3564 + t.Helper()
3565 +
3566 + const prefix = "1.3.6.1.4.1.6527.3.1.2.59.4.1.1."
3567 + chassisRows := ds.Prefix(prefix + "5.")
3568 + rows := make([]timeTetraRemoteRow, 0, len(chassisRows))
3569 +
3570 + for _, rec := range chassisRows {
3571 + oid := normalizeOID(rec.OID)
3572 + index := strings.TrimPrefix(oid, prefix+"5.")
3573 + require.NotEmpty(t, index)
3574 +
3575 + localPortNum, ifIndex, localDestMACAddress, remIndex := parseTimeTetraRemoteIndex(t, index)
3576 + row := timeTetraRemoteRow{
3577 + IndexKey: index,
3578 + LocalPortNum: localPortNum,
3579 + IfIndex: ifIndex,
3580 + LocalDestMACAddress: localDestMACAddress,
3581 + RemIndex: remIndex,
3582 + ChassisSubtype: mustAtoi(t, mustLookupWalkValue(t, ds, prefix+"4."+index)),
3583 + ChassisID: normalizeHexToken(rec.Value),
3584 + PortSubtype: mustAtoi(t, mustLookupWalkValue(t, ds, prefix+"6."+index)),
3585 + PortID: strings.TrimSpace(mustLookupWalkValue(t, ds, prefix+"7."+index)),
3586 + PortDescr: strings.TrimSpace(mustLookupWalkValue(t, ds, prefix+"8."+index)),
3587 + }
3588 +
3589 + if rec9, ok := ds.Lookup(prefix + "9." + index); ok {
3590 + row.SysName = strings.TrimSpace(rec9.Value)
3591 + }
3592 +
3593 + rows = append(rows, row)
3594 + }
3595 +
3596 + sort.Slice(rows, func(i, j int) bool {
3597 + return rows[i].IndexKey < rows[j].IndexKey
3598 + })
3599 +
3600 + return rows
3601 +}
3602 +
3603 +func parseTimeTetraRemoteIndex(t *testing.T, index string) (localPortNum int, ifIndex int, localDestMACAddress int, remIndex int) {
3604 + t.Helper()
3605 +
3606 + parts := strings.Split(strings.TrimSpace(index), ".")
3607 + require.Len(t, parts, 4)
3608 +
3609 + localPortNum = mustAtoi(t, parts[0])
3610 + ifIndex = mustAtoi(t, parts[1])
3611 + localDestMACAddress = mustAtoi(t, parts[2])
3612 + remIndex = mustAtoi(t, parts[3])
3613 + return
3614 +}
3615 +
3616 +func timeTetraLocalPortRowsByIfIndex(t *testing.T, ds WalkDataset, ifIndex int) []timeTetraLocalPortRow {
3617 + t.Helper()
3618 +
3619 + index := strconv.Itoa(ifIndex)
3620 + subtypePrefix := "1.3.6.1.4.1.6527.3.1.2.59.3.1.1.2." + index + "."
3621 + oidPrefix := "1.3.6.1.4.1.6527.3.1.2.59.3.1.1.3." + index + "."
3622 + descrPrefix := "1.3.6.1.4.1.6527.3.1.2.59.3.1.1.4." + index + "."
3623 +
3624 + subtypeRows := ds.Prefix(subtypePrefix)
3625 + rows := make([]timeTetraLocalPortRow, 0, len(subtypeRows))
3626 + for _, rec := range subtypeRows {
3627 + key := strings.TrimPrefix(normalizeOID(rec.OID), subtypePrefix)
3628 + if strings.TrimSpace(key) == "" {
3629 + continue
3630 + }
3631 +
3632 + portIDRec, ok := ds.Lookup(oidPrefix + key)
3633 + if !ok {
3634 + continue
3635 + }
3636 + portDescrRec, ok := ds.Lookup(descrPrefix + key)
3637 + if !ok {
3638 + continue
3639 + }
3640 +
3641 + rows = append(rows, timeTetraLocalPortRow{
3642 + Key: key,
3643 + PortSubtype: mustAtoi(t, rec.Value),
3644 + PortID: strings.TrimSpace(portIDRec.Value),
3645 + PortDescr: strings.TrimSpace(portDescrRec.Value),
3646 + })
3647 + }
3648 +
3649 + sort.Slice(rows, func(i, j int) bool {
3650 + return rows[i].Key < rows[j].Key
3651 + })
3652 +
3653 + return rows
3654 +}
3655 +
3656 +func buildResultFromScenarioPrefix(scenario ResolvedScenario, prefixLen int, opts BuildOptions) (engine.Result, error) {
3657 + walks, err := loadScenarioWalkPrefix(scenario, prefixLen)
3658 + if err != nil {
3659 + return engine.Result{}, err
3660 + }
3661 +
3662 + return BuildL2ResultFromWalks(walks, opts)
3663 +}
3664 +
3665 +func loadScenarioWalkPrefix(scenario ResolvedScenario, prefixLen int) ([]FixtureWalk, error) {
3666 + if prefixLen <= 0 || prefixLen > len(scenario.Fixtures) {
3667 + return nil, fmt.Errorf("invalid fixture prefix len %d", prefixLen)
3668 + }
3669 +
3670 + walks := make([]FixtureWalk, 0, prefixLen)
3671 + for i := 0; i < prefixLen; i++ {
3672 + fixture := scenario.Fixtures[i]
3673 + ds, err := LoadWalkFile(fixture.WalkFile)
3674 + if err != nil {
3675 + return nil, fmt.Errorf("load walk for fixture %q: %w", fixture.DeviceID, err)
3676 + }
3677 + walks = append(walks, FixtureWalk{
3678 + DeviceID: fixture.DeviceID,
3679 + Hostname: fixture.Hostname,
3680 + Address: fixture.Address,
3681 + Records: ds.Records,
3682 + })
3683 + }
3684 +
3685 + return walks, nil
3686 +}
3687 +
3688 +func countAdjacenciesBySource(adjacencies []engine.Adjacency, protocol string) map[string]int {
3689 + out := make(map[string]int)
3690 + for _, adj := range adjacencies {
3691 + if adj.Protocol != protocol {
3692 + continue
3693 + }
3694 + out[adj.SourceID]++
3695 + }
3696 + return out
3697 +}
3698 +
3699 +func countFixturesWithLLDPLocalElements(fixtures []FixtureWalk) int {
3700 + count := 0
3701 + for _, fixture := range fixtures {
3702 + hasLLDPIdentity := false
3703 + for _, rec := range fixture.Records {
3704 + switch normalizeOID(rec.OID) {
3705 + case "1.0.8802.1.1.2.1.3.2.0", "1.0.8802.1.1.2.1.3.3.0":
3706 + if strings.TrimSpace(rec.Value) != "" {
3707 + hasLLDPIdentity = true
3708 + }
3709 + }
3710 + if hasLLDPIdentity {
3711 + break
3712 + }
3713 + }
3714 + if hasLLDPIdentity {
3715 + count++
3716 + }
3717 + }
3718 + return count
3719 +}
3720 +
3721 +func goldenAdjacencyKeySet(adjacencies []GoldenAdjacency) map[string]struct{} {
3722 + out := make(map[string]struct{}, len(adjacencies))
3723 + for _, adj := range adjacencies {
3724 + out[adj.Protocol+"|"+adj.SourceDevice+"|"+adj.SourcePort+"|"+adj.TargetDevice+"|"+adj.TargetPort] = struct{}{}
3725 + }
3726 + return out
3727 +}
3728 +
3729 +func mustLookupWalkValue(t *testing.T, ds WalkDataset, oid string) string {
3730 + t.Helper()
3731 + record, ok := ds.Lookup(oid)
3732 + require.True(t, ok, "missing OID %s", oid)
3733 + return strings.TrimSpace(record.Value)
3734 +}
3735 +
3736 +func mustAtoi(t *testing.T, v string) int {
3737 + t.Helper()
3738 + out, err := strconv.Atoi(strings.TrimSpace(v))
3739 + require.NoError(t, err)
3740 + return out
3741 +}
3742 +
3743 +func compactHexToken(v string) string {
3744 + v = strings.TrimSpace(strings.ToLower(v))
3745 + return strings.NewReplacer(":", "", "-", "", ".", "", " ", "").Replace(v)
3746 +}
3747 +
3748 +func countCDPCacheRows(ds WalkDataset) int {
3749 + const prefix = "1.3.6.1.4.1.9.9.23.1.2.1.1.3."
3750 +
3751 + count := 0
3752 + for _, rec := range ds.Records {
3753 + if strings.HasPrefix(normalizeOID(rec.OID), prefix) {
3754 + count++
3755 + }
3756 + }
3757 + return count
3758 +}
3759 +
3760 +func lldpLocPortTriplet(t *testing.T, ds WalkDataset, portNum string) []string {
3761 + t.Helper()
3762 + return []string{
3763 + mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.7.1.2."+portNum),
3764 + mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.7.1.3."+portNum),
3765 + mustLookupWalkValue(t, ds, "1.0.8802.1.1.2.1.3.7.1.4."+portNum),
3766 + }
3767 +}
3768 +
3769 +func collectLLDPRemoteRows(ds WalkDataset) map[string]map[int]string {
3770 + const base = "1.0.8802.1.1.2.1.4.1.1."
3771 + rows := make(map[string]map[int]string)
3772 +
3773 + for _, rec := range ds.Records {
3774 + oid := normalizeOID(rec.OID)
3775 + if !strings.HasPrefix(oid, base) {
3776 + continue
3777 + }
3778 +
3779 + suffix := strings.TrimPrefix(oid, base)
3780 + suffix = strings.TrimPrefix(suffix, ".")
3781 + parts := strings.Split(suffix, ".")
3782 + if len(parts) < 4 {
3783 + continue
3784 + }
3785 +
3786 + column, err := strconv.Atoi(strings.TrimSpace(parts[0]))
3787 + if err != nil || column < 4 || column > 9 {
3788 + continue
3789 + }
3790 +
3791 + localPort := strings.TrimSpace(parts[len(parts)-2])
3792 + remIndex := strings.TrimSpace(parts[len(parts)-1])
3793 + if localPort == "" || remIndex == "" {
3794 + continue
3795 + }
3796 +
3797 + key := localPort + "|" + remIndex
3798 + row, ok := rows[key]
3799 + if !ok {
3800 + row = make(map[int]string, 6)
3801 + rows[key] = row
3802 + }
3803 + row[column] = strings.TrimSpace(rec.Value)
3804 + }
3805 +
3806 + return rows
3807 +}
3808 +
3809 +func adjacencyKeySet(adjacencies []engine.Adjacency) map[string]struct{} {
3810 + out := make(map[string]struct{}, len(adjacencies))
3811 + for _, adj := range adjacencies {
3812 + key := fmt.Sprintf("%s|%s|%s|%s|%s", adj.Protocol, adj.SourceID, adj.SourcePort, adj.TargetID, adj.TargetPort)
3813 + out[key] = struct{}{}
3814 + }
3815 + return out
3816 +}
3817 +
3818 +func countBidirectionalPairs(adjacencies []engine.Adjacency, protocol string) int {
3819 + directed := make(map[string]struct{}, len(adjacencies))
3820 + for _, adj := range adjacencies {
3821 + if adj.Protocol != protocol {
3822 + continue
3823 + }
3824 + directed[fmt.Sprintf("%s|%s|%s|%s|%s", adj.Protocol, adj.SourceID, adj.SourcePort, adj.TargetID, adj.TargetPort)] = struct{}{}
3825 + }
3826 +
3827 + counted := make(map[string]struct{}, len(directed))
3828 + pairs := 0
3829 + for _, adj := range adjacencies {
3830 + if adj.Protocol != protocol {
3831 + continue
3832 + }
3833 +
3834 + forward := fmt.Sprintf("%s|%s|%s|%s|%s", adj.Protocol, adj.SourceID, adj.SourcePort, adj.TargetID, adj.TargetPort)
3835 + reverse := fmt.Sprintf("%s|%s|%s|%s|%s", adj.Protocol, adj.TargetID, adj.TargetPort, adj.SourceID, adj.SourcePort)
3836 +
3837 + canonical := forward
3838 + if reverse < canonical {
3839 + canonical = reverse
3840 + }
3841 + if _, done := counted[canonical]; done {
3842 + continue
3843 + }
3844 + if _, ok := directed[reverse]; !ok {
3845 + continue
3846 + }
3847 +
3848 + counted[canonical] = struct{}{}
3849 + pairs++
3850 + }
3851 +
3852 + return pairs
3853 +}
3854 +
3855 +func countMutualLocalAdjacencyEdges(adjacencies []engine.Adjacency, protocol string, localDevices map[string]struct{}) int {
3856 + directedCounts := make(map[string]int)
3857 + for _, adj := range adjacencies {
3858 + if adj.Protocol != protocol || adj.SourceID == adj.TargetID {
3859 + continue
3860 + }
3861 + if _, ok := localDevices[adj.SourceID]; !ok {
3862 + continue
3863 + }
3864 + if _, ok := localDevices[adj.TargetID]; !ok {
3865 + continue
3866 + }
3867 + directedCounts[adj.SourceID+"|"+adj.TargetID]++
3868 + }
3869 +
3870 + counted := make(map[string]struct{}, len(directedCounts))
3871 + edges := 0
3872 + for key, forwardCount := range directedCounts {
3873 + parts := strings.SplitN(key, "|", 2)
3874 + if len(parts) != 2 {
3875 + continue
3876 + }
3877 +
3878 + left, right := parts[0], parts[1]
3879 + canonical := left + "|" + right
3880 + if right < left {
3881 + canonical = right + "|" + left
3882 + }
3883 + if _, ok := counted[canonical]; ok {
3884 + continue
3885 + }
3886 +
3887 + reverseCount := directedCounts[right+"|"+left]
3888 + if forwardCount < reverseCount {
3889 + edges += forwardCount
3890 + } else {
3891 + edges += reverseCount
3892 + }
3893 +
3894 + counted[canonical] = struct{}{}
3895 + }
3896 +
3897 + return edges
3898 +}
3899 +
3900 +func enrichmentByMAC(enrichments []engine.Enrichment) map[string]engine.Enrichment {
3901 + byMAC := make(map[string]engine.Enrichment, len(enrichments))
3902 + for _, enrichment := range enrichments {
3903 + if enrichment.MAC == "" {
3904 + continue
3905 + }
3906 + byMAC[enrichment.MAC] = enrichment
3907 + }
3908 + return byMAC
3909 +}
3910 +
3911 +func mustEnrichmentByMAC(t *testing.T, byMAC map[string]engine.Enrichment, mac string) engine.Enrichment {
3912 + t.Helper()
3913 + enrichment, ok := byMAC[mac]
3914 + require.True(t, ok, "missing enrichment for mac=%s", mac)
3915 + return enrichment
3916 +}
3917 +
3918 +func enrichmentContainsIP(enrichment engine.Enrichment, ip string) bool {
3919 + for _, addr := range enrichment.IPs {
3920 + if addr.String() == ip {
3921 + return true
3922 + }
3923 + }
3924 + return false
3925 +}
3926 +
3927 +func countEnrichmentIPs(enrichments []engine.Enrichment) int {
3928 + total := 0
3929 + for _, enrichment := range enrichments {
3930 + total += len(enrichment.IPs)
3931 + }
3932 + return total
3933 +}
3934 +
3935 +func countUndirectedLocalDevicePairs(adjacencies []engine.Adjacency, protocol string, localDevices map[string]struct{}) int {
3936 + pairs := make(map[string]struct{})
3937 + for _, adj := range adjacencies {
3938 + if adj.Protocol != protocol || adj.SourceID == adj.TargetID {
3939 + continue
3940 + }
3941 + if _, ok := localDevices[adj.SourceID]; !ok {
3942 + continue
3943 + }
3944 + if _, ok := localDevices[adj.TargetID]; !ok {
3945 + continue
3946 + }
3947 + left, right := adj.SourceID, adj.TargetID
3948 + if right < left {
3949 + left, right = right, left
3950 + }
3951 + pairs[left+"|"+right] = struct{}{}
3952 + }
3953 + return len(pairs)
3954 +}
3955 +
3956 +func countLocalTopologyVertices(adjacencies []engine.Adjacency, protocol string, localDevices map[string]struct{}) int {
3957 + vertices := make(map[string]struct{})
3958 + for _, adj := range adjacencies {
3959 + if adj.Protocol != protocol || adj.SourceID == adj.TargetID {
3960 + continue
3961 + }
3962 + if _, ok := localDevices[adj.SourceID]; !ok {
3963 + continue
3964 + }
3965 + if _, ok := localDevices[adj.TargetID]; !ok {
3966 + continue
3967 + }
3968 + vertices[adj.SourceID] = struct{}{}
3969 + vertices[adj.TargetID] = struct{}{}
3970 + }
3971 + return len(vertices)
3972 +}
src/go/pkg/topology/engine/parity/manifest.go new
+233
@@ -0,0 +1,233 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package parity
4 +
5 +import (
6 + "fmt"
7 + "maps"
8 + "os"
9 + "path/filepath"
10 + "strings"
11 +
12 + "gopkg.in/yaml.v3"
13 +)
14 +
15 +// ManifestVersion is the current topology parity manifest schema version.
16 +const ManifestVersion = "v1"
17 +
18 +// Manifest defines one or more topology parity scenarios.
19 +type Manifest struct {
20 + Version string `yaml:"version"`
21 + Source ManifestSource `yaml:"source"`
22 + Scenarios []ManifestScenario `yaml:"scenarios"`
23 +}
24 +
25 +// ManifestSource captures provenance for imported parity fixtures.
26 +type ManifestSource struct {
27 + Repo string `yaml:"repo"`
28 + Commit string `yaml:"commit"`
29 + Path string `yaml:"path"`
30 +}
31 +
32 +// ManifestProtocols declares protocol toggles for a scenario.
33 +type ManifestProtocols struct {
34 + LLDP bool `yaml:"lldp"`
35 + CDP bool `yaml:"cdp"`
36 + Bridge bool `yaml:"bridge"`
37 + ARPND bool `yaml:"arp_nd"`
38 +}
39 +
40 +// ManifestFixture describes one device fixture.
41 +type ManifestFixture struct {
42 + DeviceID string `yaml:"device_id"`
43 + Hostname string `yaml:"hostname"`
44 + Address string `yaml:"address"`
45 + WalkFile string `yaml:"walk_file"`
46 + Labels map[string]string `yaml:"labels,omitempty"`
47 +}
48 +
49 +// ManifestScenario defines one parity scenario.
50 +type ManifestScenario struct {
51 + ID string `yaml:"id"`
52 + Description string `yaml:"description"`
53 + Protocols ManifestProtocols `yaml:"protocols"`
54 + Fixtures []ManifestFixture `yaml:"fixtures"`
55 + GoldenYAML string `yaml:"golden_yaml"`
56 + GoldenJSON string `yaml:"golden_json"`
57 +}
58 +
59 +// ResolvedScenario contains absolute paths resolved from a manifest file.
60 +type ResolvedScenario struct {
61 + ID string
62 + Description string
63 + Protocols ManifestProtocols
64 + Fixtures []ResolvedFixture
65 + GoldenYAML string
66 + GoldenJSON string
67 +}
68 +
69 +// ResolvedFixture is one resolved fixture path.
70 +type ResolvedFixture struct {
71 + DeviceID string
72 + Hostname string
73 + Address string
74 + WalkFile string
75 + Labels map[string]string
76 +}
77 +
78 +// LoadManifest reads and validates a parity scenario manifest.
79 +func LoadManifest(path string) (Manifest, error) {
80 + data, err := os.ReadFile(path)
81 + if err != nil {
82 + return Manifest{}, fmt.Errorf("read manifest %q: %w", path, err)
83 + }
84 +
85 + var m Manifest
86 + if err := yaml.Unmarshal(data, &m); err != nil {
87 + return Manifest{}, fmt.Errorf("decode manifest %q: %w", path, err)
88 + }
89 + if err := m.validate(); err != nil {
90 + return Manifest{}, fmt.Errorf("validate manifest %q: %w", path, err)
91 + }
92 + return m, nil
93 +}
94 +
95 +// ResolveScenario resolves one scenario's relative file paths against the
96 +// manifest location and validates that all referenced files exist.
97 +func ResolveScenario(manifestPath string, scenario ManifestScenario) (ResolvedScenario, error) {
98 + if scenario.ID == "" {
99 + return ResolvedScenario{}, fmt.Errorf("scenario id is required")
100 + }
101 +
102 + baseDir := filepath.Dir(manifestPath)
103 + resolved := ResolvedScenario{
104 + ID: scenario.ID,
105 + Description: scenario.Description,
106 + Protocols: scenario.Protocols,
107 + Fixtures: make([]ResolvedFixture, 0, len(scenario.Fixtures)),
108 + GoldenYAML: resolvePath(baseDir, scenario.GoldenYAML),
109 + GoldenJSON: resolvePath(baseDir, scenario.GoldenJSON),
110 + }
111 +
112 + if err := requireFile(resolved.GoldenYAML); err != nil {
113 + return ResolvedScenario{}, fmt.Errorf("scenario %q golden_yaml: %w", scenario.ID, err)
114 + }
115 + if err := requireFile(resolved.GoldenJSON); err != nil {
116 + return ResolvedScenario{}, fmt.Errorf("scenario %q golden_json: %w", scenario.ID, err)
117 + }
118 +
119 + seenDevice := make(map[string]struct{}, len(scenario.Fixtures))
120 + for _, fixture := range scenario.Fixtures {
121 + if fixture.DeviceID == "" {
122 + return ResolvedScenario{}, fmt.Errorf("scenario %q has fixture with empty device_id", scenario.ID)
123 + }
124 + if _, ok := seenDevice[fixture.DeviceID]; ok {
125 + return ResolvedScenario{}, fmt.Errorf("scenario %q has duplicate fixture device_id %q", scenario.ID, fixture.DeviceID)
126 + }
127 + seenDevice[fixture.DeviceID] = struct{}{}
128 +
129 + walkPath := resolvePath(baseDir, fixture.WalkFile)
130 + if err := requireFile(walkPath); err != nil {
131 + return ResolvedScenario{}, fmt.Errorf("scenario %q fixture %q walk_file: %w", scenario.ID, fixture.DeviceID, err)
132 + }
133 +
134 + resolved.Fixtures = append(resolved.Fixtures, ResolvedFixture{
135 + DeviceID: fixture.DeviceID,
136 + Hostname: fixture.Hostname,
137 + Address: fixture.Address,
138 + WalkFile: walkPath,
139 + Labels: copyStringMap(fixture.Labels),
140 + })
141 + }
142 +
143 + return resolved, nil
144 +}
145 +
146 +// FindScenario finds one scenario by ID.
147 +func (m Manifest) FindScenario(id string) (ManifestScenario, bool) {
148 + for _, scenario := range m.Scenarios {
149 + if scenario.ID == id {
150 + return scenario, true
151 + }
152 + }
153 + return ManifestScenario{}, false
154 +}
155 +
156 +func (m Manifest) validate() error {
157 + if m.Version == "" {
158 + return fmt.Errorf("version is required")
159 + }
160 + if m.Version != ManifestVersion {
161 + return fmt.Errorf("unsupported version %q (want %q)", m.Version, ManifestVersion)
162 + }
163 + if len(m.Scenarios) == 0 {
164 + return fmt.Errorf("at least one scenario is required")
165 + }
166 +
167 + seenScenario := make(map[string]struct{}, len(m.Scenarios))
168 + for _, scenario := range m.Scenarios {
169 + if scenario.ID == "" {
170 + return fmt.Errorf("scenario id is required")
171 + }
172 + if _, ok := seenScenario[scenario.ID]; ok {
173 + return fmt.Errorf("duplicate scenario id %q", scenario.ID)
174 + }
175 + seenScenario[scenario.ID] = struct{}{}
176 +
177 + if scenario.GoldenYAML == "" || scenario.GoldenJSON == "" {
178 + return fmt.Errorf("scenario %q requires golden_yaml and golden_json", scenario.ID)
179 + }
180 + if len(scenario.Fixtures) == 0 {
181 + return fmt.Errorf("scenario %q requires at least one fixture", scenario.ID)
182 + }
183 + if !scenario.Protocols.LLDP && !scenario.Protocols.CDP && !scenario.Protocols.Bridge && !scenario.Protocols.ARPND {
184 + return fmt.Errorf("scenario %q must enable at least one protocol", scenario.ID)
185 + }
186 +
187 + seenDevice := make(map[string]struct{}, len(scenario.Fixtures))
188 + for _, fixture := range scenario.Fixtures {
189 + if fixture.DeviceID == "" {
190 + return fmt.Errorf("scenario %q has fixture with empty device_id", scenario.ID)
191 + }
192 + if strings.TrimSpace(fixture.WalkFile) == "" {
193 + return fmt.Errorf("scenario %q fixture %q requires walk_file", scenario.ID, fixture.DeviceID)
194 + }
195 + if _, ok := seenDevice[fixture.DeviceID]; ok {
196 + return fmt.Errorf("scenario %q has duplicate fixture device_id %q", scenario.ID, fixture.DeviceID)
197 + }
198 + seenDevice[fixture.DeviceID] = struct{}{}
199 + }
200 + }
201 +
202 + return nil
203 +}
204 +
205 +func requireFile(path string) error {
206 + if strings.TrimSpace(path) == "" {
207 + return fmt.Errorf("path is empty")
208 + }
209 + st, err := os.Stat(path)
210 + if err != nil {
211 + return err
212 + }
213 + if st.IsDir() {
214 + return fmt.Errorf("%q is a directory", path)
215 + }
216 + return nil
217 +}
218 +
219 +func resolvePath(baseDir, path string) string {
220 + if filepath.IsAbs(path) {
221 + return filepath.Clean(path)
222 + }
223 + return filepath.Clean(filepath.Join(baseDir, path))
224 +}
225 +
226 +func copyStringMap(in map[string]string) map[string]string {
227 + if len(in) == 0 {
228 + return nil
229 + }
230 + out := make(map[string]string, len(in))
231 + maps.Copy(out, in)
232 + return out
233 +}
src/go/pkg/topology/engine/parity/manifest_fixture_test.go new
+41
@@ -0,0 +1,41 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +//go:build topology_fixtures
4 +
5 +package parity
6 +
7 +import (
8 + "os"
9 + "testing"
10 +
11 + "github.com/stretchr/testify/require"
12 +)
13 +
14 +func TestLoadAndResolveManifest(t *testing.T) {
15 + manifestPath := "../../../../testdata/snmp/enlinkd/nms8003/manifest.yaml"
16 +
17 + manifest, err := LoadManifest(manifestPath)
18 + require.NoError(t, err)
19 + require.Equal(t, ManifestVersion, manifest.Version)
20 + require.Len(t, manifest.Scenarios, 1)
21 +
22 + scenario, ok := manifest.FindScenario("nms8003_lldp")
23 + require.True(t, ok)
24 + require.True(t, scenario.Protocols.LLDP)
25 + require.False(t, scenario.Protocols.CDP)
26 +
27 + resolved, err := ResolveScenario(manifestPath, scenario)
28 + require.NoError(t, err)
29 + require.Len(t, resolved.Fixtures, 5)
30 +
31 + for _, fixture := range resolved.Fixtures {
32 + st, err := os.Stat(fixture.WalkFile)
33 + require.NoError(t, err)
34 + require.False(t, st.IsDir())
35 + }
36 +
37 + _, err = os.Stat(resolved.GoldenYAML)
38 + require.NoError(t, err)
39 + _, err = os.Stat(resolved.GoldenJSON)
40 + require.NoError(t, err)
41 +}
src/go/pkg/topology/engine/parity/manifest_test.go new
+38
@@ -0,0 +1,38 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package parity
4 +
5 +import (
6 + "os"
7 + "path/filepath"
8 + "testing"
9 +
10 + "github.com/stretchr/testify/require"
11 +)
12 +
13 +func TestLoadManifest_Invalid(t *testing.T) {
14 + tmpDir := t.TempDir()
15 + manifestPath := filepath.Join(tmpDir, "manifest.yaml")
16 + badManifest := `version: v1
17 +scenarios:
18 + - id: duplicate
19 + protocols: {lldp: true}
20 + fixtures:
21 + - device_id: d1
22 + walk_file: fixture1.txt
23 + golden_yaml: golden.yaml
24 + golden_json: golden.json
25 + - id: duplicate
26 + protocols: {lldp: true}
27 + fixtures:
28 + - device_id: d2
29 + walk_file: fixture2.txt
30 + golden_yaml: golden2.yaml
31 + golden_json: golden2.json
32 +`
33 + require.NoError(t, os.WriteFile(manifestPath, []byte(badManifest), 0o644))
34 +
35 + _, err := LoadManifest(manifestPath)
36 + require.Error(t, err)
37 + require.ErrorContains(t, err, "duplicate scenario id")
38 +}
src/go/pkg/topology/engine/parity/node_topology_parity_test.go new
+345
@@ -0,0 +1,345 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +//go:build topology_fixtures
4 +
5 +package parity
6 +
7 +import (
8 + "encoding/json"
9 + "net/netip"
10 + "os"
11 + "path/filepath"
12 + "sort"
13 + "strings"
14 + "testing"
15 +
16 + "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
17 + "github.com/stretchr/testify/require"
18 +)
19 +
20 +type nodeTopologyFixtureDocument struct {
21 + SchemaVersion int `json:"schema_version"`
22 + Scenarios map[string]nodeTopologyScenario `json:"scenarios"`
23 +}
24 +
25 +type nodeTopologyScenario struct {
26 + BuilderClass string `json:"builder_class"`
27 + Getters []string `json:"getters"`
28 + Nodes []nodeTopologyFixtureNode `json:"nodes"`
29 + IPs []nodeTopologyFixtureIP `json:"ips"`
30 + SNMP []nodeTopologyFixtureSNMP `json:"snmp"`
31 + Expect nodeTopologyFixtureExpectations `json:"expect"`
32 +}
33 +
34 +type nodeTopologyFixtureNode struct {
35 + ID int `json:"id"`
36 + Label string `json:"label"`
37 + SysObject string `json:"sys_object"`
38 + SysName string `json:"sys_name"`
39 + Address string `json:"address"`
40 +}
41 +
42 +type nodeTopologyFixtureIP struct {
43 + ID int `json:"id"`
44 + NodeID int `json:"node_id"`
45 + IPAddress string `json:"ip_address"`
46 + Netmask string `json:"netmask"`
47 + IsManaged bool `json:"is_managed"`
48 + IsSnmpPrimary bool `json:"is_snmp_primary"`
49 + IfIndex int `json:"if_index"`
50 + SnmpInterfaceID int `json:"snmp_interface_id"`
51 +}
52 +
53 +type nodeTopologyFixtureSNMP struct {
54 + ID int `json:"id"`
55 + NodeID int `json:"node_id"`
56 + IfIndex int `json:"if_index"`
57 + IfName string `json:"if_name"`
58 + IfDescr string `json:"if_descr"`
59 +}
60 +
61 +type nodeTopologyFixtureExpectations struct {
62 + Nodes int `json:"nodes"`
63 + IPs *int `json:"ips"`
64 + Subnets *int `json:"subnets"`
65 + LegalSubnets *int `json:"legal_subnets"`
66 + PTPSubnets *int `json:"ptp_subnets"`
67 + LegalPTPSubnets *int `json:"legal_ptp_subnets"`
68 + Loopbacks *int `json:"loopbacks"`
69 + LegalLoopbacks *int `json:"legal_loopbacks"`
70 + MultiBet *int `json:"multibet"`
71 + PrioritySize *int `json:"priority_size"`
72 + PriorityRule nodeTopologyPriorityRule `json:"priority_rule"`
73 + NLinksFinal *int `json:"nlinks_final"`
74 + TopologyVertices *int `json:"topology_vertices"`
75 + TopologyEdges *int `json:"topology_edges"`
76 + CIDRNodeSizes []nodeTopologyCIDRExpectation `json:"cidr_node_sizes"`
77 +}
78 +
79 +type nodeTopologyPriorityRule struct {
80 + Kind string `json:"kind"`
81 + Value *int `json:"value"`
82 +}
83 +
84 +type nodeTopologyCIDRExpectation struct {
85 + CIDR string `json:"cidr"`
86 + NodeIDs int `json:"node_ids"`
87 +}
88 +
89 +func loadNodeTopologyFixtureDocument(t *testing.T) nodeTopologyFixtureDocument {
90 + t.Helper()
91 + path := filepath.Join("../../../../testdata/snmp/enlinkd", "node_topology", "scenarios.json")
92 + data, err := os.ReadFile(path)
93 + require.NoError(t, err)
94 +
95 + var doc nodeTopologyFixtureDocument
96 + require.NoError(t, json.Unmarshal(data, &doc))
97 + require.Equal(t, 1, doc.SchemaVersion)
98 + require.NotEmpty(t, doc.Scenarios)
99 + return doc
100 +}
101 +
102 +func scenarioToService(t *testing.T, scenario nodeTopologyScenario) *engine.NodeTopologyService {
103 + t.Helper()
104 +
105 + nodes := make([]engine.NodeTopologyEntity, 0, len(scenario.Nodes))
106 + for _, node := range scenario.Nodes {
107 + entry := engine.NodeTopologyEntity{
108 + ID: node.ID,
109 + Label: node.Label,
110 + SysObject: node.SysObject,
111 + SysName: node.SysName,
112 + }
113 + if ip, ok := parseOptionalAddr(node.Address); ok {
114 + entry.Address = ip
115 + }
116 + nodes = append(nodes, entry)
117 + }
118 +
119 + ips := make([]engine.IPInterfaceTopologyEntity, 0, len(scenario.IPs))
120 + for _, ip := range scenario.IPs {
121 + entry := engine.IPInterfaceTopologyEntity{
122 + ID: ip.ID,
123 + NodeID: ip.NodeID,
124 + IsManaged: ip.IsManaged,
125 + IsSnmpPrimary: ip.IsSnmpPrimary,
126 + IfIndex: ip.IfIndex,
127 + SnmpInterfaceID: ip.SnmpInterfaceID,
128 + }
129 + parsedIP, ok := parseOptionalAddr(ip.IPAddress)
130 + require.Truef(t, ok, "invalid fixture IP address %q", ip.IPAddress)
131 + entry.IPAddress = parsedIP
132 + if mask, ok := parseOptionalAddr(ip.Netmask); ok {
133 + entry.NetMask = mask
134 + }
135 + ips = append(ips, entry)
136 + }
137 +
138 + snmp := make([]engine.SnmpInterfaceTopologyEntity, 0, len(scenario.SNMP))
139 + for _, iface := range scenario.SNMP {
140 + snmp = append(snmp, engine.SnmpInterfaceTopologyEntity{
141 + ID: iface.ID,
142 + NodeID: iface.NodeID,
143 + IfIndex: iface.IfIndex,
144 + IfName: iface.IfName,
145 + IfDescr: iface.IfDescr,
146 + })
147 + }
148 +
149 + return engine.NewNodeTopologyService(nodes, ips, snmp)
150 +}
151 +
152 +func parseOptionalAddr(value string) (netip.Addr, bool) {
153 + value = stringsTrimSpace(value)
154 + if value == "" {
155 + return netip.Addr{}, false
156 + }
157 + addr, err := netip.ParseAddr(value)
158 + if err != nil {
159 + return netip.Addr{}, false
160 + }
161 + return addr.Unmap(), true
162 +}
163 +
164 +func runNodeTopologyScenarioParity(t *testing.T, scenarioName string) {
165 + t.Helper()
166 + doc := loadNodeTopologyFixtureDocument(t)
167 + scenario, ok := doc.Scenarios[scenarioName]
168 + require.Truef(t, ok, "missing scenario %q", scenarioName)
169 +
170 + service := scenarioToService(t, scenario)
171 + expect := scenario.Expect
172 +
173 + require.Len(t, service.FindAllNode(), expect.Nodes)
174 + if expect.IPs != nil {
175 + require.Len(t, service.FindAllIP(), *expect.IPs)
176 + }
177 +
178 + subnets := service.FindAllSubNetwork()
179 + if expect.Subnets != nil {
180 + require.Len(t, subnets, *expect.Subnets)
181 + }
182 +
183 + legalSubnets := service.FindAllLegalSubNetwork()
184 + if expect.LegalSubnets != nil {
185 + require.Len(t, legalSubnets, *expect.LegalSubnets)
186 + }
187 +
188 + ptpSubnets := service.FindAllPointToPointSubNetwork()
189 + if expect.PTPSubnets != nil {
190 + require.Len(t, ptpSubnets, *expect.PTPSubnets)
191 + }
192 +
193 + legalPTPSubnets := service.FindAllLegalPointToPointSubNetwork()
194 + if expect.LegalPTPSubnets != nil {
195 + require.Len(t, legalPTPSubnets, *expect.LegalPTPSubnets)
196 + }
197 +
198 + loopbacks := service.FindAllLoopbacks()
199 + if expect.Loopbacks != nil {
200 + require.Len(t, loopbacks, *expect.Loopbacks)
201 + }
202 +
203 + legalLoopbacks := service.FindAllLegalLoopbacks()
204 + if expect.LegalLoopbacks != nil {
205 + require.Len(t, legalLoopbacks, *expect.LegalLoopbacks)
206 + }
207 +
208 + multibet := service.FindSubNetworkByNetworkPrefixLessThen(30, 126)
209 + if expect.MultiBet != nil {
210 + require.Len(t, multibet, *expect.MultiBet)
211 + }
212 +
213 + priority := service.GetNodeIDPriorityMap()
214 + if expect.PrioritySize != nil {
215 + require.Len(t, priority, *expect.PrioritySize)
216 + }
217 + switch expect.PriorityRule.Kind {
218 + case "all_zero":
219 + for _, value := range priority {
220 + require.Equal(t, 0, value)
221 + }
222 + case "lt":
223 + require.NotNil(t, expect.PriorityRule.Value)
224 + for _, value := range priority {
225 + require.Less(t, value, *expect.PriorityRule.Value)
226 + }
227 + }
228 +
229 + if len(expect.CIDRNodeSizes) > 0 {
230 + actual := make(map[string]int)
231 + for _, subnet := range legalSubnets {
232 + actual[subnet.CIDR()] = len(subnet.NodeIDs())
233 + }
234 + for _, expectedCIDR := range expect.CIDRNodeSizes {
235 + require.Equalf(t, expectedCIDR.NodeIDs, actual[expectedCIDR.CIDR], "cidr %s", expectedCIDR.CIDR)
236 + }
237 + }
238 +
239 + topology := engine.BuildNetworkRouterTopology(service, 30, 126)
240 + if expect.TopologyVertices != nil {
241 + require.Len(t, topology.Vertices, *expect.TopologyVertices)
242 + } else if expect.MultiBet != nil {
243 + require.Len(t, topology.Vertices, expect.Nodes+*expect.MultiBet)
244 + }
245 +
246 + if expect.TopologyEdges != nil {
247 + require.Len(t, topology.Edges, *expect.TopologyEdges)
248 + } else if expect.NLinksFinal != nil {
249 + require.Len(t, topology.Edges, *expect.NLinksFinal)
250 + }
251 +}
252 +
253 +func TestNodeTopologyServiceIT_nms0001SubnetworksTest(t *testing.T) {
254 + runNodeTopologyScenarioParity(t, "nms0001SubnetworksTest")
255 +}
256 +func TestNodeTopologyServiceIT_nms0002SubnetworksTest(t *testing.T) {
257 + runNodeTopologyScenarioParity(t, "nms0002SubnetworksTest")
258 +}
259 +func TestNodeTopologyServiceIT_nms003SubnetworkTests(t *testing.T) {
260 + runNodeTopologyScenarioParity(t, "nms003SubnetworkTests")
261 +}
262 +func TestNodeTopologyServiceIT_nms007SubnetworkTest(t *testing.T) {
263 + runNodeTopologyScenarioParity(t, "nms007SubnetworkTest")
264 +}
265 +func TestNodeTopologyServiceIT_nms101SubnetworksTest(t *testing.T) {
266 + runNodeTopologyScenarioParity(t, "nms101SubnetworksTest")
267 +}
268 +func TestNodeTopologyServiceIT_nms102SubnetworksTest(t *testing.T) {
269 + runNodeTopologyScenarioParity(t, "nms102SubnetworksTest")
270 +}
271 +func TestNodeTopologyServiceIT_nms0123SubnetworksTest(t *testing.T) {
272 + runNodeTopologyScenarioParity(t, "nms0123SubnetworksTest")
273 +}
274 +func TestNodeTopologyServiceIT_nms1055SubnetworksTest(t *testing.T) {
275 + runNodeTopologyScenarioParity(t, "nms1055SubnetworksTest")
276 +}
277 +func TestNodeTopologyServiceIT_nms4005SubnetworksTest(t *testing.T) {
278 + runNodeTopologyScenarioParity(t, "nms4005SubnetworksTest")
279 +}
280 +func TestNodeTopologyServiceIT_nms4930SubnetworksTest(t *testing.T) {
281 + runNodeTopologyScenarioParity(t, "nms4930SubnetworksTest")
282 +}
283 +func TestNodeTopologyServiceIT_nms6802SubnetworksTest(t *testing.T) {
284 + runNodeTopologyScenarioParity(t, "nms6802SubnetworksTest")
285 +}
286 +func TestNodeTopologyServiceIT_nms7467SubnetworksTest(t *testing.T) {
287 + runNodeTopologyScenarioParity(t, "nms7467SubnetworksTest")
288 +}
289 +func TestNodeTopologyServiceIT_nms7563SubnetworksTest(t *testing.T) {
290 + runNodeTopologyScenarioParity(t, "nms7563SubnetworksTest")
291 +}
292 +func TestNodeTopologyServiceIT_nms7777DWSubnetworksTest(t *testing.T) {
293 + runNodeTopologyScenarioParity(t, "nms7777DWSubnetworksTest")
294 +}
295 +func TestNodeTopologyServiceIT_nms7918SubnetworksTest(t *testing.T) {
296 + runNodeTopologyScenarioParity(t, "nms7918SubnetworksTest")
297 +}
298 +func TestNodeTopologyServiceIT_nms8000SubnetworksTest(t *testing.T) {
299 + runNodeTopologyScenarioParity(t, "nms8000SubnetworksTest")
300 +}
301 +func TestNodeTopologyServiceIT_nms10205aSubnetworksTest(t *testing.T) {
302 + runNodeTopologyScenarioParity(t, "nms10205aSubnetworksTest")
303 +}
304 +func TestNodeTopologyServiceIT_nms10205bSubnetworksTest(t *testing.T) {
305 + runNodeTopologyScenarioParity(t, "nms10205bSubnetworksTest")
306 +}
307 +func TestNodeTopologyServiceIT_nms13593SubnetworksTest(t *testing.T) {
308 + runNodeTopologyScenarioParity(t, "nms13593SubnetworksTest")
309 +}
310 +func TestNodeTopologyServiceIT_nms13637SubnetworksTest(t *testing.T) {
311 + runNodeTopologyScenarioParity(t, "nms13637SubnetworksTest")
312 +}
313 +func TestNodeTopologyServiceIT_nms13923SubnetworksTest(t *testing.T) {
314 + runNodeTopologyScenarioParity(t, "nms13923SubnetworksTest")
315 +}
316 +func TestNodeTopologyServiceIT_nms17216SubnetworksTest(t *testing.T) {
317 + runNodeTopologyScenarioParity(t, "nms17216SubnetworksTest")
318 +}
319 +
320 +func TestNms0001EnIT_testLinkdNetworkTopologyUpdater(t *testing.T) {
321 + doc := loadNodeTopologyFixtureDocument(t)
322 + scenario, ok := doc.Scenarios["nms0001EnIT_testLinkdNetworkTopologyUpdater"]
323 + require.True(t, ok)
324 +
325 + service := scenarioToService(t, scenario)
326 + topology := engine.BuildNetworkRouterTopology(service, 30, 126)
327 + require.NotEmpty(t, topology.Vertices)
328 + require.Len(t, topology.Vertices, *scenario.Expect.TopologyVertices)
329 + require.Len(t, topology.Edges, *scenario.Expect.TopologyEdges)
330 +}
331 +
332 +func TestNodeTopologyFixtureCoverage(t *testing.T) {
333 + doc := loadNodeTopologyFixtureDocument(t)
334 + scenarios := make([]string, 0, len(doc.Scenarios))
335 + for name := range doc.Scenarios {
336 + scenarios = append(scenarios, name)
337 + }
338 + sort.Strings(scenarios)
339 +
340 + require.Contains(t, scenarios, "nms0001SubnetworksTest")
341 + require.Contains(t, scenarios, "nms17216SubnetworksTest")
342 + require.Contains(t, scenarios, "nms0001EnIT_testLinkdNetworkTopologyUpdater")
343 +}
344 +
345 +func stringsTrimSpace(value string) string { return strings.TrimSpace(value) }
src/go/pkg/topology/engine/parity/walk.go new
+260
@@ -0,0 +1,260 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package parity
4 +
5 +import (
6 + "bufio"
7 + "fmt"
8 + "io"
9 + "os"
10 + "regexp"
11 + "sort"
12 + "strconv"
13 + "strings"
14 +)
15 +
16 +var snmpWalkLineRE = regexp.MustCompile(`^\s*\.?([0-9][0-9.]*)\s*=\s*([^:]+):\s*(.*)$`)
17 +
18 +// WalkRecord is one normalized SNMP walk row.
19 +type WalkRecord struct {
20 + OID string `json:"oid" yaml:"oid"`
21 + Type string `json:"type" yaml:"type"`
22 + Value string `json:"value" yaml:"value"`
23 +}
24 +
25 +// WalkDataset is a parsed SNMP walk fixture with deterministic ordering.
26 +type WalkDataset struct {
27 + Path string `json:"path" yaml:"path"`
28 + Records []WalkRecord `json:"records" yaml:"records"`
29 + byOID map[string]WalkRecord
30 +}
31 +
32 +// LoadWalkFile parses a walk file in OpenNMS snmpwalk format.
33 +func LoadWalkFile(path string) (WalkDataset, error) {
34 + f, err := os.Open(path)
35 + if err != nil {
36 + return WalkDataset{}, fmt.Errorf("open walk file %q: %w", path, err)
37 + }
38 +
39 + records, err := ParseWalk(f)
40 + if closeErr := f.Close(); err == nil && closeErr != nil {
41 + return WalkDataset{}, fmt.Errorf("close walk file %q: %w", path, closeErr)
42 + }
43 + if err != nil {
44 + return WalkDataset{}, fmt.Errorf("parse walk file %q: %w", path, err)
45 + }
46 +
47 + ds := WalkDataset{Path: path, Records: records, byOID: make(map[string]WalkRecord, len(records))}
48 + for _, rec := range records {
49 + ds.byOID[rec.OID] = rec
50 + }
51 + return ds, nil
52 +}
53 +
54 +// ParseWalk parses OpenNMS snmpwalk text records.
55 +func ParseWalk(r io.Reader) ([]WalkRecord, error) {
56 + scanner := bufio.NewScanner(r)
57 + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
58 +
59 + type partial struct {
60 + oid string
61 + typ string
62 + value strings.Builder
63 + quoteOpen bool
64 + escaped bool
65 + }
66 +
67 + var (
68 + lineNo int
69 + records []WalkRecord
70 + cur *partial
71 + )
72 +
73 + flush := func() {
74 + if cur == nil {
75 + return
76 + }
77 + records = append(records, WalkRecord{
78 + OID: cur.oid,
79 + Type: cur.typ,
80 + Value: normalizeWalkValue(cur.value.String()),
81 + })
82 + cur = nil
83 + }
84 +
85 + for scanner.Scan() {
86 + lineNo++
87 + line := strings.TrimRight(scanner.Text(), "\r")
88 + if strings.TrimSpace(line) == "" {
89 + if cur != nil {
90 + cur.value.WriteByte('\n')
91 + cur.quoteOpen, cur.escaped = updateQuoteState("\n", cur.quoteOpen, cur.escaped)
92 + }
93 + continue
94 + }
95 +
96 + if cur != nil {
97 + if cur.value.Len() > 0 {
98 + cur.value.WriteByte('\n')
99 + cur.quoteOpen, cur.escaped = updateQuoteState("\n", cur.quoteOpen, cur.escaped)
100 + }
101 + cur.value.WriteString(line)
102 + cur.quoteOpen, cur.escaped = updateQuoteState(line, cur.quoteOpen, cur.escaped)
103 + if !cur.quoteOpen {
104 + cur.quoteOpen = false
105 + flush()
106 + }
107 + continue
108 + }
109 +
110 + match := snmpWalkLineRE.FindStringSubmatch(line)
111 + if len(match) != 4 {
112 + // Ignore non-record lines; these appear in a few vendor dumps.
113 + continue
114 + }
115 +
116 + oid := normalizeOID(match[1])
117 + typ := strings.TrimSpace(match[2])
118 + value := match[3]
119 + if oid == "" || typ == "" {
120 + continue
121 + }
122 +
123 + quoteOpen, escaped := updateQuoteState(value, false, false)
124 + if quoteOpen {
125 + cur = &partial{oid: oid, typ: typ, quoteOpen: quoteOpen, escaped: escaped}
126 + cur.value.WriteString(value)
127 + continue
128 + }
129 +
130 + records = append(records, WalkRecord{
131 + OID: oid,
132 + Type: typ,
133 + Value: normalizeWalkValue(value),
134 + })
135 + }
136 +
137 + if err := scanner.Err(); err != nil {
138 + return nil, fmt.Errorf("scan walk line %d: %w", lineNo, err)
139 + }
140 + if cur != nil && cur.quoteOpen {
141 + return nil, fmt.Errorf("unterminated quoted value for OID %s", cur.oid)
142 + }
143 + return records, nil
144 +}
145 +
146 +// Lookup returns one record by OID. OID matching ignores leading dots.
147 +func (d *WalkDataset) Lookup(oid string) (WalkRecord, bool) {
148 + if d == nil {
149 + return WalkRecord{}, false
150 + }
151 + if len(d.byOID) == 0 {
152 + index := make(map[string]WalkRecord, len(d.Records))
153 + for _, rec := range d.Records {
154 + index[rec.OID] = rec
155 + }
156 + d.byOID = index
157 + }
158 + rec, ok := d.byOID[normalizeOID(oid)]
159 + return rec, ok
160 +}
161 +
162 +// Prefix returns all records with the given OID prefix, in file order.
163 +func (d WalkDataset) Prefix(prefix string) []WalkRecord {
164 + norm := normalizeOID(prefix)
165 + if norm == "" {
166 + out := make([]WalkRecord, len(d.Records))
167 + copy(out, d.Records)
168 + return out
169 + }
170 + out := make([]WalkRecord, 0)
171 + for _, rec := range d.Records {
172 + if strings.HasPrefix(rec.OID, norm) {
173 + out = append(out, rec)
174 + }
175 + }
176 + return out
177 +}
178 +
179 +// SortedOIDs returns deterministic OID ordering from the dataset.
180 +func (d WalkDataset) SortedOIDs() []string {
181 + oids := make([]string, 0, len(d.Records))
182 + for _, rec := range d.Records {
183 + oids = append(oids, rec.OID)
184 + }
185 + sort.Slice(oids, func(i, j int) bool {
186 + return compareOID(oids[i], oids[j]) < 0
187 + })
188 + return oids
189 +}
190 +
191 +func compareOID(a, b string) int {
192 + partsA := strings.Split(a, ".")
193 + partsB := strings.Split(b, ".")
194 + limit := min(len(partsB), len(partsA))
195 +
196 + for i := range limit {
197 + if partsA[i] == partsB[i] {
198 + continue
199 + }
200 +
201 + numA, errA := strconv.Atoi(partsA[i])
202 + numB, errB := strconv.Atoi(partsB[i])
203 + if errA == nil && errB == nil {
204 + switch {
205 + case numA < numB:
206 + return -1
207 + case numA > numB:
208 + return 1
209 + default:
210 + continue
211 + }
212 + }
213 +
214 + if partsA[i] < partsB[i] {
215 + return -1
216 + }
217 + return 1
218 + }
219 +
220 + switch {
221 + case len(partsA) < len(partsB):
222 + return -1
223 + case len(partsA) > len(partsB):
224 + return 1
225 + default:
226 + return 0
227 + }
228 +}
229 +
230 +func normalizeOID(v string) string {
231 + return strings.TrimLeft(strings.TrimSpace(v), ".")
232 +}
233 +
234 +func normalizeWalkValue(v string) string {
235 + s := strings.TrimSpace(v)
236 + if len(s) >= 2 && strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
237 + s = strings.TrimPrefix(s, `"`)
238 + s = strings.TrimSuffix(s, `"`)
239 + }
240 + s = strings.ReplaceAll(s, `\"`, `"`)
241 + return s
242 +}
243 +
244 +func updateQuoteState(v string, quoteOpen, escaped bool) (bool, bool) {
245 + for i := 0; i < len(v); i++ {
246 + ch := v[i]
247 + if escaped {
248 + escaped = false
249 + continue
250 + }
251 + if ch == '\\' {
252 + escaped = true
253 + continue
254 + }
255 + if ch == '"' {
256 + quoteOpen = !quoteOpen
257 + }
258 + }
259 + return quoteOpen, escaped
260 +}
src/go/pkg/topology/engine/parity/walk_fixture_test.go new
+28
@@ -0,0 +1,28 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +//go:build topology_fixtures
4 +
5 +package parity
6 +
7 +import (
8 + "testing"
9 +
10 + "github.com/stretchr/testify/require"
11 +)
12 +
13 +func TestLoadWalkFile_NMS8003Fixture(t *testing.T) {
14 + ds, err := LoadWalkFile("../../../../testdata/snmp/enlinkd/nms8003/fixtures/NMM-R1.snmpwalk.txt")
15 + require.NoError(t, err)
16 + require.NotEmpty(t, ds.Records)
17 +
18 + sysName, ok := ds.Lookup(".1.0.8802.1.1.2.1.3.3.0")
19 + require.True(t, ok)
20 + require.Equal(t, "NMM-R1.informatik.hs-fulda.de", sysName.Value)
21 +
22 + lldpRemotes := ds.Prefix("1.0.8802.1.1.2.1.4.1.1")
23 + require.NotEmpty(t, lldpRemotes)
24 +
25 + oids := ds.SortedOIDs()
26 + require.NotEmpty(t, oids)
27 + require.Equal(t, "1.0.8802.1.1.2.1.3.1.0", oids[0])
28 +}
src/go/pkg/topology/engine/parity/walk_test.go new
+76
@@ -0,0 +1,76 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package parity
4 +
5 +import (
6 + "strings"
7 + "testing"
8 +
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestParseWalk(t *testing.T) {
13 + input := strings.Join([]string{
14 + `.1.3.6.1.2.1.1.5.0 = STRING: "test-host"`,
15 + `.1.3.6.1.2.1.1.1.0 = STRING: "line1`,
16 + `line2"`,
17 + `invalid line should be ignored`,
18 + `.1.3.6.1.2.1.1.2.0 = OID: .1.3.6.1.4.1.9.1.1045`,
19 + }, "\n")
20 +
21 + records, err := ParseWalk(strings.NewReader(input))
22 + require.NoError(t, err)
23 + require.Len(t, records, 3)
24 +
25 + require.Equal(t, "1.3.6.1.2.1.1.5.0", records[0].OID)
26 + require.Equal(t, "STRING", records[0].Type)
27 + require.Equal(t, "test-host", records[0].Value)
28 +
29 + require.Equal(t, "1.3.6.1.2.1.1.1.0", records[1].OID)
30 + require.Equal(t, "line1\nline2", records[1].Value)
31 +
32 + require.Equal(t, "1.3.6.1.2.1.1.2.0", records[2].OID)
33 + require.Equal(t, ".1.3.6.1.4.1.9.1.1045", records[2].Value)
34 +}
35 +
36 +func TestParseWalk_MultilineQuotedValueWithEscapes(t *testing.T) {
37 + input := strings.Join([]string{
38 + `.1.3.6.1.2.1.1.6.0 = STRING: "line1\\`,
39 + `line2\"`,
40 + `line3"`,
41 + }, "\n")
42 +
43 + records, err := ParseWalk(strings.NewReader(input))
44 + require.NoError(t, err)
45 + require.Len(t, records, 1)
46 + require.Equal(t, "line1\\\\\nline2\"\nline3", records[0].Value)
47 +}
48 +
49 +func TestWalkDatasetLookupCachesByOID(t *testing.T) {
50 + ds := WalkDataset{
51 + Records: []WalkRecord{
52 + {OID: "1.3.6.1.2.1.1.5.0", Type: "STRING", Value: "test-host"},
53 + },
54 + }
55 +
56 + record, ok := ds.Lookup(".1.3.6.1.2.1.1.5.0")
57 + require.True(t, ok)
58 + require.Equal(t, "test-host", record.Value)
59 + require.Contains(t, ds.byOID, "1.3.6.1.2.1.1.5.0")
60 +}
61 +
62 +func TestWalkDatasetSortedOIDs_UsesNumericOIDOrdering(t *testing.T) {
63 + ds := WalkDataset{
64 + Records: []WalkRecord{
65 + {OID: "1.3.6.1.2.1.1.10.0"},
66 + {OID: "1.3.6.1.2.1.1.2.0"},
67 + {OID: "1.3.6.1.2.1.1.2.1"},
68 + },
69 + }
70 +
71 + require.Equal(t, []string{
72 + "1.3.6.1.2.1.1.2.0",
73 + "1.3.6.1.2.1.1.2.1",
74 + "1.3.6.1.2.1.1.10.0",
75 + }, ds.SortedOIDs())
76 +}
src/go/pkg/topology/engine/runtime.go new
+122
@@ -0,0 +1,122 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "context"
7 + "fmt"
8 + "time"
9 +)
10 +
11 +// ObservationProvider gathers normalized L2 observations for discovery requests.
12 +type ObservationProvider interface {
13 + ObserveByCIDRs(ctx context.Context, req CIDRRequest) ([]L2Observation, error)
14 + ObserveByDevices(ctx context.Context, req DeviceRequest) ([]L2Observation, error)
15 +}
16 +
17 +// RuntimeEngine executes discovery using a concrete observation provider.
18 +type RuntimeEngine struct {
19 + provider ObservationProvider
20 +}
21 +
22 +// NewRuntimeEngine constructs a concrete engine backed by the given provider.
23 +func NewRuntimeEngine(provider ObservationProvider) (*RuntimeEngine, error) {
24 + if provider == nil {
25 + return nil, fmt.Errorf("%w: observation provider is required", ErrInvalidRequest)
26 + }
27 + return &RuntimeEngine{provider: provider}, nil
28 +}
29 +
30 +func (e *RuntimeEngine) DiscoverByCIDRs(ctx context.Context, req CIDRRequest) (Result, error) {
31 + if e == nil || e.provider == nil {
32 + return Result{}, fmt.Errorf("%w: observation provider is not configured", ErrInvalidRequest)
33 + }
34 + if err := validateCIDRRequest(req); err != nil {
35 + return Result{}, err
36 + }
37 +
38 + req.Options = ensureCollectedAt(req.Options)
39 +
40 + observations, err := e.provider.ObserveByCIDRs(ctx, req)
41 + if err != nil {
42 + return Result{}, fmt.Errorf("observe cidr discovery request: %w", err)
43 + }
44 + if len(observations) == 0 {
45 + return emptyResult(req.Options.CollectedAt), nil
46 + }
47 +
48 + result, err := BuildL2ResultFromObservations(observations, req.Options)
49 + if err != nil {
50 + return Result{}, fmt.Errorf("build l2 result from cidr discovery request: %w", err)
51 + }
52 + return result, nil
53 +}
54 +
55 +func (e *RuntimeEngine) DiscoverByDevices(ctx context.Context, req DeviceRequest) (Result, error) {
56 + if e == nil || e.provider == nil {
57 + return Result{}, fmt.Errorf("%w: observation provider is not configured", ErrInvalidRequest)
58 + }
59 + if err := validateDeviceRequest(req); err != nil {
60 + return Result{}, err
61 + }
62 +
63 + req.Options = ensureCollectedAt(req.Options)
64 +
65 + observations, err := e.provider.ObserveByDevices(ctx, req)
66 + if err != nil {
67 + return Result{}, fmt.Errorf("observe device discovery request: %w", err)
68 + }
69 + if len(observations) == 0 {
70 + return emptyResult(req.Options.CollectedAt), nil
71 + }
72 +
73 + result, err := BuildL2ResultFromObservations(observations, req.Options)
74 + if err != nil {
75 + return Result{}, fmt.Errorf("build l2 result from device discovery request: %w", err)
76 + }
77 + return result, nil
78 +}
79 +
80 +func validateCIDRRequest(req CIDRRequest) error {
81 + if len(req.CIDRs) == 0 {
82 + return fmt.Errorf("%w: cidrs are required", ErrInvalidRequest)
83 + }
84 + for i := range req.CIDRs {
85 + if !req.CIDRs[i].IsValid() {
86 + return fmt.Errorf("%w: cidrs[%d] has invalid prefix", ErrInvalidRequest, i)
87 + }
88 + }
89 + return nil
90 +}
91 +
92 +func validateDeviceRequest(req DeviceRequest) error {
93 + if len(req.Devices) == 0 {
94 + return fmt.Errorf("%w: devices are required", ErrInvalidRequest)
95 + }
96 + for i := range req.Devices {
97 + if !req.Devices[i].Address.IsValid() {
98 + return fmt.Errorf("%w: devices[%d] has invalid address", ErrInvalidRequest, i)
99 + }
100 + }
101 + return nil
102 +}
103 +
104 +func ensureCollectedAt(opts DiscoverOptions) DiscoverOptions {
105 + if opts.CollectedAt.IsZero() {
106 + opts.CollectedAt = time.Now().UTC()
107 + } else {
108 + opts.CollectedAt = opts.CollectedAt.UTC()
109 + }
110 + return opts
111 +}
112 +
113 +func emptyResult(collectedAt time.Time) Result {
114 + if !collectedAt.IsZero() {
115 + collectedAt = collectedAt.UTC()
116 + }
117 +
118 + return Result{
119 + CollectedAt: collectedAt,
120 + Stats: newL2ResultStats(),
121 + }
122 +}
src/go/pkg/topology/engine/runtime_test.go new
+256
@@ -0,0 +1,256 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "context"
7 + "errors"
8 + "net/netip"
9 + "testing"
10 + "time"
11 +
12 + "github.com/stretchr/testify/require"
13 +)
14 +
15 +type fakeObservationProvider struct {
16 + cidrObs []L2Observation
17 + deviceObs []L2Observation
18 + cidrErr error
19 + deviceErr error
20 + cidrReqs []CIDRRequest
21 + deviceReqs []DeviceRequest
22 +}
23 +
24 +func (p *fakeObservationProvider) ObserveByCIDRs(_ context.Context, req CIDRRequest) ([]L2Observation, error) {
25 + p.cidrReqs = append(p.cidrReqs, req)
26 + if p.cidrErr != nil {
27 + return nil, p.cidrErr
28 + }
29 + return p.cidrObs, nil
30 +}
31 +
32 +func (p *fakeObservationProvider) ObserveByDevices(_ context.Context, req DeviceRequest) ([]L2Observation, error) {
33 + p.deviceReqs = append(p.deviceReqs, req)
34 + if p.deviceErr != nil {
35 + return nil, p.deviceErr
36 + }
37 + return p.deviceObs, nil
38 +}
39 +
40 +func TestNewRuntimeEngine_RequiresProvider(t *testing.T) {
41 + _, err := NewRuntimeEngine(nil)
42 + require.Error(t, err)
43 + require.ErrorIs(t, err, ErrInvalidRequest)
44 +}
45 +
46 +func TestRuntimeEngine_DiscoverByDevices_BuildsResult(t *testing.T) {
47 + provider := &fakeObservationProvider{
48 + deviceObs: []L2Observation{
49 + {
50 + DeviceID: "switch-a",
51 + Hostname: "switch-a.example.net",
52 + ManagementIP: "10.0.0.1",
53 + LLDPRemotes: []LLDPRemoteObservation{
54 + {
55 + LocalPortNum: "8",
56 + LocalPortID: "Gi0/0",
57 + SysName: "switch-b.example.net",
58 + PortID: "Gi0/1",
59 + },
60 + },
61 + },
62 + {
63 + DeviceID: "switch-b",
64 + Hostname: "switch-b.example.net",
65 + },
66 + },
67 + }
68 + eng, err := NewRuntimeEngine(provider)
69 + require.NoError(t, err)
70 +
71 + req := DeviceRequest{
72 + Devices: []DeviceTarget{{Address: netip.MustParseAddr("10.0.0.1")}},
73 + Options: DiscoverOptions{EnableLLDP: true},
74 + }
75 +
76 + result, err := eng.DiscoverByDevices(context.Background(), req)
77 + require.NoError(t, err)
78 + require.Len(t, provider.deviceReqs, 1)
79 + require.Len(t, result.Adjacencies, 1)
80 + require.Equal(t, "lldp", result.Adjacencies[0].Protocol)
81 + require.Equal(t, "switch-a", result.Adjacencies[0].SourceID)
82 + require.Equal(t, "switch-b", result.Adjacencies[0].TargetID)
83 +}
84 +
85 +func TestRuntimeEngine_DiscoverByDevices_PropagatesCollectedAt(t *testing.T) {
86 + sourceZone := time.FixedZone("UTC+02", 2*60*60)
87 + collectedAt := time.Date(2026, time.April, 2, 3, 4, 5, 0, sourceZone)
88 + expectedCollectedAt := collectedAt.UTC()
89 + provider := &fakeObservationProvider{
90 + deviceObs: []L2Observation{
91 + {
92 + DeviceID: "switch-a",
93 + Hostname: "switch-a.example.net",
94 + },
95 + },
96 + }
97 + eng, err := NewRuntimeEngine(provider)
98 + require.NoError(t, err)
99 +
100 + req := DeviceRequest{
101 + Devices: []DeviceTarget{{Address: netip.MustParseAddr("10.0.0.1")}},
102 + Options: DiscoverOptions{CollectedAt: collectedAt},
103 + }
104 +
105 + result, err := eng.DiscoverByDevices(context.Background(), req)
106 + require.NoError(t, err)
107 + require.Len(t, provider.deviceReqs, 1)
108 + require.Equal(t, expectedCollectedAt, provider.deviceReqs[0].Options.CollectedAt)
109 + require.Equal(t, expectedCollectedAt, result.CollectedAt)
110 +}
111 +
112 +func TestRuntimeEngine_DiscoverByCIDRs_InvalidRequest(t *testing.T) {
113 + eng, err := NewRuntimeEngine(&fakeObservationProvider{})
114 + require.NoError(t, err)
115 +
116 + _, err = eng.DiscoverByCIDRs(context.Background(), CIDRRequest{})
117 + require.Error(t, err)
118 + require.ErrorIs(t, err, ErrInvalidRequest)
119 +}
120 +
121 +func TestRuntimeEngine_DiscoverByCIDRs_InvalidPrefix(t *testing.T) {
122 + provider := &fakeObservationProvider{}
123 + eng, err := NewRuntimeEngine(provider)
124 + require.NoError(t, err)
125 +
126 + _, err = eng.DiscoverByCIDRs(context.Background(), CIDRRequest{
127 + CIDRs: []netip.Prefix{{}},
128 + })
129 + require.Error(t, err)
130 + require.ErrorIs(t, err, ErrInvalidRequest)
131 + require.ErrorContains(t, err, "cidrs[0] has invalid prefix")
132 + require.Empty(t, provider.cidrReqs)
133 +}
134 +
135 +func TestRuntimeEngine_DiscoverByDevices_InvalidRequest(t *testing.T) {
136 + eng, err := NewRuntimeEngine(&fakeObservationProvider{})
137 + require.NoError(t, err)
138 +
139 + _, err = eng.DiscoverByDevices(context.Background(), DeviceRequest{})
140 + require.Error(t, err)
141 + require.ErrorIs(t, err, ErrInvalidRequest)
142 + require.ErrorContains(t, err, "devices are required")
143 +}
144 +
145 +func TestRuntimeEngine_DiscoverByDevices_InvalidAddress(t *testing.T) {
146 + eng, err := NewRuntimeEngine(&fakeObservationProvider{})
147 + require.NoError(t, err)
148 +
149 + _, err = eng.DiscoverByDevices(context.Background(), DeviceRequest{
150 + Devices: []DeviceTarget{{Address: netip.Addr{}}},
151 + })
152 + require.Error(t, err)
153 + require.ErrorIs(t, err, ErrInvalidRequest)
154 +}
155 +
156 +func TestRuntimeEngine_DiscoverByCIDRs_ProviderError(t *testing.T) {
157 + providerErr := errors.New("provider failed")
158 + provider := &fakeObservationProvider{cidrErr: providerErr}
159 + eng, err := NewRuntimeEngine(provider)
160 + require.NoError(t, err)
161 +
162 + _, err = eng.DiscoverByCIDRs(context.Background(), CIDRRequest{
163 + CIDRs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
164 + })
165 + require.Error(t, err)
166 + require.ErrorIs(t, err, providerErr)
167 +}
168 +
169 +func TestRuntimeEngine_EmptyObservationsReturnEmptyResult(t *testing.T) {
170 + provider := &fakeObservationProvider{}
171 + eng, err := NewRuntimeEngine(provider)
172 + require.NoError(t, err)
173 +
174 + sourceZone := time.FixedZone("UTC-03", -3*60*60)
175 + collectedAt := time.Date(2026, time.April, 2, 3, 4, 5, 0, sourceZone)
176 + expectedCollectedAt := collectedAt.UTC()
177 +
178 + result, err := eng.DiscoverByCIDRs(context.Background(), CIDRRequest{
179 + CIDRs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
180 + Options: DiscoverOptions{CollectedAt: collectedAt},
181 + })
182 + require.NoError(t, err)
183 + require.Len(t, provider.cidrReqs, 1)
184 + require.Equal(t, expectedCollectedAt, provider.cidrReqs[0].Options.CollectedAt)
185 + require.Equal(t, expectedCollectedAt, result.CollectedAt)
186 + require.Empty(t, result.Devices)
187 + require.Empty(t, result.Adjacencies)
188 + require.Equal(t, 0, result.Stats["links_total"])
189 + require.Equal(t, 0, result.Stats["identity_alias_endpoints_mapped"])
190 + require.Equal(t, 0, result.Stats["identity_alias_endpoints_ambiguous_mac"])
191 + require.Equal(t, 0, result.Stats["identity_alias_ips_merged"])
192 + require.Equal(t, 0, result.Stats["identity_alias_ips_conflict_skipped"])
193 +}
194 +
195 +func TestRuntimeEngine_EmptyResultStatsSchemaMatchesPipelineResult(t *testing.T) {
196 + emptyStats := emptyResult(time.Time{}).Stats
197 + pipelineResult, err := BuildL2ResultFromObservations([]L2Observation{{DeviceID: "switch-a"}}, DiscoverOptions{})
198 + require.NoError(t, err)
199 +
200 + require.ElementsMatch(t, statsKeys(pipelineResult.Stats), statsKeys(emptyStats))
201 +}
202 +
203 +func TestRuntimeEngine_DiscoverByDevices_NilReceiver(t *testing.T) {
204 + var eng *RuntimeEngine
205 + _, err := eng.DiscoverByDevices(context.Background(), DeviceRequest{
206 + Devices: []DeviceTarget{{Address: netip.MustParseAddr("10.0.0.1")}},
207 + })
208 + require.Error(t, err)
209 + require.ErrorIs(t, err, ErrInvalidRequest)
210 +}
211 +
212 +func TestRuntimeEngine_DiscoverByDevices_NilProvider(t *testing.T) {
213 + eng := &RuntimeEngine{}
214 + _, err := eng.DiscoverByDevices(context.Background(), DeviceRequest{
215 + Devices: []DeviceTarget{{Address: netip.MustParseAddr("10.0.0.1")}},
216 + })
217 + require.Error(t, err)
218 + require.ErrorIs(t, err, ErrInvalidRequest)
219 +}
220 +
221 +func statsKeys(stats map[string]any) []string {
222 + keys := make([]string, 0, len(stats))
223 + for key := range stats {
224 + keys = append(keys, key)
225 + }
226 + return keys
227 +}
228 +
229 +func TestRuntimeEngine_DiscoverByCIDRs_NilReceiver(t *testing.T) {
230 + var eng *RuntimeEngine
231 + _, err := eng.DiscoverByCIDRs(context.Background(), CIDRRequest{
232 + CIDRs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
233 + })
234 + require.Error(t, err)
235 + require.ErrorIs(t, err, ErrInvalidRequest)
236 +}
237 +
238 +func TestRuntimeEngine_DiscoverByCIDRs_NilProvider(t *testing.T) {
239 + eng := &RuntimeEngine{}
240 + _, err := eng.DiscoverByCIDRs(context.Background(), CIDRRequest{
241 + CIDRs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")},
242 + })
243 + require.Error(t, err)
244 + require.ErrorIs(t, err, ErrInvalidRequest)
245 +}
246 +
247 +func TestEnsureCollectedAt_DefaultsZeroToUTCNow(t *testing.T) {
248 + before := time.Now().UTC()
249 + opts := ensureCollectedAt(DiscoverOptions{})
250 + after := time.Now().UTC()
251 +
252 + require.False(t, opts.CollectedAt.IsZero())
253 + require.Equal(t, time.UTC, opts.CollectedAt.Location())
254 + require.False(t, opts.CollectedAt.Before(before))
255 + require.False(t, opts.CollectedAt.After(after))
256 +}
src/go/pkg/topology/engine/topology_adapter.go new
+236
@@ -0,0 +1,236 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "net/netip"
7 + "strings"
8 + "time"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/topology"
11 +)
12 +
13 +// TopologyDataOptions controls conversion from Result to topology.Data.
14 +type TopologyDataOptions struct {
15 + SchemaVersion string
16 + Source string
17 + Layer string
18 + View string
19 + AgentID string
20 + LocalDeviceID string
21 + CollectedAt time.Time
22 + ResolveDNSName func(ip string) string
23 + CollapseActorsByIP bool
24 + EliminateNonIPInferred bool
25 + ProbabilisticConnectivity bool
26 + InferenceStrategy string
27 +}
28 +
29 +const (
30 + topologyInferenceStrategyFDBMinimumKnowledge = "fdb_minimum_knowledge"
31 + topologyInferenceStrategySTPParentTree = "stp_parent_tree"
32 + topologyInferenceStrategyFDBPairwise = "fdb_pairwise_minimum_knowledge"
33 + topologyInferenceStrategySTPFDBCorrelated = "stp_fdb_correlated"
34 + topologyInferenceStrategyCDPFDBHybrid = "cdp_fdb_hybrid"
35 +)
36 +
37 +type topologyInferenceStrategyConfig struct {
38 + id string
39 + includeLLDPBridgeLinks bool
40 + includeCDPBridgeLinks bool
41 + includeSTPBridgeLinks bool
42 + useSTPDesignatedParent bool
43 + enableFDBPairwiseLinks bool
44 + enableSTPManagedAliasCorrelation bool
45 + filterSwitchFacingAttachments bool
46 +}
47 +
48 +type endpointActorAccumulator struct {
49 + endpointID string
50 + mac string
51 + ips map[string]netip.Addr
52 + sources map[string]struct{}
53 + deviceIDs map[string]struct{}
54 + ifIndexes map[string]struct{}
55 + ifNames map[string]struct{}
56 +}
57 +
58 +type projectedSegments struct {
59 + actors []topology.Actor
60 + links []topology.Link
61 + linksFdb int
62 + bidirectionalCount int
63 + endpointLinksCandidates int
64 + endpointLinksEmitted int
65 + endpointLinksSuppressed int
66 + endpointsWithAmbiguousSegment int
67 + endpointDirectOwners map[string]fdbEndpointOwner
68 + suppressedManagedOverlapIDs map[string]struct{}
69 +}
70 +
71 +type fdbReporterObservation struct {
72 + byEndpoint map[string]map[string]map[string]struct{}
73 + byReporter map[string]map[string]map[string]struct{}
74 +}
75 +
76 +type fdbEndpointOwner struct {
77 + portKey string
78 + portVLANKey string
79 + port bridgePortRef
80 + source string
81 +}
82 +
83 +type probableEndpointReporterHint struct {
84 + deviceID string
85 + ifIndex int
86 + ifName string
87 +}
88 +
89 +type segmentReporterIndex struct {
90 + byDevice map[string]map[string]struct{}
91 + byDeviceIfIndex map[string]map[string]struct{}
92 + byDeviceIfName map[string]map[string]struct{}
93 +}
94 +
95 +type topologyIdentityKeySet map[string]struct{}
96 +
97 +type topologyDevicePortStatus struct {
98 + IfIndex int
99 + IfName string
100 + IfDescr string
101 + IfAlias string
102 + MAC string
103 + SpeedBps int64
104 + LastChange int64
105 + Duplex string
106 + InterfaceType string
107 + AdminStatus string
108 + OperStatus string
109 + LinkMode string
110 + ModeConfidence string
111 + ModeSources []string
112 + VLANIDs []string
113 + TopologyRole string
114 + RoleConfidence string
115 + RoleSources []string
116 + FDBMACCount int
117 + STPState string
118 + VLANs []map[string]any
119 + Neighbors []topologyPortNeighborStatus
120 +}
121 +
122 +type topologyPortNeighborStatus struct {
123 + Protocol string
124 + RemoteDevice string
125 + RemotePort string
126 + RemoteIP string
127 + RemoteChassisID string
128 + RemoteCapabilities []string
129 +}
130 +
131 +type topologyDeviceInterfaceSummary struct {
132 + portsTotal int
133 + ifIndexes []string
134 + ifNames []string
135 + adminStatusCount map[string]any
136 + operStatusCount map[string]any
137 + linkModeCount map[string]any
138 + roleCount map[string]any
139 + portsUp int
140 + portsDown int
141 + portsAdminDown int
142 + totalBandwidthBps int64
143 + fdbTotalMACs int
144 + vlanCount int
145 + lldpNeighborCount int
146 + cdpNeighborCount int
147 + portStatuses []map[string]any
148 +}
149 +
150 +type topologyDevicePortEvidence struct {
151 + vlanIDs map[string]struct{}
152 + vlanNames map[string]string
153 + fdbEndpointIDs map[string]struct{}
154 + hasFDB bool
155 + hasFDBManagedAlias bool
156 + hasSTP bool
157 + hasPeer bool
158 + hasBridgeLink bool
159 + isLAG bool
160 + stpStates map[string]struct{}
161 + neighbors map[string]topologyPortNeighborStatus
162 +}
163 +
164 +func normalizeTopologyInferenceStrategy(value string) string {
165 + switch strings.ToLower(strings.TrimSpace(value)) {
166 + case "", topologyInferenceStrategyFDBMinimumKnowledge:
167 + return topologyInferenceStrategyFDBMinimumKnowledge
168 + case topologyInferenceStrategySTPParentTree:
169 + return topologyInferenceStrategySTPParentTree
170 + case topologyInferenceStrategyFDBPairwise:
171 + return topologyInferenceStrategyFDBPairwise
172 + case topologyInferenceStrategySTPFDBCorrelated:
173 + return topologyInferenceStrategySTPFDBCorrelated
174 + case topologyInferenceStrategyCDPFDBHybrid:
175 + return topologyInferenceStrategyCDPFDBHybrid
176 + default:
177 + return topologyInferenceStrategyFDBMinimumKnowledge
178 + }
179 +}
180 +
181 +func topologyInferenceStrategyConfigFor(strategy string) topologyInferenceStrategyConfig {
182 + switch normalizeTopologyInferenceStrategy(strategy) {
183 + case topologyInferenceStrategySTPParentTree:
184 + return topologyInferenceStrategyConfig{
185 + id: topologyInferenceStrategySTPParentTree,
186 + includeSTPBridgeLinks: true,
187 + useSTPDesignatedParent: true,
188 + filterSwitchFacingAttachments: true,
189 + }
190 + case topologyInferenceStrategyFDBPairwise:
191 + return topologyInferenceStrategyConfig{
192 + id: topologyInferenceStrategyFDBPairwise,
193 + enableFDBPairwiseLinks: true,
194 + filterSwitchFacingAttachments: true,
195 + }
196 + case topologyInferenceStrategySTPFDBCorrelated:
197 + return topologyInferenceStrategyConfig{
198 + id: topologyInferenceStrategySTPFDBCorrelated,
199 + includeLLDPBridgeLinks: true,
200 + includeCDPBridgeLinks: true,
201 + includeSTPBridgeLinks: true,
202 + useSTPDesignatedParent: true,
203 + enableFDBPairwiseLinks: true,
204 + enableSTPManagedAliasCorrelation: true,
205 + filterSwitchFacingAttachments: true,
206 + }
207 + case topologyInferenceStrategyCDPFDBHybrid:
208 + return topologyInferenceStrategyConfig{
209 + id: topologyInferenceStrategyCDPFDBHybrid,
210 + includeCDPBridgeLinks: true,
211 + enableFDBPairwiseLinks: true,
212 + filterSwitchFacingAttachments: true,
213 + }
214 + default:
215 + return topologyInferenceStrategyConfig{
216 + id: topologyInferenceStrategyFDBMinimumKnowledge,
217 + includeLLDPBridgeLinks: true,
218 + includeCDPBridgeLinks: true,
219 + filterSwitchFacingAttachments: true,
220 + }
221 + }
222 +}
223 +
224 +// ToTopologyData converts an engine result to the shared topology schema.
225 +func ToTopologyData(result Result, opts TopologyDataOptions) topology.Data {
226 + builder := newTopologyDataBuilder(result, opts)
227 + builder.prepareIndexes()
228 + builder.collectBridgeTopologyInputs()
229 + builder.buildDeviceActors()
230 + builder.projectAdjacencyTopology()
231 + builder.buildEndpointTopology()
232 + builder.buildSegmentTopology()
233 + builder.finalizeGraph()
234 + builder.buildStats()
235 + return builder.data()
236 +}
src/go/pkg/topology/engine/topology_adapter_actor_collapse.go new
+303
@@ -0,0 +1,303 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/topology"
10 +)
11 +
12 +func collapseActorsByIP(actors []topology.Actor) []topology.Actor {
13 + if len(actors) <= 1 {
14 + return actors
15 + }
16 +
17 + parent := make([]int, len(actors))
18 + for i := range parent {
19 + parent[i] = i
20 + }
21 + find := func(x int) int {
22 + for parent[x] != x {
23 + parent[x] = parent[parent[x]]
24 + x = parent[x]
25 + }
26 + return x
27 + }
28 + union := func(a, b int) {
29 + ra := find(a)
30 + rb := find(b)
31 + if ra == rb {
32 + return
33 + }
34 + if ra < rb {
35 + parent[rb] = ra
36 + return
37 + }
38 + parent[ra] = rb
39 + }
40 +
41 + ipOwner := make(map[string]int)
42 + for idx, actor := range actors {
43 + if strings.EqualFold(strings.TrimSpace(actor.ActorType), "segment") {
44 + continue
45 + }
46 + ips := normalizedTopologyActorIPs(actor)
47 + if len(ips) == 0 {
48 + continue
49 + }
50 + for _, ip := range ips {
51 + if owner, ok := ipOwner[ip]; ok {
52 + union(idx, owner)
53 + continue
54 + }
55 + ipOwner[ip] = idx
56 + }
57 + }
58 +
59 + groups := make(map[int][]int)
60 + for idx := range actors {
61 + root := find(idx)
62 + groups[root] = append(groups[root], idx)
63 + }
64 +
65 + keep := make([]bool, len(actors))
66 + for i := range keep {
67 + keep[i] = true
68 + }
69 + for _, members := range groups {
70 + if len(members) <= 1 {
71 + continue
72 + }
73 + rep := members[0]
74 + for _, idx := range members[1:] {
75 + if compareTopologyActorCollapsePriority(actors[idx], actors[rep]) < 0 {
76 + rep = idx
77 + }
78 + }
79 + merged := actors[rep]
80 + collapsedCount := 1
81 + for _, idx := range members {
82 + if idx == rep {
83 + continue
84 + }
85 + collapsedCount++
86 + merged.Match = mergeTopologyActorMatch(merged.Match, actors[idx].Match)
87 + merged.Labels = mergeTopologyActorLabels(merged.Labels, actors[idx].Labels)
88 + merged.Attributes = mergeTopologyActorAttributes(merged.Attributes, actors[idx].Attributes)
89 + keep[idx] = false
90 + }
91 + if collapsedCount > 1 {
92 + if merged.Attributes == nil {
93 + merged.Attributes = make(map[string]any)
94 + }
95 + merged.Attributes["collapsed_by_ip"] = true
96 + merged.Attributes["collapsed_count"] = collapsedCount
97 + }
98 + actors[rep] = merged
99 + }
100 +
101 + out := make([]topology.Actor, 0, len(actors))
102 + for idx, actor := range actors {
103 + if !keep[idx] {
104 + continue
105 + }
106 + out = append(out, actor)
107 + }
108 + return out
109 +}
110 +
111 +func eliminateNonIPInferredActors(actors []topology.Actor, links []topology.Link) ([]topology.Actor, []topology.Link) {
112 + if len(actors) == 0 {
113 + return actors, links
114 + }
115 + removedIdentityKeys := make(map[string]struct{})
116 + filteredActors := make([]topology.Actor, 0, len(actors))
117 + for _, actor := range actors {
118 + if topologyActorIsInferred(actor) && len(normalizedTopologyActorIPs(actor)) == 0 {
119 + for _, key := range topologyMatchIdentityKeys(actor.Match) {
120 + removedIdentityKeys[key] = struct{}{}
121 + }
122 + continue
123 + }
124 + filteredActors = append(filteredActors, actor)
125 + }
126 + if len(removedIdentityKeys) == 0 {
127 + return actors, links
128 + }
129 +
130 + filteredLinks := make([]topology.Link, 0, len(links))
131 + for _, link := range links {
132 + srcKeys := topologyMatchIdentityKeys(link.Src.Match)
133 + dstKeys := topologyMatchIdentityKeys(link.Dst.Match)
134 + if topologyIdentityKeysOverlap(srcKeys, removedIdentityKeys) {
135 + continue
136 + }
137 + if topologyIdentityKeysOverlap(dstKeys, removedIdentityKeys) {
138 + continue
139 + }
140 + filteredLinks = append(filteredLinks, link)
141 + }
142 + return filteredActors, filteredLinks
143 +}
144 +
145 +func topologyIdentityKeysOverlap(keys []string, set map[string]struct{}) bool {
146 + if len(keys) == 0 || len(set) == 0 {
147 + return false
148 + }
149 + for _, key := range keys {
150 + if _, ok := set[key]; ok {
151 + return true
152 + }
153 + }
154 + return false
155 +}
156 +
157 +func normalizedTopologyActorIPs(actor topology.Actor) []string {
158 + if len(actor.Match.IPAddresses) == 0 {
159 + return nil
160 + }
161 + seen := make(map[string]struct{}, len(actor.Match.IPAddresses))
162 + out := make([]string, 0, len(actor.Match.IPAddresses))
163 + for _, value := range actor.Match.IPAddresses {
164 + ip := normalizeTopologyIP(value)
165 + if ip == "" {
166 + continue
167 + }
168 + if _, ok := seen[ip]; ok {
169 + continue
170 + }
171 + seen[ip] = struct{}{}
172 + out = append(out, ip)
173 + }
174 + sort.Strings(out)
175 + return out
176 +}
177 +
178 +func compareTopologyActorCollapsePriority(left, right topology.Actor) int {
179 + leftDevice := IsDeviceActorType(left.ActorType)
180 + rightDevice := IsDeviceActorType(right.ActorType)
181 + if leftDevice != rightDevice {
182 + if leftDevice {
183 + return -1
184 + }
185 + return 1
186 + }
187 + leftInferred := topologyActorIsInferred(left)
188 + rightInferred := topologyActorIsInferred(right)
189 + if leftInferred != rightInferred {
190 + if !leftInferred {
191 + return -1
192 + }
193 + return 1
194 + }
195 + leftKey := canonicalTopologyMatchKey(left.Match)
196 + rightKey := canonicalTopologyMatchKey(right.Match)
197 + return strings.Compare(leftKey, rightKey)
198 +}
199 +
200 +func mergeTopologyActorMatch(base, other topology.Match) topology.Match {
201 + base.ChassisIDs = mergeTopologyStringLists(base.ChassisIDs, other.ChassisIDs)
202 + base.MacAddresses = mergeTopologyStringLists(base.MacAddresses, other.MacAddresses)
203 + base.IPAddresses = mergeTopologyStringLists(base.IPAddresses, other.IPAddresses)
204 + base.Hostnames = mergeTopologyStringLists(base.Hostnames, other.Hostnames)
205 + base.DNSNames = mergeTopologyStringLists(base.DNSNames, other.DNSNames)
206 + if strings.TrimSpace(base.SysName) == "" {
207 + base.SysName = strings.TrimSpace(other.SysName)
208 + }
209 + if strings.TrimSpace(base.SysObjectID) == "" {
210 + base.SysObjectID = strings.TrimSpace(other.SysObjectID)
211 + }
212 + return base
213 +}
214 +
215 +func mergeTopologyStringLists(base []string, extra []string) []string {
216 + seen := make(map[string]struct{}, len(base)+len(extra))
217 + out := make([]string, 0, len(base)+len(extra))
218 + for _, value := range append(base, extra...) {
219 + value = strings.TrimSpace(value)
220 + if value == "" {
221 + continue
222 + }
223 + if _, ok := seen[value]; ok {
224 + continue
225 + }
226 + seen[value] = struct{}{}
227 + out = append(out, value)
228 + }
229 + sort.Strings(out)
230 + if len(out) == 0 {
231 + return nil
232 + }
233 + return out
234 +}
235 +
236 +func mergeTopologyActorLabels(base, extra map[string]string) map[string]string {
237 + if len(extra) == 0 {
238 + return base
239 + }
240 + if base == nil {
241 + base = make(map[string]string, len(extra))
242 + }
243 + for key, value := range extra {
244 + key = strings.TrimSpace(key)
245 + value = strings.TrimSpace(value)
246 + if key == "" || value == "" {
247 + continue
248 + }
249 + if _, exists := base[key]; exists {
250 + continue
251 + }
252 + base[key] = value
253 + }
254 + return base
255 +}
256 +
257 +func mergeTopologyActorAttributes(base, extra map[string]any) map[string]any {
258 + if len(extra) == 0 {
259 + return base
260 + }
261 + if base == nil {
262 + base = make(map[string]any, len(extra))
263 + }
264 + for key, value := range extra {
265 + key = strings.TrimSpace(key)
266 + if key == "" {
267 + continue
268 + }
269 + if _, exists := base[key]; exists {
270 + continue
271 + }
272 + base[key] = value
273 + }
274 + return base
275 +}
276 +
277 +func topologyActorIsInferred(actor topology.Actor) bool {
278 + if strings.EqualFold(strings.TrimSpace(actor.ActorType), "endpoint") {
279 + return true
280 + }
281 + if topologyAnyBoolValue(actor.Attributes["inferred"]) {
282 + return true
283 + }
284 + if len(actor.Labels) > 0 {
285 + if topologyAnyBoolValue(actor.Labels["inferred"]) {
286 + return true
287 + }
288 + }
289 + return false
290 +}
291 +
292 +func topologyAnyBoolValue(value any) bool {
293 + switch typed := value.(type) {
294 + case bool:
295 + return typed
296 + case string:
297 + switch strings.ToLower(strings.TrimSpace(typed)) {
298 + case "1", "true", "yes", "on":
299 + return true
300 + }
301 + }
302 + return false
303 +}
src/go/pkg/topology/engine/topology_adapter_bridge_links.go new
+161
@@ -0,0 +1,161 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func collectBridgeLinkRecords(
11 + adjacencies []Adjacency,
12 + ifIndexByDeviceName map[string]int,
13 + strategy topologyInferenceStrategyConfig,
14 +) []bridgeBridgeLinkRecord {
15 + records := make([]bridgeBridgeLinkRecord, 0)
16 + seen := make(map[string]struct{})
17 +
18 + for _, adj := range adjacencies {
19 + protocol := strings.ToLower(strings.TrimSpace(adj.Protocol))
20 + if !strategy.acceptsBridgeProtocol(protocol) {
21 + continue
22 + }
23 +
24 + src := bridgePortFromAdjacencySide(adj.SourceID, adj.SourcePort, ifIndexByDeviceName)
25 + dst := bridgePortFromAdjacencySide(adj.TargetID, adj.TargetPort, ifIndexByDeviceName)
26 + srcKey := bridgePortRefKey(src, false, false)
27 + dstKey := bridgePortRefKey(dst, false, false)
28 + if srcKey == "" || dstKey == "" {
29 + continue
30 + }
31 +
32 + pairKey := bridgePairKey(src, dst)
33 + if pairKey == "" {
34 + continue
35 + }
36 + if _, ok := seen[pairKey]; ok {
37 + continue
38 + }
39 + seen[pairKey] = struct{}{}
40 +
41 + designated := src
42 + other := dst
43 + if protocol == "stp" && strategy.useSTPDesignatedParent {
44 + designated = dst
45 + other = src
46 + if bridgePortRefKey(designated, false, false) == "" {
47 + designated = src
48 + other = dst
49 + }
50 + } else {
51 + if bridgePortRefSortKey(src) > bridgePortRefSortKey(dst) {
52 + designated = dst
53 + other = src
54 + }
55 + }
56 + records = append(records, bridgeBridgeLinkRecord{
57 + port: other,
58 + designatedPort: designated,
59 + method: protocol,
60 + })
61 + }
62 +
63 + sort.SliceStable(records, func(i, j int) bool {
64 + li := portSortKey(records[i].designatedPort) + keySep + portSortKey(records[i].port)
65 + lj := portSortKey(records[j].designatedPort) + keySep + portSortKey(records[j].port)
66 + return li < lj
67 + })
68 + return records
69 +}
70 +
71 +func (s topologyInferenceStrategyConfig) acceptsBridgeProtocol(protocol string) bool {
72 + switch strings.ToLower(strings.TrimSpace(protocol)) {
73 + case "lldp":
74 + return s.includeLLDPBridgeLinks
75 + case "cdp":
76 + return s.includeCDPBridgeLinks
77 + case "stp":
78 + return s.includeSTPBridgeLinks
79 + default:
80 + return false
81 + }
82 +}
83 +
84 +func mergeBridgeLinkRecordSets(base, extra []bridgeBridgeLinkRecord) []bridgeBridgeLinkRecord {
85 + if len(extra) == 0 {
86 + return base
87 + }
88 + out := make([]bridgeBridgeLinkRecord, 0, len(base)+len(extra))
89 + out = append(out, base...)
90 + seen := make(map[string]struct{}, len(base)+len(extra))
91 + for _, link := range out {
92 + if key := bridgePairKey(link.designatedPort, link.port); key != "" {
93 + seen[key] = struct{}{}
94 + }
95 + }
96 + for _, link := range extra {
97 + key := bridgePairKey(link.designatedPort, link.port)
98 + if key == "" {
99 + continue
100 + }
101 + if _, ok := seen[key]; ok {
102 + continue
103 + }
104 + seen[key] = struct{}{}
105 + out = append(out, link)
106 + }
107 + sort.SliceStable(out, func(i, j int) bool {
108 + li := portSortKey(out[i].designatedPort) + keySep + portSortKey(out[i].port)
109 + lj := portSortKey(out[j].designatedPort) + keySep + portSortKey(out[j].port)
110 + return li < lj
111 + })
112 + return out
113 +}
114 +
115 +func collectBridgeMacLinkRecords(
116 + attachments []Attachment,
117 + ifaceByDeviceIndex map[string]Interface,
118 + switchFacingPortKeys map[string]struct{},
119 +) []bridgeMacLinkRecord {
120 + records := make([]bridgeMacLinkRecord, 0, len(attachments))
121 + seen := make(map[string]struct{}, len(attachments))
122 +
123 + attachmentsSorted := append([]Attachment(nil), attachments...)
124 + sort.SliceStable(attachmentsSorted, func(i, j int) bool {
125 + return bridgeAttachmentSortKey(attachmentsSorted[i]) < bridgeAttachmentSortKey(attachmentsSorted[j])
126 + })
127 +
128 + for _, attachment := range attachmentsSorted {
129 + port := bridgePortFromAttachment(attachment, ifaceByDeviceIndex)
130 + portKey := bridgePortRefKey(port, false, false)
131 + endpointID := strings.TrimSpace(attachment.EndpointID)
132 + if portKey == "" || endpointID == "" {
133 + continue
134 + }
135 + method := strings.ToLower(strings.TrimSpace(attachment.Method))
136 + if method == "" {
137 + method = "fdb"
138 + }
139 + if method == "fdb" {
140 + if _, isSwitchFacingPort := switchFacingPortKeys[bridgePortObservationKey(port)]; isSwitchFacingPort {
141 + continue
142 + }
143 + if _, isSwitchFacingPort := switchFacingPortKeys[bridgePortObservationVLANKey(port)]; isSwitchFacingPort {
144 + continue
145 + }
146 + }
147 +
148 + key := portKey + keySep + endpointID + keySep + method
149 + if _, ok := seen[key]; ok {
150 + continue
151 + }
152 + seen[key] = struct{}{}
153 + records = append(records, bridgeMacLinkRecord{
154 + port: port,
155 + endpointID: endpointID,
156 + method: method,
157 + })
158 + }
159 +
160 + return records
161 +}
src/go/pkg/topology/engine/topology_adapter_bridge_links_deterministic.go new
+106
@@ -0,0 +1,106 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "strings"
6 +
7 +func buildDeterministicDiscoveryDevicePairSet(adjacencies []Adjacency) map[string]struct{} {
8 + if len(adjacencies) == 0 {
9 + return nil
10 + }
11 +
12 + out := make(map[string]struct{}, len(adjacencies))
13 + for _, adj := range adjacencies {
14 + protocol := strings.ToLower(strings.TrimSpace(adj.Protocol))
15 + if protocol != "lldp" && protocol != "cdp" {
16 + continue
17 + }
18 +
19 + left := strings.TrimSpace(adj.SourceID)
20 + right := strings.TrimSpace(adj.TargetID)
21 + if left == "" || right == "" {
22 + continue
23 + }
24 + if pair := topologyUndirectedPairKey(left, right); pair != "" {
25 + out[pair] = struct{}{}
26 + }
27 + }
28 + if len(out) == 0 {
29 + return nil
30 + }
31 + return out
32 +}
33 +
34 +func suppressInferredBridgeLinksOnDeterministicDiscovery(
35 + bridgeLinks []bridgeBridgeLinkRecord,
36 + deterministicTransitPortKeys map[string]struct{},
37 + discoveryDevicePairs map[string]struct{},
38 +) []bridgeBridgeLinkRecord {
39 + if len(bridgeLinks) == 0 {
40 + return bridgeLinks
41 + }
42 +
43 + filtered := make([]bridgeBridgeLinkRecord, 0, len(bridgeLinks))
44 + for _, link := range bridgeLinks {
45 + method := strings.ToLower(strings.TrimSpace(link.method))
46 + if method == "lldp" || method == "cdp" {
47 + filtered = append(filtered, link)
48 + continue
49 + }
50 +
51 + if len(deterministicTransitPortKeys) > 0 {
52 + if _, blocked := deterministicTransitPortKeys[bridgePortObservationKey(link.designatedPort)]; blocked {
53 + continue
54 + }
55 + if _, blocked := deterministicTransitPortKeys[bridgePortObservationVLANKey(link.designatedPort)]; blocked {
56 + continue
57 + }
58 + if _, blocked := deterministicTransitPortKeys[bridgePortObservationKey(link.port)]; blocked {
59 + continue
60 + }
61 + if _, blocked := deterministicTransitPortKeys[bridgePortObservationVLANKey(link.port)]; blocked {
62 + continue
63 + }
64 + }
65 +
66 + if len(discoveryDevicePairs) > 0 {
67 + left := strings.TrimSpace(link.designatedPort.deviceID)
68 + right := strings.TrimSpace(link.port.deviceID)
69 + if pair := topologyUndirectedPairKey(left, right); pair != "" {
70 + if _, blocked := discoveryDevicePairs[pair]; blocked {
71 + continue
72 + }
73 + }
74 + }
75 +
76 + filtered = append(filtered, link)
77 + }
78 + return filtered
79 +}
80 +
81 +func buildDeterministicTransitPortKeySet(
82 + adjacencies []Adjacency,
83 + ifIndexByDeviceName map[string]int,
84 +) map[string]struct{} {
85 + if len(adjacencies) == 0 {
86 + return nil
87 + }
88 +
89 + out := make(map[string]struct{}, len(adjacencies)*4)
90 + for _, adj := range adjacencies {
91 + protocol := strings.ToLower(strings.TrimSpace(adj.Protocol))
92 + if protocol != "lldp" && protocol != "cdp" {
93 + continue
94 + }
95 +
96 + src := bridgePortFromAdjacencySide(adj.SourceID, adj.SourcePort, ifIndexByDeviceName)
97 + dst := bridgePortFromAdjacencySide(adj.TargetID, adj.TargetPort, ifIndexByDeviceName)
98 + addBridgePortObservationKeys(out, src)
99 + addBridgePortObservationKeys(out, dst)
100 + }
101 +
102 + if len(out) == 0 {
103 + return nil
104 + }
105 + return out
106 +}
src/go/pkg/topology/engine/topology_adapter_bridge_links_observation.go new
+52
@@ -0,0 +1,52 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 +)
9 +
10 +func bridgePortObservationKey(port bridgePortRef) string {
11 + base := bridgePortObservationBaseKey(port)
12 + if base == "" {
13 + return ""
14 + }
15 + return base + keySep + "vlan:"
16 +}
17 +
18 +func bridgePortObservationVLANKey(port bridgePortRef) string {
19 + base := bridgePortObservationBaseKey(port)
20 + if base == "" {
21 + return ""
22 + }
23 + return base + keySep + "vlan:" + strings.ToLower(strings.TrimSpace(port.vlanID))
24 +}
25 +
26 +func bridgePortObservationBaseKey(port bridgePortRef) string {
27 + deviceID := strings.TrimSpace(port.deviceID)
28 + if deviceID == "" {
29 + return ""
30 + }
31 + if port.ifIndex > 0 {
32 + return deviceID + keySep + "if:" + strconv.Itoa(port.ifIndex)
33 + }
34 + name := firstNonEmpty(port.ifName, port.bridgePort)
35 + name = normalizeInterfaceNameForLookup(name)
36 + if name == "" {
37 + return ""
38 + }
39 + return deviceID + keySep + "name:" + name
40 +}
41 +
42 +func addBridgePortObservationKeys(set map[string]struct{}, port bridgePortRef) {
43 + if set == nil {
44 + return
45 + }
46 + if key := bridgePortObservationKey(port); key != "" {
47 + set[key] = struct{}{}
48 + }
49 + if key := bridgePortObservationVLANKey(port); key != "" {
50 + set[key] = struct{}{}
51 + }
52 +}
src/go/pkg/topology/engine/topology_adapter_bridge_ports.go new
+153
@@ -0,0 +1,153 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 +)
9 +
10 +func topologyMetricString(metrics map[string]any, key string) string {
11 + if len(metrics) == 0 {
12 + return ""
13 + }
14 + value, ok := metrics[key]
15 + if !ok || value == nil {
16 + return ""
17 + }
18 + typed, ok := value.(string)
19 + if !ok {
20 + return ""
21 + }
22 + return strings.TrimSpace(typed)
23 +}
24 +
25 +func bridgeDomainSegmentID(segment *bridgeDomainSegment) string {
26 + if segment == nil {
27 + return ""
28 + }
29 + portKeys := sortedBridgePortSet(segment.ports)
30 + sig := strings.Join(portKeys, "<->")
31 + if sig == "" {
32 + sig = portSortKey(segment.designatedPort)
33 + }
34 + return "bridge-domain:" + sig
35 +}
36 +
37 +func bridgePortFromAdjacencySide(deviceID, port string, ifIndexByDeviceName map[string]int) bridgePortRef {
38 + deviceID = strings.TrimSpace(deviceID)
39 + port = strings.TrimSpace(port)
40 + if deviceID == "" || port == "" {
41 + return bridgePortRef{}
42 + }
43 + ifIndex := resolveIfIndexByPortName(deviceID, port, ifIndexByDeviceName)
44 + return bridgePortRef{
45 + deviceID: deviceID,
46 + ifIndex: ifIndex,
47 + ifName: port,
48 + bridgePort: port,
49 + }
50 +}
51 +
52 +func bridgePortFromAttachment(attachment Attachment, ifaceByDeviceIndex map[string]Interface) bridgePortRef {
53 + deviceID := strings.TrimSpace(attachment.DeviceID)
54 + if deviceID == "" {
55 + return bridgePortRef{}
56 + }
57 + ifIndex := attachment.IfIndex
58 + ifName := strings.TrimSpace(attachment.Labels["if_name"])
59 + if ifName == "" && ifIndex > 0 {
60 + if iface, ok := ifaceByDeviceIndex[deviceIfIndexKey(deviceID, ifIndex)]; ok {
61 + ifName = strings.TrimSpace(iface.IfName)
62 + }
63 + }
64 + bridgePort := strings.TrimSpace(attachment.Labels["bridge_port"])
65 + if bridgePort == "" {
66 + if ifIndex > 0 {
67 + bridgePort = strconv.Itoa(ifIndex)
68 + } else {
69 + bridgePort = ifName
70 + }
71 + }
72 + vlanID := strings.TrimSpace(attachment.Labels["vlan"])
73 + if vlanID == "" {
74 + vlanID = strings.TrimSpace(attachment.Labels["vlan_id"])
75 + }
76 + return bridgePortRef{
77 + deviceID: deviceID,
78 + ifIndex: ifIndex,
79 + ifName: ifName,
80 + bridgePort: bridgePort,
81 + vlanID: vlanID,
82 + }
83 +}
84 +
85 +func bridgeAttachmentSortKey(attachment Attachment) string {
86 + vlanID := strings.TrimSpace(attachment.Labels["vlan"])
87 + if vlanID == "" {
88 + vlanID = strings.TrimSpace(attachment.Labels["vlan_id"])
89 + }
90 + parts := []string{
91 + strings.TrimSpace(attachment.DeviceID),
92 + strconv.Itoa(attachment.IfIndex),
93 + strings.TrimSpace(attachment.Labels["if_name"]),
94 + strings.TrimSpace(attachment.Labels["bridge_port"]),
95 + strings.ToLower(vlanID),
96 + strings.ToLower(strings.TrimSpace(attachment.Method)),
97 + strings.TrimSpace(attachment.EndpointID),
98 + }
99 + return strings.Join(parts, keySep)
100 +}
101 +
102 +func bridgePairKey(left, right bridgePortRef) string {
103 + leftKey := bridgePortRefKey(left, false, false)
104 + rightKey := bridgePortRefKey(right, false, false)
105 + if leftKey == "" || rightKey == "" {
106 + return ""
107 + }
108 + if leftKey > rightKey {
109 + leftKey, rightKey = rightKey, leftKey
110 + }
111 + return leftKey + "<->" + rightKey
112 +}
113 +
114 +func bridgePortRefKey(port bridgePortRef, includeBridgePort bool, includeVLAN bool) string {
115 + deviceID := strings.TrimSpace(port.deviceID)
116 + if deviceID == "" {
117 + return ""
118 + }
119 + bridgePort := strings.TrimSpace(port.bridgePort)
120 + if bridgePort == "" && port.ifIndex > 0 {
121 + bridgePort = strconv.Itoa(port.ifIndex)
122 + }
123 + ifName := strings.TrimSpace(port.ifName)
124 + vlanID := strings.TrimSpace(port.vlanID)
125 + if !includeVLAN {
126 + vlanID = ""
127 + }
128 +
129 + parts := []string{
130 + deviceID,
131 + "if:" + strconv.Itoa(port.ifIndex),
132 + "name:" + strings.ToLower(ifName),
133 + }
134 + if includeBridgePort {
135 + parts = append(parts, "bp:"+strings.ToLower(bridgePort))
136 + }
137 + parts = append(parts, "vlan:"+strings.ToLower(vlanID))
138 + return strings.Join(parts, keySep)
139 +}
140 +
141 +func bridgePortRefSortKey(port bridgePortRef) string {
142 + return bridgePortRefKey(port, true, true)
143 +}
144 +
145 +func bridgePortDisplay(port bridgePortRef) string {
146 + if name := strings.TrimSpace(port.ifName); name != "" {
147 + return name
148 + }
149 + if port.ifIndex > 0 {
150 + return strconv.Itoa(port.ifIndex)
151 + }
152 + return strings.TrimSpace(port.bridgePort)
153 +}
src/go/pkg/topology/engine/topology_adapter_bridge_ports_test.go new
+37
@@ -0,0 +1,37 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestBridgeAttachmentSortKey_DistinguishesVLANAndMethod(t *testing.T) {
12 + base := Attachment{
13 + DeviceID: "switch-a",
14 + IfIndex: 7,
15 + EndpointID: "mac:00:11:22:33:44:55",
16 + Labels: map[string]string{
17 + "if_name": "swp07",
18 + "bridge_port": "7",
19 + "vlan_id": "20",
20 + },
21 + }
22 +
23 + fdb := base
24 + fdb.Method = "fdb"
25 + arp := base
26 + arp.Method = "arp"
27 + otherVLAN := base
28 + otherVLAN.Method = "fdb"
29 + otherVLAN.Labels = map[string]string{
30 + "if_name": "swp07",
31 + "bridge_port": "7",
32 + "vlan_id": "30",
33 + }
34 +
35 + require.NotEqual(t, bridgeAttachmentSortKey(fdb), bridgeAttachmentSortKey(arp))
36 + require.NotEqual(t, bridgeAttachmentSortKey(fdb), bridgeAttachmentSortKey(otherVLAN))
37 +}
src/go/pkg/topology/engine/topology_adapter_builder.go new
+300
@@ -0,0 +1,300 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strings"
7 + "time"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/topology"
10 +)
11 +
12 +type topologyDataBuilder struct {
13 + result Result
14 + opts TopologyDataOptions
15 +
16 + schemaVersion string
17 + source string
18 + layer string
19 + view string
20 + collectedAt time.Time
21 +
22 + strategyConfig topologyInferenceStrategyConfig
23 +
24 + deviceByID map[string]Device
25 + ifaceByDeviceIndex map[string]Interface
26 + ifIndexByDeviceName map[string]int
27 + bridgeLinks []bridgeBridgeLinkRecord
28 + reporterAliases map[string][]string
29 + ifaceSummaryByDevice map[string]topologyDeviceInterfaceSummary
30 +
31 + actors []topology.Actor
32 + actorIndex map[string]struct{}
33 + actorMACIndex map[string]struct{}
34 +
35 + projectedAdjacencies projectedLinks
36 + endpointActors builtEndpointActors
37 + segmentProjection projectedSegments
38 +
39 + links []topology.Link
40 + segmentSuppressed int
41 + unlinkedSuppressed int
42 + linkCounts topologyLinkCounts
43 + probableLinks int
44 + stats map[string]any
45 +}
46 +
47 +func newTopologyDataBuilder(result Result, opts TopologyDataOptions) *topologyDataBuilder {
48 + builder := &topologyDataBuilder{
49 + result: result,
50 + opts: opts,
51 + }
52 +
53 + builder.schemaVersion = strings.TrimSpace(opts.SchemaVersion)
54 + if builder.schemaVersion == "" {
55 + builder.schemaVersion = "2.0"
56 + }
57 +
58 + builder.source = strings.TrimSpace(opts.Source)
59 + if builder.source == "" {
60 + builder.source = "snmp"
61 + }
62 +
63 + builder.layer = strings.TrimSpace(opts.Layer)
64 + if builder.layer == "" {
65 + builder.layer = "2"
66 + }
67 +
68 + builder.view = strings.TrimSpace(opts.View)
69 + if builder.view == "" {
70 + builder.view = "summary"
71 + }
72 +
73 + builder.collectedAt = opts.CollectedAt
74 + if builder.collectedAt.IsZero() {
75 + builder.collectedAt = result.CollectedAt
76 + }
77 + if builder.collectedAt.IsZero() {
78 + builder.collectedAt = time.Now().UTC()
79 + }
80 +
81 + builder.strategyConfig = topologyInferenceStrategyConfigFor(opts.InferenceStrategy)
82 + return builder
83 +}
84 +
85 +func (b *topologyDataBuilder) prepareIndexes() {
86 + b.deviceByID = make(map[string]Device, len(b.result.Devices))
87 + b.ifaceByDeviceIndex = make(map[string]Interface, len(b.result.Interfaces))
88 + b.ifIndexByDeviceName = make(map[string]int, len(b.result.Interfaces))
89 +
90 + for _, dev := range b.result.Devices {
91 + b.deviceByID[dev.ID] = dev
92 + }
93 +
94 + for _, iface := range b.result.Interfaces {
95 + if iface.IfIndex <= 0 {
96 + continue
97 + }
98 + b.ifaceByDeviceIndex[deviceIfIndexKey(iface.DeviceID, iface.IfIndex)] = iface
99 + for _, alias := range interfaceNameLookupAliases(iface.IfName, iface.IfDescr) {
100 + b.ifIndexByDeviceName[deviceIfNameKey(iface.DeviceID, alias)] = iface.IfIndex
101 + }
102 + }
103 +}
104 +
105 +func (b *topologyDataBuilder) collectBridgeTopologyInputs() {
106 + b.bridgeLinks = collectBridgeLinkRecords(b.result.Adjacencies, b.ifIndexByDeviceName, b.strategyConfig)
107 + b.reporterAliases = buildFDBReporterAliases(b.deviceByID, b.ifaceByDeviceIndex)
108 + if b.strategyConfig.enableFDBPairwiseLinks {
109 + b.bridgeLinks = mergeBridgeLinkRecordSets(
110 + b.bridgeLinks,
111 + inferFDBPairwiseBridgeLinks(b.result.Attachments, b.ifaceByDeviceIndex, b.reporterAliases),
112 + )
113 + }
114 +
115 + deterministicTransitPortKeys := buildDeterministicTransitPortKeySet(b.result.Adjacencies, b.ifIndexByDeviceName)
116 + discoveryDevicePairs := buildDeterministicDiscoveryDevicePairSet(b.result.Adjacencies)
117 + b.bridgeLinks = suppressInferredBridgeLinksOnDeterministicDiscovery(
118 + b.bridgeLinks,
119 + deterministicTransitPortKeys,
120 + discoveryDevicePairs,
121 + )
122 +
123 + b.ifaceSummaryByDevice = buildTopologyDeviceInterfaceSummaries(
124 + b.result.Interfaces,
125 + b.result.Attachments,
126 + b.result.Adjacencies,
127 + b.deviceByID,
128 + b.ifIndexByDeviceName,
129 + b.bridgeLinks,
130 + b.reporterAliases,
131 + )
132 +}
133 +
134 +func (b *topologyDataBuilder) buildDeviceActors() {
135 + b.actors = make([]topology.Actor, 0, len(b.result.Devices))
136 + b.actorIndex = make(map[string]struct{}, len(b.result.Devices)*2)
137 + b.actorMACIndex = make(map[string]struct{}, len(b.result.Devices))
138 +
139 + for _, dev := range b.result.Devices {
140 + actor := deviceToTopologyActor(
141 + dev,
142 + b.source,
143 + b.layer,
144 + b.opts.LocalDeviceID,
145 + b.ifaceSummaryByDevice[dev.ID],
146 + b.reporterAliases[dev.ID],
147 + )
148 + keys := topologyMatchIdentityKeys(actor.Match)
149 + if len(keys) == 0 {
150 + continue
151 + }
152 + macKeys := topologyMatchHardwareIdentityKeys(actor.Match)
153 + if len(macKeys) > 0 {
154 + if topologyIdentityIndexOverlaps(b.actorMACIndex, macKeys) {
155 + continue
156 + }
157 + addTopologyIdentityKeys(b.actorMACIndex, macKeys)
158 + } else if topologyIdentityIndexOverlaps(b.actorIndex, keys) {
159 + continue
160 + }
161 + addTopologyIdentityKeys(b.actorIndex, keys)
162 + b.actors = append(b.actors, actor)
163 + }
164 +}
165 +
166 +func (b *topologyDataBuilder) projectAdjacencyTopology() {
167 + b.projectedAdjacencies = projectAdjacencyLinks(
168 + b.result.Adjacencies,
169 + b.layer,
170 + b.collectedAt,
171 + b.deviceByID,
172 + b.ifIndexByDeviceName,
173 + b.ifaceByDeviceIndex,
174 + )
175 +}
176 +
177 +func (b *topologyDataBuilder) buildEndpointTopology() {
178 + b.endpointActors = buildEndpointActors(
179 + b.result.Attachments,
180 + b.result.Enrichments,
181 + b.ifaceByDeviceIndex,
182 + b.source,
183 + b.layer,
184 + b.actorIndex,
185 + b.actorMACIndex,
186 + )
187 + b.actors = append(b.actors, b.endpointActors.actors...)
188 +}
189 +
190 +func (b *topologyDataBuilder) buildSegmentTopology() {
191 + b.segmentProjection = projectSegmentTopology(
192 + b.result.Attachments,
193 + b.result.Adjacencies,
194 + b.layer,
195 + b.source,
196 + b.collectedAt,
197 + b.deviceByID,
198 + b.ifaceByDeviceIndex,
199 + b.ifIndexByDeviceName,
200 + b.bridgeLinks,
201 + b.reporterAliases,
202 + b.endpointActors.matchByEndpointID,
203 + b.endpointActors.labelsByEndpointID,
204 + b.actorIndex,
205 + b.opts.ProbabilisticConnectivity,
206 + b.strategyConfig,
207 + )
208 + annotateEndpointActorsWithDirectOwners(
209 + b.actors,
210 + b.endpointActors.matchByEndpointID,
211 + b.segmentProjection.endpointDirectOwners,
212 + b.deviceByID,
213 + )
214 + b.actors = append(b.actors, b.segmentProjection.actors...)
215 +}
216 +
217 +func (b *topologyDataBuilder) finalizeGraph() {
218 + sortTopologyActors(b.actors)
219 +
220 + b.links = make([]topology.Link, 0, len(b.projectedAdjacencies.links)+len(b.segmentProjection.links))
221 + b.links = append(b.links, b.projectedAdjacencies.links...)
222 + b.links = append(b.links, b.segmentProjection.links...)
223 + sortTopologyLinks(b.links)
224 +
225 + b.actors, b.links, b.segmentSuppressed = pruneSegmentArtifacts(b.actors, b.links)
226 + if b.opts.CollapseActorsByIP {
227 + b.actors = collapseActorsByIP(b.actors)
228 + }
229 + if b.opts.EliminateNonIPInferred {
230 + b.actors, b.links = eliminateNonIPInferredActors(b.actors, b.links)
231 + }
232 + if b.opts.CollapseActorsByIP {
233 + b.actors, b.unlinkedSuppressed = pruneManagedOverlapUnlinkedEndpointActors(
234 + b.actors,
235 + b.links,
236 + b.segmentProjection.suppressedManagedOverlapIDs,
237 + )
238 + }
239 + var additionalSegmentSuppressed int
240 + b.actors, b.links, additionalSegmentSuppressed = pruneSegmentArtifacts(b.actors, b.links)
241 + b.segmentSuppressed += additionalSegmentSuppressed
242 + sortTopologyActors(b.actors)
243 + sortTopologyLinks(b.links)
244 + applyTopologyDisplayNames(b.actors, b.links, b.opts.ResolveDNSName)
245 + assignTopologyActorIDsAndLinkEndpoints(b.actors, b.links)
246 + enrichTopologyPortTablesWithLinkCounts(b.actors, b.links)
247 +
248 + b.linkCounts = summarizeTopologyLinks(b.links)
249 + b.probableLinks = 0
250 + for _, link := range b.links {
251 + if strings.EqualFold(strings.TrimSpace(link.State), "probable") {
252 + b.probableLinks++
253 + continue
254 + }
255 + if strings.EqualFold(topologyMetricString(link.Metrics, "inference"), "probable") {
256 + b.probableLinks++
257 + }
258 + }
259 +}
260 +
261 +func (b *topologyDataBuilder) buildStats() {
262 + b.stats = cloneAnyMap(b.result.Stats)
263 + if b.stats == nil {
264 + b.stats = make(map[string]any)
265 + }
266 +
267 + b.stats["devices_total"] = len(b.result.Devices)
268 + b.stats["devices_discovered"] = discoveredDeviceCount(b.result.Devices, b.opts.LocalDeviceID)
269 + b.stats["links_total"] = len(b.links)
270 + b.stats["links_lldp"] = b.linkCounts.lldp
271 + b.stats["links_cdp"] = b.linkCounts.cdp
272 + b.stats["links_bidirectional"] = b.linkCounts.bidirectional
273 + b.stats["links_unidirectional"] = b.linkCounts.unidirectional
274 + b.stats["links_fdb"] = b.linkCounts.fdb
275 + b.stats["links_fdb_endpoint_candidates"] = b.segmentProjection.endpointLinksCandidates
276 + b.stats["links_fdb_endpoint_emitted"] = b.segmentProjection.endpointLinksEmitted
277 + b.stats["links_fdb_endpoint_suppressed"] = b.segmentProjection.endpointLinksSuppressed
278 + b.stats["endpoints_ambiguous_segments"] = b.segmentProjection.endpointsWithAmbiguousSegment
279 + b.stats["links_arp"] = b.linkCounts.arp
280 + b.stats["links_probable"] = b.probableLinks
281 + b.stats["segments_suppressed"] = b.segmentSuppressed
282 + b.stats["actors_total"] = len(b.actors)
283 + b.stats["actors_unlinked_suppressed"] = b.unlinkedSuppressed
284 + b.stats["endpoints_total"] = b.endpointActors.count
285 + b.stats["inference_strategy"] = b.strategyConfig.id
286 +}
287 +
288 +func (b *topologyDataBuilder) data() topology.Data {
289 + return topology.Data{
290 + SchemaVersion: b.schemaVersion,
291 + Source: b.source,
292 + Layer: b.layer,
293 + AgentID: b.opts.AgentID,
294 + CollectedAt: b.collectedAt,
295 + View: b.view,
296 + Actors: b.actors,
297 + Links: b.links,
298 + Stats: b.stats,
299 + }
300 +}
src/go/pkg/topology/engine/topology_adapter_collections.go new
+200
@@ -0,0 +1,200 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "maps"
7 + "net/netip"
8 + "sort"
9 + "strings"
10 +)
11 +
12 +func cloneAnyMap(in map[string]any) map[string]any {
13 + if len(in) == 0 {
14 + return nil
15 + }
16 + out := make(map[string]any, len(in))
17 + maps.Copy(out, in)
18 + return out
19 +}
20 +
21 +func cloneStringMap(in map[string]string) map[string]string {
22 + if len(in) == 0 {
23 + return nil
24 + }
25 + out := make(map[string]string, len(in))
26 + maps.Copy(out, in)
27 + return out
28 +}
29 +
30 +func addressStrings(addresses []netip.Addr) []string {
31 + if len(addresses) == 0 {
32 + return nil
33 + }
34 + out := make([]string, 0, len(addresses))
35 + for _, addr := range addresses {
36 + if !addr.IsValid() {
37 + continue
38 + }
39 + out = append(out, addr.Unmap().String())
40 + }
41 + out = uniqueTopologyStrings(out)
42 + if len(out) == 0 {
43 + return nil
44 + }
45 + return out
46 +}
47 +
48 +func firstAddress(addresses []netip.Addr) string {
49 + values := addressStrings(addresses)
50 + if len(values) == 0 {
51 + return ""
52 + }
53 + return values[0]
54 +}
55 +
56 +func uniqueTopologyStrings(values []string) []string {
57 + if len(values) == 0 {
58 + return nil
59 + }
60 + seen := make(map[string]struct{}, len(values))
61 + out := make([]string, 0, len(values))
62 + for _, value := range values {
63 + value = strings.TrimSpace(value)
64 + if value == "" {
65 + continue
66 + }
67 + if _, ok := seen[value]; ok {
68 + continue
69 + }
70 + seen[value] = struct{}{}
71 + out = append(out, value)
72 + }
73 + sort.Strings(out)
74 + if len(out) == 0 {
75 + return nil
76 + }
77 + return out
78 +}
79 +
80 +func sortedEndpointIPs(in map[string]netip.Addr) []string {
81 + if len(in) == 0 {
82 + return nil
83 + }
84 + keys := make([]string, 0, len(in))
85 + for key := range in {
86 + keys = append(keys, key)
87 + }
88 + sort.Strings(keys)
89 +
90 + out := make([]string, 0, len(keys))
91 + for _, key := range keys {
92 + addr, ok := in[key]
93 + if !ok || !addr.IsValid() {
94 + continue
95 + }
96 + out = append(out, addr.Unmap().String())
97 + }
98 + out = uniqueTopologyStrings(out)
99 + if len(out) == 0 {
100 + return nil
101 + }
102 + return out
103 +}
104 +
105 +func sortedTopologySet(in map[string]struct{}) []string {
106 + if len(in) == 0 {
107 + return nil
108 + }
109 + out := make([]string, 0, len(in))
110 + for value := range in {
111 + value = strings.TrimSpace(value)
112 + if value == "" {
113 + continue
114 + }
115 + out = append(out, value)
116 + }
117 + sort.Strings(out)
118 + if len(out) == 0 {
119 + return nil
120 + }
121 + return out
122 +}
123 +
124 +func csvToSet(value string) []string {
125 + value = strings.TrimSpace(value)
126 + if value == "" {
127 + return nil
128 + }
129 + parts := strings.Split(value, ",")
130 + out := make([]string, 0, len(parts))
131 + for _, part := range parts {
132 + part = strings.TrimSpace(part)
133 + if part == "" {
134 + continue
135 + }
136 + out = append(out, part)
137 + }
138 + if len(out) == 0 {
139 + return nil
140 + }
141 + return out
142 +}
143 +
144 +func labelsCSVToSlice(labels map[string]string, key string) []string {
145 + if len(labels) == 0 {
146 + return nil
147 + }
148 + return csvToSet(labels[key])
149 +}
150 +
151 +func pruneTopologyAttributes(attrs map[string]any) map[string]any {
152 + for key, value := range attrs {
153 + switch typed := value.(type) {
154 + case string:
155 + if strings.TrimSpace(typed) == "" {
156 + delete(attrs, key)
157 + }
158 + case []string:
159 + if len(typed) == 0 {
160 + delete(attrs, key)
161 + }
162 + case map[string]string:
163 + if len(typed) == 0 {
164 + delete(attrs, key)
165 + }
166 + case map[string]any:
167 + if len(typed) == 0 {
168 + delete(attrs, key)
169 + }
170 + case int:
171 + if typed == 0 {
172 + delete(attrs, key)
173 + }
174 + case nil:
175 + delete(attrs, key)
176 + }
177 + }
178 + if len(attrs) == 0 {
179 + return nil
180 + }
181 + return attrs
182 +}
183 +
184 +func mapStringStringToAny(in map[string]string) map[string]any {
185 + if len(in) == 0 {
186 + return nil
187 + }
188 + out := make(map[string]any, len(in))
189 + for key, value := range in {
190 + value = strings.TrimSpace(value)
191 + if value == "" {
192 + continue
193 + }
194 + out[key] = value
195 + }
196 + if len(out) == 0 {
197 + return nil
198 + }
199 + return out
200 +}
src/go/pkg/topology/engine/topology_adapter_collections_test.go new
+30
@@ -0,0 +1,30 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "net/netip"
7 + "testing"
8 +
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestAddressStrings_DeduplicatesMappedAndUnmappedIPs(t *testing.T) {
13 + addresses := []netip.Addr{
14 + netip.MustParseAddr("::ffff:10.0.0.1"),
15 + netip.MustParseAddr("10.0.0.1"),
16 + netip.MustParseAddr("2001:db8::1"),
17 + }
18 +
19 + require.Equal(t, []string{"10.0.0.1", "2001:db8::1"}, addressStrings(addresses))
20 +}
21 +
22 +func TestSortedEndpointIPs_DeduplicatesMappedAndUnmappedIPs(t *testing.T) {
23 + values := map[string]netip.Addr{
24 + "mapped": netip.MustParseAddr("::ffff:10.0.0.1"),
25 + "unmapped": netip.MustParseAddr("10.0.0.1"),
26 + "ipv6": netip.MustParseAddr("2001:db8::1"),
27 + }
28 +
29 + require.Equal(t, []string{"10.0.0.1", "2001:db8::1"}, sortedEndpointIPs(values))
30 +}
src/go/pkg/topology/engine/topology_adapter_device_actor.go new
+188
@@ -0,0 +1,188 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strings"
7 +
8 + "github.com/netdata/netdata/go/plugins/pkg/topology"
9 +)
10 +
11 +func topologyDeviceInferred(dev Device) bool {
12 + if len(dev.Labels) == 0 {
13 + return false
14 + }
15 + switch strings.ToLower(strings.TrimSpace(dev.Labels["inferred"])) {
16 + case "1", "true", "yes", "on":
17 + return true
18 + default:
19 + return false
20 + }
21 +}
22 +
23 +func buildDeviceActorMatch(dev Device, reporterAliases []string) topology.Match {
24 + match := topology.Match{
25 + SysObjectID: strings.TrimSpace(dev.SysObject),
26 + SysName: strings.TrimSpace(dev.Hostname),
27 + }
28 +
29 + macSet := make(map[string]struct{}, 1+len(reporterAliases))
30 + chassis := strings.TrimSpace(dev.ChassisID)
31 + if chassis != "" {
32 + match.ChassisIDs = []string{chassis}
33 + if mac := normalizeMAC(chassis); mac != "" {
34 + macSet[mac] = struct{}{}
35 + }
36 + }
37 + for _, alias := range reporterAliases {
38 + alias = strings.TrimSpace(alias)
39 + if alias == "" {
40 + continue
41 + }
42 + if after, ok := strings.CutPrefix(alias, "mac:"); ok {
43 + if mac := normalizeMAC(after); mac != "" {
44 + macSet[mac] = struct{}{}
45 + }
46 + continue
47 + }
48 + if mac := normalizeMAC(alias); mac != "" {
49 + macSet[mac] = struct{}{}
50 + }
51 + }
52 + if len(macSet) > 0 {
53 + match.MacAddresses = sortedTopologySet(macSet)
54 + }
55 +
56 + if len(dev.Addresses) > 0 {
57 + ips := make([]string, 0, len(dev.Addresses))
58 + for _, addr := range dev.Addresses {
59 + if !addr.IsValid() {
60 + continue
61 + }
62 + ips = append(ips, addr.String())
63 + }
64 + match.IPAddresses = uniqueTopologyStrings(ips)
65 + }
66 +
67 + return match
68 +}
69 +
70 +func buildDeviceActorAttributes(
71 + dev Device,
72 + localDeviceID string,
73 + ifaceSummary topologyDeviceInterfaceSummary,
74 + match topology.Match,
75 +) map[string]any {
76 + discovered := strings.TrimSpace(localDeviceID) == "" || dev.ID != localDeviceID
77 +
78 + attrs := map[string]any{
79 + "device_id": dev.ID,
80 + "discovered": discovered,
81 + "inferred": topologyDeviceInferred(dev),
82 + "management_ip": firstAddress(dev.Addresses),
83 + "management_addresses": addressStrings(dev.Addresses),
84 + "protocols": labelsCSVToSlice(dev.Labels, "protocols_observed"),
85 + "protocols_collected": labelsCSVToSlice(dev.Labels, "protocols_observed"),
86 + "capabilities": labelsCSVToSlice(dev.Labels, "capabilities"),
87 + "capabilities_supported": labelsCSVToSlice(dev.Labels, "capabilities_supported"),
88 + "capabilities_enabled": labelsCSVToSlice(dev.Labels, "capabilities_enabled"),
89 + }
90 + derivedVendor, derivedPrefix := inferTopologyVendorFromMatch(match)
91 + if derivedVendor != "" {
92 + attrs["vendor_derived"] = derivedVendor
93 + attrs["vendor_derived_source"] = "mac_oui"
94 + attrs["vendor_derived_confidence"] = "low"
95 + attrs["vendor_derived_match_prefix"] = derivedPrefix
96 + }
97 + if vendor := strings.TrimSpace(dev.Labels["vendor"]); vendor != "" {
98 + attrs["vendor"] = vendor
99 + attrs["vendor_source"] = "labels"
100 + attrs["vendor_confidence"] = "high"
101 + } else if derivedVendor != "" {
102 + attrs["vendor"] = derivedVendor
103 + attrs["vendor_source"] = "mac_oui"
104 + attrs["vendor_confidence"] = "low"
105 + attrs["vendor_match_prefix"] = derivedPrefix
106 + }
107 + if ifaceSummary.portsTotal > 0 {
108 + attrs["ports_total"] = ifaceSummary.portsTotal
109 + }
110 + if len(ifaceSummary.ifIndexes) > 0 {
111 + attrs["if_indexes"] = ifaceSummary.ifIndexes
112 + }
113 + if len(ifaceSummary.ifNames) > 0 {
114 + attrs["if_names"] = ifaceSummary.ifNames
115 + }
116 + if ifaceSummary.portsUp > 0 {
117 + attrs["ports_up"] = ifaceSummary.portsUp
118 + }
119 + if ifaceSummary.portsDown > 0 {
120 + attrs["ports_down"] = ifaceSummary.portsDown
121 + }
122 + if ifaceSummary.portsAdminDown > 0 {
123 + attrs["ports_admin_down"] = ifaceSummary.portsAdminDown
124 + }
125 + if ifaceSummary.totalBandwidthBps > 0 {
126 + attrs["total_bandwidth_bps"] = ifaceSummary.totalBandwidthBps
127 + }
128 + if ifaceSummary.fdbTotalMACs > 0 {
129 + attrs["fdb_total_macs"] = ifaceSummary.fdbTotalMACs
130 + }
131 + if ifaceSummary.vlanCount > 0 {
132 + attrs["vlan_count"] = ifaceSummary.vlanCount
133 + }
134 + if ifaceSummary.lldpNeighborCount > 0 {
135 + attrs["lldp_neighbor_count"] = ifaceSummary.lldpNeighborCount
136 + }
137 + if ifaceSummary.cdpNeighborCount > 0 {
138 + attrs["cdp_neighbor_count"] = ifaceSummary.cdpNeighborCount
139 + }
140 + if len(ifaceSummary.adminStatusCount) > 0 {
141 + attrs["if_admin_status_counts"] = ifaceSummary.adminStatusCount
142 + }
143 + if len(ifaceSummary.operStatusCount) > 0 {
144 + attrs["if_oper_status_counts"] = ifaceSummary.operStatusCount
145 + }
146 + if len(ifaceSummary.linkModeCount) > 0 {
147 + attrs["if_link_mode_counts"] = ifaceSummary.linkModeCount
148 + }
149 + if len(ifaceSummary.roleCount) > 0 {
150 + attrs["if_topology_role_counts"] = ifaceSummary.roleCount
151 + }
152 + if len(ifaceSummary.portStatuses) > 0 {
153 + attrs["if_statuses"] = ifaceSummary.portStatuses
154 + }
155 + return attrs
156 +}
157 +
158 +func buildDeviceActorTables(ifaceSummary topologyDeviceInterfaceSummary) map[string][]map[string]any {
159 + if len(ifaceSummary.portStatuses) == 0 {
160 + return nil
161 + }
162 +
163 + rows := make([]map[string]any, 0, len(ifaceSummary.portStatuses))
164 + for _, ps := range ifaceSummary.portStatuses {
165 + row := make(map[string]any, len(ps)+2)
166 + for k, v := range ps {
167 + switch k {
168 + case "if_name":
169 + row["name"] = v
170 + case "if_type":
171 + row["port_type"] = v
172 + default:
173 + row[k] = v
174 + }
175 + }
176 + if neighbors, ok := ps["neighbors"]; ok {
177 + switch nb := neighbors.(type) {
178 + case []map[string]any:
179 + row["neighbor_count"] = len(nb)
180 + case []any:
181 + row["neighbor_count"] = len(nb)
182 + }
183 + }
184 + rows = append(rows, row)
185 + }
186 +
187 + return map[string][]map[string]any{"ports": rows}
188 +}
src/go/pkg/topology/engine/topology_adapter_device_endpoint.go new
+65
@@ -0,0 +1,65 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/topology"
10 +)
11 +
12 +func adjacencySideToEndpoint(dev Device, port string, ifIndexByDeviceName map[string]int, ifaceByDeviceIndex map[string]Interface) topology.LinkEndpoint {
13 + match := buildDeviceActorMatch(dev, nil)
14 +
15 + port = strings.TrimSpace(port)
16 + ifName := ""
17 + ifDescr := ""
18 + ifIndex := 0
19 + var iface Interface
20 + hasIface := false
21 + if port != "" {
22 + ifIndex = resolveIfIndexByPortName(dev.ID, port, ifIndexByDeviceName)
23 + }
24 + if ifIndex > 0 {
25 + if ifaceValue, ok := ifaceByDeviceIndex[deviceIfIndexKey(dev.ID, ifIndex)]; ok {
26 + iface = ifaceValue
27 + hasIface = true
28 + ifName = strings.TrimSpace(iface.IfName)
29 + ifDescr = strings.TrimSpace(iface.IfDescr)
30 + }
31 + }
32 + if ifName == "" {
33 + ifName = ifDescr
34 + }
35 + if ifName == "" {
36 + ifName = port
37 + }
38 + if ifIndex > 0 && ifName == "" {
39 + ifName = strconv.Itoa(ifIndex)
40 + }
41 +
42 + attrs := map[string]any{
43 + "if_index": ifIndex,
44 + "if_name": ifName,
45 + "port_id": port,
46 + "sys_name": strings.TrimSpace(dev.Hostname),
47 + "management_ip": firstAddress(dev.Addresses),
48 + }
49 + if ifDescr != "" {
50 + attrs["if_descr"] = ifDescr
51 + }
52 + if ifIndex > 0 && hasIface {
53 + if admin := strings.TrimSpace(iface.Labels["admin_status"]); admin != "" {
54 + attrs["if_admin_status"] = admin
55 + }
56 + if oper := strings.TrimSpace(iface.Labels["oper_status"]); oper != "" {
57 + attrs["if_oper_status"] = oper
58 + }
59 + }
60 +
61 + return topology.LinkEndpoint{
62 + Match: match,
63 + Attributes: pruneTopologyAttributes(attrs),
64 + }
65 +}
src/go/pkg/topology/engine/topology_adapter_device_summary.go new
+63
@@ -0,0 +1,63 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "math"
7 + "strconv"
8 + "strings"
9 +)
10 +
11 +func normalizeTopologyVLANID(value string) string {
12 + value = strings.TrimSpace(value)
13 + if value == "" {
14 + return ""
15 + }
16 + return strings.ToLower(value)
17 +}
18 +
19 +func safeTopologyInt64Add(base, add int64) int64 {
20 + if add <= 0 {
21 + return base
22 + }
23 + if base > math.MaxInt64-add {
24 + return math.MaxInt64
25 + }
26 + return base + add
27 +}
28 +
29 +func parseTopologyLabelInt64(value string) int64 {
30 + value = strings.TrimSpace(value)
31 + if value == "" {
32 + return 0
33 + }
34 + parsed, err := strconv.ParseInt(value, 10, 64)
35 + if err != nil || parsed <= 0 {
36 + return 0
37 + }
38 + return parsed
39 +}
40 +
41 +func normalizeTopologyDuplex(value string) string {
42 + value = strings.ToLower(strings.TrimSpace(value))
43 + switch value {
44 + case "full", "3":
45 + return "full"
46 + case "half", "2":
47 + return "half"
48 + case "unknown", "1":
49 + return "unknown"
50 + default:
51 + return ""
52 + }
53 +}
54 +
55 +func firstNonEmpty(values ...string) string {
56 + for _, value := range values {
57 + value = strings.TrimSpace(value)
58 + if value != "" {
59 + return value
60 + }
61 + }
62 + return ""
63 +}
src/go/pkg/topology/engine/topology_adapter_device_summary_builder.go new
+84
@@ -0,0 +1,84 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +type deviceInterfaceCollector struct {
6 + ifIndexes map[string]struct{}
7 + ifNames map[string]struct{}
8 + ifTypes map[string]int
9 + adminCounts map[string]int
10 + operCounts map[string]int
11 + portStatuses []topologyDevicePortStatus
12 + portEvidence map[int]*topologyDevicePortEvidence
13 +}
14 +
15 +type deviceInterfaceSummaryBuilder struct {
16 + interfaces []Interface
17 + attachments []Attachment
18 + adjacencies []Adjacency
19 + deviceByID map[string]Device
20 + ifIndexByDeviceName map[string]int
21 + bridgeLinks []bridgeBridgeLinkRecord
22 + reporterAliases map[string][]string
23 + collectors map[string]*deviceInterfaceCollector
24 + managedAliasOwners map[string]map[string]struct{}
25 +}
26 +
27 +func buildTopologyDeviceInterfaceSummaries(
28 + interfaces []Interface,
29 + attachments []Attachment,
30 + adjacencies []Adjacency,
31 + deviceByID map[string]Device,
32 + ifIndexByDeviceName map[string]int,
33 + bridgeLinks []bridgeBridgeLinkRecord,
34 + reporterAliases map[string][]string,
35 +) map[string]topologyDeviceInterfaceSummary {
36 + return newDeviceInterfaceSummaryBuilder(
37 + interfaces,
38 + attachments,
39 + adjacencies,
40 + deviceByID,
41 + ifIndexByDeviceName,
42 + bridgeLinks,
43 + reporterAliases,
44 + ).build()
45 +}
46 +
47 +func newDeviceInterfaceSummaryBuilder(
48 + interfaces []Interface,
49 + attachments []Attachment,
50 + adjacencies []Adjacency,
51 + deviceByID map[string]Device,
52 + ifIndexByDeviceName map[string]int,
53 + bridgeLinks []bridgeBridgeLinkRecord,
54 + reporterAliases map[string][]string,
55 +) *deviceInterfaceSummaryBuilder {
56 + return &deviceInterfaceSummaryBuilder{
57 + interfaces: interfaces,
58 + attachments: attachments,
59 + adjacencies: adjacencies,
60 + deviceByID: deviceByID,
61 + ifIndexByDeviceName: ifIndexByDeviceName,
62 + bridgeLinks: bridgeLinks,
63 + reporterAliases: reporterAliases,
64 + collectors: make(map[string]*deviceInterfaceCollector),
65 + }
66 +}
67 +
68 +func (b *deviceInterfaceSummaryBuilder) build() map[string]topologyDeviceInterfaceSummary {
69 + if len(b.interfaces) == 0 {
70 + return nil
71 + }
72 +
73 + b.collectInterfaces()
74 + if len(b.collectors) == 0 {
75 + return nil
76 + }
77 +
78 + b.managedAliasOwners = buildFDBAliasOwnerMap(b.reporterAliases)
79 + b.collectFDBAttachments()
80 + b.collectAdjacencyEvidence()
81 + b.collectBridgeLinkEvidence()
82 +
83 + return b.buildSummaries()
84 +}
src/go/pkg/topology/engine/topology_adapter_device_summary_collect.go new
+189
@@ -0,0 +1,189 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 +)
9 +
10 +func (b *deviceInterfaceSummaryBuilder) collectInterfaces() {
11 + for _, iface := range b.interfaces {
12 + deviceID := strings.TrimSpace(iface.DeviceID)
13 + if deviceID == "" || iface.IfIndex <= 0 {
14 + continue
15 + }
16 + col := b.collectors[deviceID]
17 + if col == nil {
18 + col = &deviceInterfaceCollector{
19 + ifIndexes: make(map[string]struct{}),
20 + ifNames: make(map[string]struct{}),
21 + ifTypes: make(map[string]int),
22 + adminCounts: make(map[string]int),
23 + operCounts: make(map[string]int),
24 + portEvidence: make(map[int]*topologyDevicePortEvidence),
25 + }
26 + b.collectors[deviceID] = col
27 + }
28 +
29 + ifIndex := strconv.Itoa(iface.IfIndex)
30 + col.ifIndexes[ifIndex] = struct{}{}
31 + if ifName := strings.TrimSpace(iface.IfName); ifName != "" {
32 + col.ifNames[ifName] = struct{}{}
33 + }
34 +
35 + admin := strings.TrimSpace(iface.Labels["admin_status"])
36 + oper := strings.TrimSpace(iface.Labels["oper_status"])
37 + ifType := strings.TrimSpace(iface.Labels["if_type"])
38 + ifAlias := strings.TrimSpace(iface.Labels["if_alias"])
39 + ifDescr := strings.TrimSpace(iface.IfDescr)
40 + if ifDescr == "" {
41 + ifDescr = strings.TrimSpace(iface.IfName)
42 + }
43 + speedBps := parseTopologyLabelInt64(iface.Labels["speed_bps"])
44 + lastChange := parseTopologyLabelInt64(iface.Labels["last_change"])
45 + duplex := normalizeTopologyDuplex(iface.Labels["duplex"])
46 + mac := normalizeMAC(iface.MAC)
47 + if mac == "" {
48 + mac = normalizeMAC(iface.Labels["mac"])
49 + }
50 + if ifType != "" {
51 + col.ifTypes[ifType]++
52 + }
53 + if admin != "" {
54 + col.adminCounts[admin]++
55 + }
56 + if oper != "" {
57 + col.operCounts[oper]++
58 + }
59 +
60 + col.portStatuses = append(col.portStatuses, topologyDevicePortStatus{
61 + IfIndex: iface.IfIndex,
62 + IfName: strings.TrimSpace(iface.IfName),
63 + IfDescr: ifDescr,
64 + IfAlias: ifAlias,
65 + MAC: mac,
66 + SpeedBps: speedBps,
67 + LastChange: lastChange,
68 + Duplex: duplex,
69 + InterfaceType: ifType,
70 + AdminStatus: admin,
71 + OperStatus: oper,
72 + LinkMode: "unknown",
73 + ModeConfidence: "low",
74 + TopologyRole: "unknown",
75 + RoleConfidence: "low",
76 + })
77 +
78 + if isTopologyLAGInterfaceType(ifType) {
79 + evidence := ensureTopologyPortEvidence(col.portEvidence, iface.IfIndex)
80 + evidence.isLAG = true
81 + }
82 + }
83 +}
84 +
85 +func (b *deviceInterfaceSummaryBuilder) collectFDBAttachments() {
86 + for _, attachment := range b.attachments {
87 + deviceID := strings.TrimSpace(attachment.DeviceID)
88 + if deviceID == "" || attachment.IfIndex <= 0 {
89 + continue
90 + }
91 + col := b.collectors[deviceID]
92 + if col == nil {
93 + continue
94 + }
95 + if !strings.EqualFold(strings.TrimSpace(attachment.Method), "fdb") {
96 + continue
97 + }
98 + fdbStatus := strings.ToLower(strings.TrimSpace(attachment.Labels["fdb_status"]))
99 + if fdbStatus == "ignored" {
100 + continue
101 + }
102 +
103 + evidence := ensureTopologyPortEvidence(col.portEvidence, attachment.IfIndex)
104 + evidence.hasFDB = true
105 + endpointID := normalizeFDBEndpointID(attachment.EndpointID)
106 + if endpointID == "" {
107 + endpointID = strings.TrimSpace(attachment.EndpointID)
108 + }
109 + if endpointID != "" {
110 + evidence.fdbEndpointIDs[endpointID] = struct{}{}
111 + if aliasOwners, ok := b.managedAliasOwners[endpointID]; ok {
112 + for aliasOwnerID := range aliasOwners {
113 + if !strings.EqualFold(strings.TrimSpace(aliasOwnerID), deviceID) {
114 + evidence.hasFDBManagedAlias = true
115 + break
116 + }
117 + }
118 + }
119 + }
120 + vlanID := normalizeTopologyVLANID(firstNonEmpty(attachment.Labels["vlan_id"], attachment.Labels["vlan"]))
121 + if vlanID != "" {
122 + evidence.vlanIDs[vlanID] = struct{}{}
123 + if vlanName := strings.TrimSpace(attachment.Labels["vlan_name"]); vlanName != "" {
124 + if _, exists := evidence.vlanNames[vlanID]; !exists {
125 + evidence.vlanNames[vlanID] = vlanName
126 + }
127 + }
128 + }
129 + }
130 +}
131 +
132 +func (b *deviceInterfaceSummaryBuilder) collectAdjacencyEvidence() {
133 + for _, adj := range b.adjacencies {
134 + deviceID := strings.TrimSpace(adj.SourceID)
135 + if deviceID == "" {
136 + continue
137 + }
138 + col := b.collectors[deviceID]
139 + if col == nil {
140 + continue
141 + }
142 +
143 + ifIndex := resolveAdjacencySourceIfIndex(adj, b.ifIndexByDeviceName)
144 + if ifIndex <= 0 {
145 + continue
146 + }
147 + evidence := ensureTopologyPortEvidence(col.portEvidence, ifIndex)
148 + protocol := strings.ToLower(strings.TrimSpace(adj.Protocol))
149 + switch protocol {
150 + case "stp":
151 + evidence.hasSTP = true
152 + vlanID := normalizeTopologyVLANID(firstNonEmpty(adj.Labels["vlan_id"], adj.Labels["vlan"]))
153 + if vlanID != "" {
154 + evidence.vlanIDs[vlanID] = struct{}{}
155 + if vlanName := strings.TrimSpace(adj.Labels["vlan_name"]); vlanName != "" {
156 + if _, exists := evidence.vlanNames[vlanID]; !exists {
157 + evidence.vlanNames[vlanID] = vlanName
158 + }
159 + }
160 + }
161 + if state := normalizeTopologySTPState(adj.Labels["stp_state"]); state != "" {
162 + evidence.stpStates[state] = struct{}{}
163 + }
164 + case "lldp", "cdp":
165 + evidence.hasPeer = true
166 + neighbor := buildTopologyPortNeighborStatus(protocol, adj, b.deviceByID)
167 + if key := topologyPortNeighborStatusKey(neighbor); key != "" {
168 + evidence.neighbors[key] = neighbor
169 + }
170 + }
171 + }
172 +}
173 +
174 +func (b *deviceInterfaceSummaryBuilder) collectBridgeLinkEvidence() {
175 + for _, link := range b.bridgeLinks {
176 + for _, port := range []bridgePortRef{link.designatedPort, link.port} {
177 + deviceID := strings.TrimSpace(port.deviceID)
178 + if deviceID == "" || port.ifIndex <= 0 {
179 + continue
180 + }
181 + col := b.collectors[deviceID]
182 + if col == nil {
183 + continue
184 + }
185 + evidence := ensureTopologyPortEvidence(col.portEvidence, port.ifIndex)
186 + evidence.hasBridgeLink = true
187 + }
188 + }
189 +}
src/go/pkg/topology/engine/topology_adapter_device_summary_neighbors.go new
+131
@@ -0,0 +1,131 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func topologyNeighborCapabilitiesFromLabels(labels map[string]string) []string {
11 + if len(labels) == 0 {
12 + return nil
13 + }
14 + seen := make(map[string]struct{})
15 + for _, key := range []string{"capabilities_enabled", "capabilities_supported", "capabilities"} {
16 + for _, capability := range labelsCSVToSlice(labels, key) {
17 + capability = strings.TrimSpace(capability)
18 + if capability == "" {
19 + continue
20 + }
21 + seen[capability] = struct{}{}
22 + }
23 + }
24 + return sortedTopologySet(seen)
25 +}
26 +
27 +func buildTopologyPortNeighborStatus(protocol string, adj Adjacency, deviceByID map[string]Device) topologyPortNeighborStatus {
28 + protocol = strings.ToLower(strings.TrimSpace(protocol))
29 + targetID := strings.TrimSpace(adj.TargetID)
30 +
31 + neighbor := topologyPortNeighborStatus{
32 + Protocol: protocol,
33 + RemoteDevice: targetID,
34 + RemotePort: strings.TrimSpace(adj.TargetPort),
35 + }
36 + if targetID == "" {
37 + return neighbor
38 + }
39 +
40 + remote, ok := deviceByID[targetID]
41 + if !ok {
42 + if protocol == "cdp" {
43 + neighbor.RemoteIP = strings.TrimSpace(adj.Labels["remote_address_raw"])
44 + }
45 + return neighbor
46 + }
47 +
48 + if remoteName := strings.TrimSpace(remote.Hostname); remoteName != "" {
49 + neighbor.RemoteDevice = remoteName
50 + }
51 + neighbor.RemoteIP = firstAddress(remote.Addresses)
52 + neighbor.RemoteChassisID = strings.TrimSpace(remote.ChassisID)
53 + neighbor.RemoteCapabilities = topologyNeighborCapabilitiesFromLabels(remote.Labels)
54 + if neighbor.RemoteIP == "" && protocol == "cdp" {
55 + neighbor.RemoteIP = strings.TrimSpace(adj.Labels["remote_address_raw"])
56 + }
57 + return neighbor
58 +}
59 +
60 +func topologyPortNeighborStatusKey(status topologyPortNeighborStatus) string {
61 + protocol := strings.ToLower(strings.TrimSpace(status.Protocol))
62 + remoteDevice := strings.ToLower(strings.TrimSpace(status.RemoteDevice))
63 + remotePort := strings.ToLower(strings.TrimSpace(status.RemotePort))
64 + remoteIP := strings.ToLower(strings.TrimSpace(status.RemoteIP))
65 + remoteChassisID := normalizeMAC(status.RemoteChassisID)
66 + if remoteChassisID == "" {
67 + remoteChassisID = strings.ToLower(strings.TrimSpace(status.RemoteChassisID))
68 + }
69 +
70 + if protocol == "" && remoteDevice == "" && remotePort == "" && remoteIP == "" && remoteChassisID == "" {
71 + return ""
72 + }
73 + return strings.Join([]string{
74 + protocol,
75 + remoteDevice,
76 + remotePort,
77 + remoteIP,
78 + remoteChassisID,
79 + }, keySep)
80 +}
81 +
82 +func sortedTopologyPortNeighbors(neighbors map[string]topologyPortNeighborStatus) []topologyPortNeighborStatus {
83 + if len(neighbors) == 0 {
84 + return nil
85 + }
86 + out := make([]topologyPortNeighborStatus, 0, len(neighbors))
87 + for _, neighbor := range neighbors {
88 + neighbor.Protocol = strings.ToLower(strings.TrimSpace(neighbor.Protocol))
89 + neighbor.RemoteDevice = strings.TrimSpace(neighbor.RemoteDevice)
90 + neighbor.RemotePort = strings.TrimSpace(neighbor.RemotePort)
91 + neighbor.RemoteIP = strings.TrimSpace(neighbor.RemoteIP)
92 + neighbor.RemoteChassisID = strings.TrimSpace(neighbor.RemoteChassisID)
93 + neighbor.RemoteCapabilities = uniqueTopologyStrings(neighbor.RemoteCapabilities)
94 + if topologyPortNeighborStatusKey(neighbor) == "" {
95 + continue
96 + }
97 + out = append(out, neighbor)
98 + }
99 + sort.Slice(out, func(i, j int) bool {
100 + left, right := out[i], out[j]
101 + if left.Protocol != right.Protocol {
102 + return left.Protocol < right.Protocol
103 + }
104 + if left.RemoteDevice != right.RemoteDevice {
105 + return left.RemoteDevice < right.RemoteDevice
106 + }
107 + if left.RemotePort != right.RemotePort {
108 + return left.RemotePort < right.RemotePort
109 + }
110 + if left.RemoteIP != right.RemoteIP {
111 + return left.RemoteIP < right.RemoteIP
112 + }
113 + return left.RemoteChassisID < right.RemoteChassisID
114 + })
115 + if len(out) == 0 {
116 + return nil
117 + }
118 + return out
119 +}
120 +
121 +func topologyPortNeighborStatusToAttributes(status topologyPortNeighborStatus) map[string]any {
122 + attrs := map[string]any{
123 + "protocol": strings.ToLower(strings.TrimSpace(status.Protocol)),
124 + "remote_device": strings.TrimSpace(status.RemoteDevice),
125 + "remote_port": strings.TrimSpace(status.RemotePort),
126 + "remote_ip": strings.TrimSpace(status.RemoteIP),
127 + "remote_chassis_id": strings.TrimSpace(status.RemoteChassisID),
128 + "remote_capabilities": uniqueTopologyStrings(status.RemoteCapabilities),
129 + }
130 + return pruneTopologyAttributes(attrs)
131 +}
src/go/pkg/topology/engine/topology_adapter_device_summary_port_evidence.go new
+236
@@ -0,0 +1,236 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func topologyPortVLANAttributes(vlanIDs []string, vlanNames map[string]string, linkMode string) []map[string]any {
11 + if len(vlanIDs) == 0 {
12 + return nil
13 + }
14 + tagged := len(vlanIDs) != 1 || !strings.EqualFold(strings.TrimSpace(linkMode), "access")
15 + out := make([]map[string]any, 0, len(vlanIDs))
16 + for _, vlanID := range vlanIDs {
17 + vlanID = normalizeTopologyVLANID(vlanID)
18 + if vlanID == "" {
19 + continue
20 + }
21 + entry := map[string]any{
22 + "vlan_id": vlanID,
23 + "tagged": tagged,
24 + }
25 + if vlanName := strings.TrimSpace(vlanNames[vlanID]); vlanName != "" {
26 + entry["vlan_name"] = vlanName
27 + }
28 + out = append(out, entry)
29 + }
30 + if len(out) == 0 {
31 + return nil
32 + }
33 + return out
34 +}
35 +
36 +func normalizeTopologySTPState(value string) string {
37 + value = strings.ToLower(strings.TrimSpace(value))
38 + switch value {
39 + case "", "0":
40 + return ""
41 + case "1", "disabled":
42 + return "disabled"
43 + case "2", "blocking", "discarding":
44 + return "blocking"
45 + case "3", "listening":
46 + return "listening"
47 + case "4", "learning":
48 + return "learning"
49 + case "5", "forwarding":
50 + return "forwarding"
51 + case "6", "broken":
52 + return "broken"
53 + default:
54 + return value
55 + }
56 +}
57 +
58 +func summarizeTopologySTPState(states map[string]struct{}) string {
59 + if len(states) == 0 {
60 + return ""
61 + }
62 +
63 + rank := map[string]int{
64 + "forwarding": 1,
65 + "learning": 2,
66 + "listening": 3,
67 + "blocking": 4,
68 + "disabled": 5,
69 + "broken": 6,
70 + }
71 + selected := ""
72 + selectedRank := -1
73 + for state := range states {
74 + state = normalizeTopologySTPState(state)
75 + if state == "" {
76 + continue
77 + }
78 + currentRank, ok := rank[state]
79 + if !ok {
80 + currentRank = 7
81 + }
82 + if currentRank > selectedRank {
83 + selected = state
84 + selectedRank = currentRank
85 + }
86 + }
87 + return selected
88 +}
89 +
90 +func ensureTopologyPortEvidence(
91 + evidenceByIfIndex map[int]*topologyDevicePortEvidence,
92 + ifIndex int,
93 +) *topologyDevicePortEvidence {
94 + if ifIndex <= 0 {
95 + return nil
96 + }
97 + evidence := evidenceByIfIndex[ifIndex]
98 + if evidence == nil {
99 + evidence = &topologyDevicePortEvidence{
100 + vlanIDs: make(map[string]struct{}),
101 + vlanNames: make(map[string]string),
102 + fdbEndpointIDs: make(map[string]struct{}),
103 + stpStates: make(map[string]struct{}),
104 + neighbors: make(map[string]topologyPortNeighborStatus),
105 + }
106 + evidenceByIfIndex[ifIndex] = evidence
107 + }
108 + return evidence
109 +}
110 +
111 +func resolveAdjacencySourceIfIndex(adj Adjacency, ifIndexByDeviceName map[string]int) int {
112 + ifIndex := 0
113 + if ifName := strings.TrimSpace(adj.SourcePort); ifName != "" {
114 + ifIndex = resolveIfIndexByPortName(adj.SourceID, ifName, ifIndexByDeviceName)
115 + }
116 + return ifIndex
117 +}
118 +
119 +func classifyTopologyPortLinkMode(evidence *topologyDevicePortEvidence) (mode string, confidence string, sources []string, vlans []string) {
120 + mode = "unknown"
121 + confidence = "low"
122 + if evidence == nil {
123 + return mode, confidence, nil, nil
124 + }
125 +
126 + if len(evidence.vlanIDs) > 0 {
127 + vlans = sortedTopologySet(evidence.vlanIDs)
128 + }
129 + if evidence.hasFDB {
130 + sources = append(sources, "fdb")
131 + }
132 + if evidence.hasSTP {
133 + sources = append(sources, "stp")
134 + }
135 + if evidence.hasPeer {
136 + sources = append(sources, "peer_link")
137 + }
138 +
139 + switch vlanCount := len(evidence.vlanIDs); {
140 + case vlanCount >= 2:
141 + mode = "trunk"
142 + if evidence.hasFDB && evidence.hasSTP {
143 + confidence = "high"
144 + } else {
145 + confidence = "medium"
146 + }
147 + case vlanCount == 1 && !evidence.hasPeer:
148 + mode = "access"
149 + confidence = "medium"
150 + default:
151 + mode = "unknown"
152 + confidence = "low"
153 + }
154 + return mode, confidence, sources, vlans
155 +}
156 +
157 +func classifyTopologyPortRole(evidence *topologyDevicePortEvidence) (role string, confidence string, sources []string) {
158 + role = "unknown"
159 + confidence = "low"
160 + if evidence == nil {
161 + return role, confidence, nil
162 + }
163 +
164 + if evidence.hasPeer {
165 + sources = append(sources, "peer_link")
166 + }
167 + if evidence.hasBridgeLink {
168 + sources = append(sources, "bridge_link")
169 + }
170 + if evidence.hasSTP {
171 + sources = append(sources, "stp")
172 + }
173 + if evidence.hasFDB {
174 + sources = append(sources, "fdb")
175 + }
176 + if evidence.hasFDBManagedAlias {
177 + sources = append(sources, "fdb_managed_alias")
178 + }
179 + if evidence.isLAG {
180 + sources = append(sources, "lag_interface")
181 + }
182 +
183 + switch {
184 + case evidence.hasPeer || evidence.hasBridgeLink:
185 + role = "switch_facing"
186 + confidence = "high"
187 + case evidence.hasSTP && evidence.hasFDBManagedAlias:
188 + role = "switch_facing"
189 + confidence = "medium"
190 + case evidence.isLAG && evidence.hasFDB:
191 + role = "switch_facing"
192 + confidence = "medium"
193 + case evidence.hasFDB && len(evidence.fdbEndpointIDs) == 1 && !evidence.hasSTP:
194 + role = "host_facing"
195 + confidence = "medium"
196 + case evidence.hasFDB && !evidence.hasSTP:
197 + role = "host_candidate"
198 + confidence = "low"
199 + default:
200 + role = "unknown"
201 + confidence = "low"
202 + }
203 + return role, confidence, sources
204 +}
205 +
206 +func isTopologyLAGInterfaceType(ifType string) bool {
207 + switch strings.ToLower(strings.TrimSpace(ifType)) {
208 + case "ieee8023adlag", "lag", "bond":
209 + return true
210 + default:
211 + return false
212 + }
213 +}
214 +
215 +func intCountMapToAny(in map[string]int) map[string]any {
216 + if len(in) == 0 {
217 + return nil
218 + }
219 + keys := make([]string, 0, len(in))
220 + for key := range in {
221 + key = strings.TrimSpace(key)
222 + if key == "" {
223 + continue
224 + }
225 + keys = append(keys, key)
226 + }
227 + if len(keys) == 0 {
228 + return nil
229 + }
230 + sort.Strings(keys)
231 + out := make(map[string]any, len(keys))
232 + for _, key := range keys {
233 + out[key] = in[key]
234 + }
235 + return out
236 +}
src/go/pkg/topology/engine/topology_adapter_device_summary_render.go new
+65
@@ -0,0 +1,65 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "strings"
6 +
7 +func buildTopologyDevicePortStatusAttributes(st topologyDevicePortStatus) map[string]any {
8 + portStatus := map[string]any{
9 + "if_index": st.IfIndex,
10 + "if_name": strings.TrimSpace(st.IfName),
11 + "if_descr": strings.TrimSpace(st.IfDescr),
12 + "if_alias": strings.TrimSpace(st.IfAlias),
13 + "mac": strings.TrimSpace(st.MAC),
14 + "duplex": strings.TrimSpace(st.Duplex),
15 + "link_mode": st.LinkMode,
16 + "link_mode_confidence": st.ModeConfidence,
17 + "topology_role": st.TopologyRole,
18 + "topology_role_confidence": st.RoleConfidence,
19 + }
20 + if st.SpeedBps > 0 {
21 + portStatus["speed"] = st.SpeedBps
22 + }
23 + if st.LastChange > 0 {
24 + portStatus["last_change"] = st.LastChange
25 + }
26 + if len(st.ModeSources) > 0 {
27 + portStatus["link_mode_sources"] = st.ModeSources
28 + }
29 + if len(st.RoleSources) > 0 {
30 + portStatus["topology_role_sources"] = st.RoleSources
31 + }
32 + if len(st.VLANIDs) > 0 {
33 + portStatus["vlan_ids"] = st.VLANIDs
34 + }
35 + if len(st.VLANs) > 0 {
36 + portStatus["vlans"] = st.VLANs
37 + }
38 + if st.FDBMACCount > 0 {
39 + portStatus["fdb_mac_count"] = st.FDBMACCount
40 + }
41 + if st.STPState != "" {
42 + portStatus["stp_state"] = st.STPState
43 + }
44 + if len(st.Neighbors) > 0 {
45 + neighbors := make([]map[string]any, 0, len(st.Neighbors))
46 + for _, neighbor := range st.Neighbors {
47 + if attrs := topologyPortNeighborStatusToAttributes(neighbor); len(attrs) > 0 {
48 + neighbors = append(neighbors, attrs)
49 + }
50 + }
51 + if len(neighbors) > 0 {
52 + portStatus["neighbors"] = neighbors
53 + }
54 + }
55 + if st.AdminStatus != "" {
56 + portStatus["admin_status"] = st.AdminStatus
57 + }
58 + if st.OperStatus != "" {
59 + portStatus["oper_status"] = st.OperStatus
60 + }
61 + if st.InterfaceType != "" {
62 + portStatus["if_type"] = st.InterfaceType
63 + }
64 + return pruneTopologyAttributes(portStatus)
65 +}
src/go/pkg/topology/engine/topology_adapter_device_summary_summaries.go new
+97
@@ -0,0 +1,97 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func (b *deviceInterfaceSummaryBuilder) buildSummaries() map[string]topologyDeviceInterfaceSummary {
11 + out := make(map[string]topologyDeviceInterfaceSummary, len(b.collectors))
12 + for deviceID, col := range b.collectors {
13 + sort.Slice(col.portStatuses, func(i, j int) bool {
14 + left, right := col.portStatuses[i], col.portStatuses[j]
15 + if left.IfIndex != right.IfIndex {
16 + return left.IfIndex < right.IfIndex
17 + }
18 + return left.IfName < right.IfName
19 + })
20 +
21 + modeCounts := make(map[string]int)
22 + roleCounts := make(map[string]int)
23 + deviceVLANIDs := make(map[string]struct{})
24 + portsUp := 0
25 + portsDown := 0
26 + portsAdminDown := 0
27 + totalBandwidthBps := int64(0)
28 + fdbTotalMACs := 0
29 + lldpNeighborCount := 0
30 + cdpNeighborCount := 0
31 + portStatuses := make([]map[string]any, 0, len(col.portStatuses))
32 + for _, st := range col.portStatuses {
33 + evidence := col.portEvidence[st.IfIndex]
34 + mode, confidence, sources, vlans := classifyTopologyPortLinkMode(evidence)
35 + role, roleConfidence, roleSources := classifyTopologyPortRole(evidence)
36 + st.LinkMode = mode
37 + st.ModeConfidence = confidence
38 + st.ModeSources = sources
39 + st.VLANIDs = vlans
40 + st.TopologyRole = role
41 + st.RoleConfidence = roleConfidence
42 + st.RoleSources = roleSources
43 + if evidence != nil {
44 + st.FDBMACCount = len(evidence.fdbEndpointIDs)
45 + st.STPState = summarizeTopologySTPState(evidence.stpStates)
46 + st.VLANs = topologyPortVLANAttributes(st.VLANIDs, evidence.vlanNames, st.LinkMode)
47 + st.Neighbors = sortedTopologyPortNeighbors(evidence.neighbors)
48 + }
49 +
50 + for _, vlanID := range st.VLANIDs {
51 + deviceVLANIDs[vlanID] = struct{}{}
52 + }
53 + if strings.EqualFold(strings.TrimSpace(st.OperStatus), "up") {
54 + portsUp++
55 + totalBandwidthBps = safeTopologyInt64Add(totalBandwidthBps, st.SpeedBps)
56 + } else if strings.EqualFold(strings.TrimSpace(st.OperStatus), "down") || strings.EqualFold(strings.TrimSpace(st.OperStatus), "lowerlayerdown") {
57 + portsDown++
58 + }
59 + if strings.EqualFold(strings.TrimSpace(st.AdminStatus), "down") || strings.EqualFold(strings.TrimSpace(st.AdminStatus), "administrativelydown") {
60 + portsAdminDown++
61 + }
62 + fdbTotalMACs += st.FDBMACCount
63 + for _, neighbor := range st.Neighbors {
64 + switch strings.ToLower(strings.TrimSpace(neighbor.Protocol)) {
65 + case "lldp":
66 + lldpNeighborCount++
67 + case "cdp":
68 + cdpNeighborCount++
69 + }
70 + }
71 +
72 + modeCounts[mode]++
73 + roleCounts[role]++
74 + portStatuses = append(portStatuses, buildTopologyDevicePortStatusAttributes(st))
75 + }
76 +
77 + out[deviceID] = topologyDeviceInterfaceSummary{
78 + portsTotal: len(col.ifIndexes),
79 + ifIndexes: sortedTopologySet(col.ifIndexes),
80 + ifNames: sortedTopologySet(col.ifNames),
81 + adminStatusCount: intCountMapToAny(col.adminCounts),
82 + operStatusCount: intCountMapToAny(col.operCounts),
83 + linkModeCount: intCountMapToAny(modeCounts),
84 + roleCount: intCountMapToAny(roleCounts),
85 + portsUp: portsUp,
86 + portsDown: portsDown,
87 + portsAdminDown: portsAdminDown,
88 + totalBandwidthBps: totalBandwidthBps,
89 + fdbTotalMACs: fdbTotalMACs,
90 + vlanCount: len(deviceVLANIDs),
91 + lldpNeighborCount: lldpNeighborCount,
92 + cdpNeighborCount: cdpNeighborCount,
93 + portStatuses: portStatuses,
94 + }
95 + }
96 + return out
97 +}
src/go/pkg/topology/engine/topology_adapter_device_summary_test.go new
+127
@@ -0,0 +1,127 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestSortedTopologyPortNeighbors_NormalizesAndOrders(t *testing.T) {
12 + neighbors := map[string]topologyPortNeighborStatus{
13 + "b": {
14 + Protocol: " CDP ",
15 + RemoteDevice: " switch-b ",
16 + RemotePort: " Gi0/2 ",
17 + RemoteIP: " 10.0.0.2 ",
18 + RemoteChassisID: "00:11:22:33:44:55",
19 + RemoteCapabilities: []string{"router", "bridge", "router"},
20 + },
21 + "a": {
22 + Protocol: " lldp ",
23 + RemoteDevice: " switch-a ",
24 + RemotePort: " Gi0/1 ",
25 + RemoteIP: " 10.0.0.1 ",
26 + RemoteChassisID: " aa:bb:cc:dd:ee:ff ",
27 + RemoteCapabilities: []string{"bridge", "router", "bridge"},
28 + },
29 + "empty": {},
30 + }
31 +
32 + sorted := sortedTopologyPortNeighbors(neighbors)
33 + require.Len(t, sorted, 2)
34 +
35 + require.Equal(t, "cdp", sorted[0].Protocol)
36 + require.Equal(t, "switch-b", sorted[0].RemoteDevice)
37 + require.Equal(t, "Gi0/2", sorted[0].RemotePort)
38 + require.Equal(t, "10.0.0.2", sorted[0].RemoteIP)
39 + require.Equal(t, "00:11:22:33:44:55", sorted[0].RemoteChassisID)
40 + require.Equal(t, []string{"bridge", "router"}, sorted[0].RemoteCapabilities)
41 +
42 + require.Equal(t, "lldp", sorted[1].Protocol)
43 + require.Equal(t, "switch-a", sorted[1].RemoteDevice)
44 + require.Equal(t, "Gi0/1", sorted[1].RemotePort)
45 + require.Equal(t, "10.0.0.1", sorted[1].RemoteIP)
46 + require.Equal(t, "aa:bb:cc:dd:ee:ff", sorted[1].RemoteChassisID)
47 + require.Equal(t, []string{"bridge", "router"}, sorted[1].RemoteCapabilities)
48 +}
49 +
50 +func TestBuildTopologyDevicePortStatusAttributes_RendersOptionalFields(t *testing.T) {
51 + status := topologyDevicePortStatus{
52 + IfIndex: 7,
53 + IfName: "Gi0/7",
54 + IfDescr: "Uplink",
55 + IfAlias: "core",
56 + MAC: "00:11:22:33:44:55",
57 + SpeedBps: 1000000000,
58 + LastChange: 12345,
59 + Duplex: "full",
60 + InterfaceType: "ethernetCsmacd",
61 + AdminStatus: "up",
62 + OperStatus: "up",
63 + LinkMode: "trunk",
64 + ModeConfidence: "high",
65 + ModeSources: []string{"fdb", "stp"},
66 + VLANIDs: []string{"100", "200"},
67 + VLANs: []map[string]any{
68 + {"vlan_id": "100", "tagged": true},
69 + {"vlan_id": "200", "tagged": true, "vlan_name": "servers"},
70 + },
71 + TopologyRole: "switch_facing",
72 + RoleConfidence: "high",
73 + RoleSources: []string{"peer_link", "bridge_link"},
74 + FDBMACCount: 3,
75 + STPState: "forwarding",
76 + Neighbors: []topologyPortNeighborStatus{
77 + {
78 + Protocol: "lldp",
79 + RemoteDevice: "switch-b",
80 + RemotePort: "Gi0/1",
81 + RemoteIP: "10.0.0.2",
82 + RemoteChassisID: "aa:bb:cc:dd:ee:ff",
83 + RemoteCapabilities: []string{"bridge", "router"},
84 + },
85 + },
86 + }
87 +
88 + attrs := buildTopologyDevicePortStatusAttributes(status)
89 + require.Equal(t, 7, attrs["if_index"])
90 + require.Equal(t, "Gi0/7", attrs["if_name"])
91 + require.Equal(t, "Uplink", attrs["if_descr"])
92 + require.Equal(t, "core", attrs["if_alias"])
93 + require.Equal(t, "00:11:22:33:44:55", attrs["mac"])
94 + require.Equal(t, int64(1000000000), attrs["speed"])
95 + require.Equal(t, int64(12345), attrs["last_change"])
96 + require.Equal(t, "full", attrs["duplex"])
97 + require.Equal(t, "trunk", attrs["link_mode"])
98 + require.Equal(t, "high", attrs["link_mode_confidence"])
99 + require.Equal(t, []string{"fdb", "stp"}, attrs["link_mode_sources"])
100 + require.Equal(t, []string{"100", "200"}, attrs["vlan_ids"])
101 + require.Equal(t, "switch_facing", attrs["topology_role"])
102 + require.Equal(t, "high", attrs["topology_role_confidence"])
103 + require.Equal(t, []string{"peer_link", "bridge_link"}, attrs["topology_role_sources"])
104 + require.Equal(t, 3, attrs["fdb_mac_count"])
105 + require.Equal(t, "forwarding", attrs["stp_state"])
106 + require.Equal(t, "up", attrs["admin_status"])
107 + require.Equal(t, "up", attrs["oper_status"])
108 + require.Equal(t, "ethernetCsmacd", attrs["if_type"])
109 +
110 + neighbors, ok := attrs["neighbors"].([]map[string]any)
111 + require.True(t, ok)
112 + require.Len(t, neighbors, 1)
113 + require.Equal(t, "lldp", neighbors[0]["protocol"])
114 + require.Equal(t, "switch-b", neighbors[0]["remote_device"])
115 + require.Equal(t, "Gi0/1", neighbors[0]["remote_port"])
116 + require.Equal(t, "10.0.0.2", neighbors[0]["remote_ip"])
117 + require.Equal(t, "aa:bb:cc:dd:ee:ff", neighbors[0]["remote_chassis_id"])
118 + require.Equal(t, []string{"bridge", "router"}, neighbors[0]["remote_capabilities"])
119 +
120 + vlans, ok := attrs["vlans"].([]map[string]any)
121 + require.True(t, ok)
122 + require.Len(t, vlans, 2)
123 + require.Equal(t, "100", vlans[0]["vlan_id"])
124 + require.Equal(t, true, vlans[0]["tagged"])
125 + require.Equal(t, "200", vlans[1]["vlan_id"])
126 + require.Equal(t, "servers", vlans[1]["vlan_name"])
127 +}
src/go/pkg/topology/engine/topology_adapter_device_types.go new
+89
@@ -0,0 +1,89 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "strings"
6 +
7 +var deviceCategoryToActorType = map[string]string{
8 + "router": "router",
9 + "gateway": "router",
10 + "layer 3 switch": "router",
11 + "voip gateway": "router",
12 + "switch": "switch",
13 + "bridge": "switch",
14 + "hub": "switch",
15 + "sanswitch": "switch",
16 + "sanbridge": "switch",
17 + "bridge/extender": "switch",
18 + "firewall": "firewall",
19 + "security": "firewall",
20 + "access point": "access_point",
21 + "wireless": "access_point",
22 + "wireless lan controller": "access_point",
23 + "extender": "access_point",
24 + "radio": "access_point",
25 + "server": "server",
26 + "file server": "server",
27 + "application": "server",
28 + "desktop": "server",
29 + "blade system": "server",
30 + "storage": "storage",
31 + "nas": "storage",
32 + "self-contained nas": "storage",
33 + "nas head": "storage",
34 + "tape library": "storage",
35 + "load balancer": "load_balancer",
36 + "wan accelerator": "load_balancer",
37 + "web caching": "load_balancer",
38 + "proxy server": "load_balancer",
39 + "content": "load_balancer",
40 + "printer": "printer",
41 + "ip phone": "phone",
42 + "voip": "phone",
43 + "gsm": "phone",
44 + "mobile": "phone",
45 + "ups": "ups",
46 + "pdu": "ups",
47 + "power": "ups",
48 + "video": "camera",
49 + "media": "camera",
50 + "media exchange": "camera",
51 + "sensor": "camera",
52 + "other": "device",
53 + "network device": "device",
54 + "management": "server",
55 + "management controller": "server",
56 + "dslam": "switch",
57 + "access server": "server",
58 + "pon": "switch",
59 + "console": "server",
60 + "module": "device",
61 + "plc": "device",
62 + "sre module": "server",
63 + "chassis manager": "server",
64 + "snmp managed device": "device",
65 +}
66 +
67 +var deviceActorTypes = func() map[string]struct{} {
68 + s := map[string]struct{}{"device": {}}
69 + for _, v := range deviceCategoryToActorType {
70 + s[v] = struct{}{}
71 + }
72 + return s
73 +}()
74 +
75 +func resolveDeviceActorType(labels map[string]string) string {
76 + cat := strings.TrimSpace(labels["type"])
77 + if cat == "" {
78 + return "device"
79 + }
80 + if at, ok := deviceCategoryToActorType[strings.ToLower(cat)]; ok {
81 + return at
82 + }
83 + return "device"
84 +}
85 +
86 +func IsDeviceActorType(actorType string) bool {
87 + _, ok := deviceActorTypes[strings.ToLower(strings.TrimSpace(actorType))]
88 + return ok
89 +}
src/go/pkg/topology/engine/topology_adapter_devices.go new
+26
@@ -0,0 +1,26 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "github.com/netdata/netdata/go/plugins/pkg/topology"
6 +
7 +func deviceToTopologyActor(
8 + dev Device,
9 + source, layer, localDeviceID string,
10 + ifaceSummary topologyDeviceInterfaceSummary,
11 + reporterAliases []string,
12 +) topology.Actor {
13 + match := buildDeviceActorMatch(dev, reporterAliases)
14 + attrs := buildDeviceActorAttributes(dev, localDeviceID, ifaceSummary, match)
15 + tables := buildDeviceActorTables(ifaceSummary)
16 +
17 + return topology.Actor{
18 + ActorType: resolveDeviceActorType(dev.Labels),
19 + Layer: layer,
20 + Source: source,
21 + Match: match,
22 + Attributes: pruneTopologyAttributes(attrs),
23 + Labels: cloneStringMap(dev.Labels),
24 + Tables: tables,
25 + }
26 +}
src/go/pkg/topology/engine/topology_adapter_devices_test.go new
+44
@@ -0,0 +1,44 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "net/netip"
7 + "testing"
8 +
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestResolveDeviceActorType_MapsAndFallsBack(t *testing.T) {
13 + require.Equal(t, "switch", resolveDeviceActorType(map[string]string{"type": "switch"}))
14 + require.Equal(t, "access_point", resolveDeviceActorType(map[string]string{"type": "Wireless"}))
15 + require.Equal(t, "device", resolveDeviceActorType(map[string]string{"type": "unknown-kind"}))
16 + require.Equal(t, "device", resolveDeviceActorType(nil))
17 + require.True(t, IsDeviceActorType("switch"))
18 + require.True(t, IsDeviceActorType(" access_point "))
19 + require.False(t, IsDeviceActorType("unknown-kind"))
20 +}
21 +
22 +func TestAdjacencySideToEndpoint_FallsBackToRequestedPort(t *testing.T) {
23 + addr := netip.MustParseAddr("10.0.0.1")
24 + dev := Device{
25 + ID: "switch-a",
26 + Hostname: "switch-a",
27 + SysObject: "1.2.3.4",
28 + ChassisID: "00:11:22:33:44:55",
29 + Addresses: []netip.Addr{addr},
30 + }
31 +
32 + endpoint := adjacencySideToEndpoint(dev, "Gi0/9", nil, nil)
33 + require.Equal(t, []string{"00:11:22:33:44:55"}, endpoint.Match.ChassisIDs)
34 + require.Equal(t, []string{"00:11:22:33:44:55"}, endpoint.Match.MacAddresses)
35 + require.Equal(t, []string{"10.0.0.1"}, endpoint.Match.IPAddresses)
36 + _, hasIfIndex := endpoint.Attributes["if_index"]
37 + require.False(t, hasIfIndex)
38 + require.Equal(t, "Gi0/9", endpoint.Attributes["if_name"])
39 + require.Equal(t, "Gi0/9", endpoint.Attributes["port_id"])
40 + require.Equal(t, "switch-a", endpoint.Attributes["sys_name"])
41 + require.Equal(t, "10.0.0.1", endpoint.Attributes["management_ip"])
42 + _, hasDescr := endpoint.Attributes["if_descr"]
43 + require.False(t, hasDescr)
44 +}
src/go/pkg/topology/engine/topology_adapter_display.go new
+287
@@ -0,0 +1,287 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strings"
7 +
8 + "github.com/netdata/netdata/go/plugins/pkg/topology"
9 +)
10 +
11 +type topologyDisplayNameResolver struct {
12 + lookup func(ip string) string
13 + cache map[string]string
14 +}
15 +
16 +type topologyDisplayName struct {
17 + name string
18 + source string
19 +}
20 +
21 +func applyTopologyDisplayNames(actors []topology.Actor, links []topology.Link, lookup func(ip string) string) {
22 + resolver := topologyDisplayNameResolver{
23 + lookup: lookup,
24 + cache: make(map[string]string),
25 + }
26 +
27 + deviceDisplayByID := make(map[string]string, len(actors))
28 + displayByMatchKey := make(map[string]string, len(actors))
29 +
30 + // First pass: materialize display names for non-segment actors so segment naming can reuse them.
31 + for i := range actors {
32 + if actors[i].ActorType == "segment" {
33 + continue
34 + }
35 + display := topologyActorDisplayName(actors[i], nil, &resolver)
36 + if display.name == "" {
37 + display = topologyFallbackActorDisplayName(actors[i])
38 + }
39 + topologySetActorDisplay(&actors[i], display)
40 + if matchKey := canonicalTopologyMatchKey(actors[i].Match); matchKey != "" {
41 + displayByMatchKey[matchKey] = display.name
42 + }
43 + if IsDeviceActorType(actors[i].ActorType) {
44 + if deviceID := topologyActorDeviceID(actors[i]); deviceID != "" {
45 + deviceDisplayByID[deviceID] = display.name
46 + }
47 + }
48 + }
49 +
50 + // Second pass: segment display names depend on finalized device display names.
51 + for i := range actors {
52 + if actors[i].ActorType != "segment" {
53 + continue
54 + }
55 + display := topologyActorDisplayName(actors[i], deviceDisplayByID, &resolver)
56 + if display.name == "" {
57 + display = topologyFallbackActorDisplayName(actors[i])
58 + }
59 + topologySetActorDisplay(&actors[i], display)
60 + if matchKey := canonicalTopologyMatchKey(actors[i].Match); matchKey != "" {
61 + displayByMatchKey[matchKey] = display.name
62 + }
63 + }
64 +
65 + for i := range links {
66 + src := topologyEndpointDisplayName(links[i].Src, displayByMatchKey, &resolver)
67 + if src.name == "" {
68 + src = topologyDisplayName{name: "[unset]", source: "fallback"}
69 + }
70 + srcPortName := topologySetEndpointDisplayAndCanonicalPortName(&links[i].Src, src)
71 +
72 + dst := topologyEndpointDisplayName(links[i].Dst, displayByMatchKey, &resolver)
73 + if dst.name == "" {
74 + dst = topologyDisplayName{name: "[unset]", source: "fallback"}
75 + }
76 + dstPortName := topologySetEndpointDisplayAndCanonicalPortName(&links[i].Dst, dst)
77 +
78 + linkName := topologyCanonicalLinkName(src.name, srcPortName, dst.name, dstPortName)
79 + if links[i].Metrics == nil {
80 + links[i].Metrics = make(map[string]any)
81 + }
82 + links[i].Metrics["display_name"] = linkName
83 + links[i].Metrics["src_port_name"] = srcPortName
84 + links[i].Metrics["dst_port_name"] = dstPortName
85 + }
86 +}
87 +
88 +func topologySetActorDisplay(actor *topology.Actor, display topologyDisplayName) {
89 + if actor == nil {
90 + return
91 + }
92 + labels := cloneStringMap(actor.Labels)
93 + if labels == nil {
94 + labels = make(map[string]string)
95 + }
96 + labels["display_name"] = display.name
97 + if display.source != "" {
98 + labels["display_source"] = display.source
99 + }
100 + actor.Labels = labels
101 +
102 + attrs := cloneAnyMap(actor.Attributes)
103 + if attrs == nil {
104 + attrs = make(map[string]any)
105 + }
106 + attrs["display_name"] = display.name
107 + if display.source != "" {
108 + attrs["display_source"] = display.source
109 + }
110 + actor.Attributes = pruneTopologyAttributes(attrs)
111 +}
112 +
113 +func topologySetEndpointDisplayAndCanonicalPortName(endpoint *topology.LinkEndpoint, display topologyDisplayName) string {
114 + if endpoint == nil {
115 + return ""
116 + }
117 + attrs := cloneAnyMap(endpoint.Attributes)
118 + if attrs == nil {
119 + attrs = make(map[string]any)
120 + }
121 + attrs["display_name"] = display.name
122 + if display.source != "" {
123 + attrs["display_source"] = display.source
124 + }
125 + name := topologyCanonicalPortName(attrs)
126 + attrs["port_name"] = name
127 + endpoint.Attributes = pruneTopologyAttributes(attrs)
128 + return name
129 +}
130 +
131 +func topologyEndpointDisplayName(endpoint topology.LinkEndpoint, actorDisplayByMatch map[string]string, resolver *topologyDisplayNameResolver) topologyDisplayName {
132 + if key := canonicalTopologyMatchKey(endpoint.Match); key != "" {
133 + if name := strings.TrimSpace(actorDisplayByMatch[key]); name != "" {
134 + return topologyDisplayName{name: name, source: "actor"}
135 + }
136 + }
137 + return topologyDisplayNameFromMatch(endpoint.Match, resolver)
138 +}
139 +
140 +func topologyActorDisplayName(actor topology.Actor, deviceDisplayByID map[string]string, resolver *topologyDisplayNameResolver) topologyDisplayName {
141 + if actor.ActorType == "segment" {
142 + if name := topologySegmentDisplayName(actor, deviceDisplayByID); name != "" {
143 + return topologyDisplayName{name: name, source: "segment"}
144 + }
145 + }
146 +
147 + display := topologyDisplayNameFromMatch(actor.Match, resolver)
148 + if display.name != "" {
149 + return display
150 + }
151 +
152 + if segmentID := topologyAttrString(actor.Attributes, "segment_id"); segmentID != "" {
153 + return topologyDisplayName{name: topologyCompactSegmentID(segmentID), source: "segment_id"}
154 + }
155 + return topologyDisplayName{}
156 +}
157 +
158 +func topologyFallbackActorDisplayName(actor topology.Actor) topologyDisplayName {
159 + if matchKey := canonicalTopologyMatchKey(actor.Match); matchKey != "" {
160 + return topologyDisplayName{name: matchKey, source: "fallback_match"}
161 + }
162 + if segmentID := topologyAttrString(actor.Attributes, "segment_id"); segmentID != "" {
163 + return topologyDisplayName{name: topologyCompactSegmentID(segmentID), source: "segment_id"}
164 + }
165 + actorType := strings.TrimSpace(actor.ActorType)
166 + if actorType == "" {
167 + actorType = "actor"
168 + }
169 + return topologyDisplayName{name: actorType + ":[unset]", source: "fallback"}
170 +}
171 +
172 +func topologyActorDeviceID(actor topology.Actor) string {
173 + return topologyAttrString(actor.Attributes, "device_id")
174 +}
175 +
176 +func topologyDisplayNameFromMatch(match topology.Match, resolver *topologyDisplayNameResolver) topologyDisplayName {
177 + if dns := topologyMatchPreferredDNSName(match, resolver); dns != "" {
178 + return topologyDisplayName{name: dns, source: "dns"}
179 + }
180 + if sysName := topologyMatchPreferredSysName(match); sysName != "" {
181 + return topologyDisplayName{name: sysName, source: "sys_name"}
182 + }
183 + if hostname := topologyMatchPreferredHostname(match); hostname != "" {
184 + return topologyDisplayName{name: hostname, source: "hostname"}
185 + }
186 + if ip := topologyMatchPreferredIP(match); ip != "" {
187 + return topologyDisplayName{name: ip, source: "ip"}
188 + }
189 + if mac := topologyMatchPreferredMAC(match); mac != "" {
190 + return topologyDisplayName{name: mac, source: "mac"}
191 + }
192 + return topologyDisplayName{}
193 +}
194 +
195 +func topologyMatchPreferredDNSName(match topology.Match, resolver *topologyDisplayNameResolver) string {
196 + candidates := make(map[string]struct{})
197 + for _, value := range match.DNSNames {
198 + if normalized := normalizeDNSName(value); normalized != "" {
199 + candidates[normalized] = struct{}{}
200 + }
201 + }
202 + for _, value := range match.IPAddresses {
203 + if resolver == nil {
204 + continue
205 + }
206 + if ip := normalizeTopologyIP(value); ip != "" {
207 + if resolved := resolver.resolve(ip); resolved != "" {
208 + candidates[resolved] = struct{}{}
209 + }
210 + }
211 + }
212 + names := sortedTopologySet(candidates)
213 + if len(names) == 0 {
214 + return ""
215 + }
216 + return names[0]
217 +}
218 +
219 +func topologyMatchPreferredSysName(match topology.Match) string {
220 + return strings.TrimSpace(match.SysName)
221 +}
222 +
223 +func topologyMatchPreferredHostname(match topology.Match) string {
224 + hostnames := uniqueTopologyStrings(match.Hostnames)
225 + if len(hostnames) == 0 {
226 + return ""
227 + }
228 + return hostnames[0]
229 +}
230 +
231 +func topologyMatchPreferredIP(match topology.Match) string {
232 + ips := make([]string, 0, len(match.IPAddresses))
233 + for _, value := range match.IPAddresses {
234 + if ip := normalizeTopologyIP(value); ip != "" {
235 + ips = append(ips, ip)
236 + }
237 + }
238 + ips = uniqueTopologyStrings(ips)
239 + if len(ips) == 0 {
240 + return ""
241 + }
242 + return ips[0]
243 +}
244 +
245 +func topologyMatchPreferredMAC(match topology.Match) string {
246 + macs := make([]string, 0, len(match.MacAddresses)+len(match.ChassisIDs))
247 + for _, value := range match.MacAddresses {
248 + if mac := normalizeMAC(value); mac != "" {
249 + macs = append(macs, mac)
250 + }
251 + }
252 + for _, value := range match.ChassisIDs {
253 + if mac := normalizeMAC(value); mac != "" {
254 + macs = append(macs, mac)
255 + }
256 + }
257 + macs = uniqueTopologyStrings(macs)
258 + if len(macs) == 0 {
259 + return ""
260 + }
261 + return macs[0]
262 +}
263 +
264 +func normalizeDNSName(name string) string {
265 + name = strings.TrimSpace(name)
266 + name = strings.TrimSuffix(name, ".")
267 + if name == "" {
268 + return ""
269 + }
270 + return strings.ToLower(name)
271 +}
272 +
273 +func (r *topologyDisplayNameResolver) resolve(ip string) string {
274 + if r == nil || r.lookup == nil {
275 + return ""
276 + }
277 + ip = normalizeTopologyIP(ip)
278 + if ip == "" {
279 + return ""
280 + }
281 + if name, ok := r.cache[ip]; ok {
282 + return name
283 + }
284 + name := normalizeDNSName(r.lookup(ip))
285 + r.cache[ip] = name
286 + return name
287 +}
src/go/pkg/topology/engine/topology_adapter_display_attrs.go new
+152
@@ -0,0 +1,152 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "math"
7 + "strconv"
8 + "strings"
9 + "time"
10 +)
11 +
12 +func topologyCanonicalPortName(attrs map[string]any) string {
13 + if name := topologyAttrString(attrs, "port_name"); name != "" {
14 + return name
15 + }
16 + if name := topologyAttrString(attrs, "if_name"); name != "" {
17 + return name
18 + }
19 + if name := topologyAttrString(attrs, "if_descr"); name != "" {
20 + return name
21 + }
22 + if name := topologyAttrString(attrs, "if_alias"); name != "" {
23 + return name
24 + }
25 +
26 + if ifIndex := topologyAttrInt(attrs, "if_index"); ifIndex > 0 {
27 + return strconv.Itoa(ifIndex)
28 + }
29 +
30 + if portID := topologyAttrString(attrs, "port_id"); portID != "" {
31 + if n, err := strconv.Atoi(strings.TrimSpace(portID)); err == nil && n > 0 {
32 + return strconv.Itoa(n)
33 + }
34 + return portID
35 + }
36 + if bridgePort := topologyAttrString(attrs, "bridge_port"); bridgePort != "" {
37 + if n, err := strconv.Atoi(strings.TrimSpace(bridgePort)); err == nil && n > 0 {
38 + return strconv.Itoa(n)
39 + }
40 + return bridgePort
41 + }
42 + return ""
43 +}
44 +
45 +func topologyCanonicalLinkName(srcName, srcPortName, dstName, dstPortName string) string {
46 + srcName = strings.TrimSpace(srcName)
47 + if srcName == "" {
48 + srcName = "[unset]"
49 + }
50 + dstName = strings.TrimSpace(dstName)
51 + if dstName == "" {
52 + dstName = "[unset]"
53 + }
54 + srcPortName = strings.TrimSpace(srcPortName)
55 + if srcPortName == "" {
56 + srcPortName = "[unset]"
57 + }
58 + dstPortName = strings.TrimSpace(dstPortName)
59 + if dstPortName == "" {
60 + dstPortName = "[unset]"
61 + }
62 + return srcName + ":" + srcPortName + " -> " + dstName + ":" + dstPortName
63 +}
64 +
65 +func topologyAttrString(attrs map[string]any, key string) string {
66 + if len(attrs) == 0 {
67 + return ""
68 + }
69 + value, ok := attrs[key]
70 + if !ok || value == nil {
71 + return ""
72 + }
73 + str, ok := value.(string)
74 + if !ok {
75 + return ""
76 + }
77 + return strings.TrimSpace(str)
78 +}
79 +
80 +func topologyAttrInt(attrs map[string]any, key string) int {
81 + if len(attrs) == 0 {
82 + return 0
83 + }
84 + value, ok := attrs[key]
85 + if !ok || value == nil {
86 + return 0
87 + }
88 + switch typed := value.(type) {
89 + case int:
90 + return typed
91 + case int64:
92 + if typed < 0 {
93 + return 0
94 + }
95 + if typed > math.MaxInt {
96 + return math.MaxInt
97 + }
98 + return int(typed)
99 + case float64:
100 + if typed <= 0 {
101 + return 0
102 + }
103 + if typed > math.MaxInt {
104 + return math.MaxInt
105 + }
106 + return int(typed)
107 + case string:
108 + parsed, err := strconv.Atoi(strings.TrimSpace(typed))
109 + if err != nil || parsed <= 0 {
110 + return 0
111 + }
112 + return parsed
113 + default:
114 + return 0
115 + }
116 +}
117 +
118 +func topologyAttrStringSlice(attrs map[string]any, key string) []string {
119 + if len(attrs) == 0 {
120 + return nil
121 + }
122 + value, ok := attrs[key]
123 + if !ok || value == nil {
124 + return nil
125 + }
126 + switch typed := value.(type) {
127 + case []string:
128 + return append([]string(nil), typed...)
129 + case []any:
130 + out := make([]string, 0, len(typed))
131 + for _, item := range typed {
132 + str, ok := item.(string)
133 + if !ok {
134 + continue
135 + }
136 + if str = strings.TrimSpace(str); str != "" {
137 + out = append(out, str)
138 + }
139 + }
140 + return out
141 + default:
142 + return nil
143 + }
144 +}
145 +
146 +func topologyTimePtr(t time.Time) *time.Time {
147 + if t.IsZero() {
148 + return nil
149 + }
150 + out := t
151 + return &out
152 +}
src/go/pkg/topology/engine/topology_adapter_display_segment.go new
+131
@@ -0,0 +1,131 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strings"
7 +
8 + "github.com/netdata/netdata/go/plugins/pkg/topology"
9 +)
10 +
11 +type topologySegmentPortRef struct {
12 + deviceID string
13 + ifName string
14 + ifIndex string
15 + bridgePort string
16 +}
17 +
18 +func topologySegmentDisplayName(actor topology.Actor, deviceDisplayByID map[string]string) string {
19 + attrs := actor.Attributes
20 + if len(attrs) == 0 {
21 + return ""
22 + }
23 +
24 + ref := parseTopologySegmentPortRef(topologyAttrString(attrs, "designated_port"))
25 + parent := topologySegmentParentDisplayName(ref.deviceID, deviceDisplayByID)
26 + port := topologySegmentPortDisplay(ref)
27 + if parent != "" && port != "" {
28 + return parent + "." + port + ".segment"
29 + }
30 +
31 + parentCandidates := make(map[string]struct{})
32 + for _, candidate := range topologyAttrStringSlice(attrs, "parent_devices") {
33 + if value := topologySegmentParentDisplayName(candidate, deviceDisplayByID); value != "" {
34 + parentCandidates[value] = struct{}{}
35 + }
36 + }
37 + portCandidates := make(map[string]struct{})
38 + for _, candidate := range topologyAttrStringSlice(attrs, "if_names") {
39 + if candidate = strings.TrimSpace(candidate); candidate != "" {
40 + portCandidates[candidate] = struct{}{}
41 + }
42 + }
43 + if len(portCandidates) == 0 {
44 + for _, candidate := range topologyAttrStringSlice(attrs, "bridge_ports") {
45 + if candidate = strings.TrimSpace(candidate); candidate != "" {
46 + portCandidates[candidate] = struct{}{}
47 + }
48 + }
49 + }
50 + parents := sortedTopologySet(parentCandidates)
51 + ports := sortedTopologySet(portCandidates)
52 + if len(parents) > 0 && len(ports) > 0 {
53 + return parents[0] + "." + ports[0] + ".segment"
54 + }
55 +
56 + return topologyCompactSegmentID(topologyAttrString(attrs, "segment_id"))
57 +}
58 +
59 +func parseTopologySegmentPortRef(raw string) topologySegmentPortRef {
60 + raw = strings.TrimSpace(raw)
61 + if raw == "" {
62 + return topologySegmentPortRef{}
63 + }
64 + parts := strings.Split(raw, keySep)
65 + if len(parts) == 0 {
66 + return topologySegmentPortRef{}
67 + }
68 + ref := topologySegmentPortRef{
69 + deviceID: strings.TrimSpace(parts[0]),
70 + }
71 + for _, part := range parts[1:] {
72 + switch {
73 + case strings.HasPrefix(part, "name:"):
74 + ref.ifName = strings.TrimSpace(strings.TrimPrefix(part, "name:"))
75 + case strings.HasPrefix(part, "if:"):
76 + ref.ifIndex = strings.TrimSpace(strings.TrimPrefix(part, "if:"))
77 + case strings.HasPrefix(part, "bp:"):
78 + ref.bridgePort = strings.TrimSpace(strings.TrimPrefix(part, "bp:"))
79 + }
80 + }
81 + return ref
82 +}
83 +
84 +func topologySegmentPortDisplay(ref topologySegmentPortRef) string {
85 + if name := strings.TrimSpace(ref.ifName); name != "" {
86 + return name
87 + }
88 + if name := strings.TrimSpace(ref.bridgePort); name != "" {
89 + return name
90 + }
91 + if index := strings.TrimSpace(ref.ifIndex); index != "" && index != "0" {
92 + return index
93 + }
94 + return ""
95 +}
96 +
97 +func topologySegmentParentDisplayName(raw string, deviceDisplayByID map[string]string) string {
98 + raw = strings.TrimSpace(raw)
99 + if raw == "" {
100 + return ""
101 + }
102 + if deviceDisplayByID != nil {
103 + if display := strings.TrimSpace(deviceDisplayByID[raw]); display != "" {
104 + return display
105 + }
106 + }
107 + lower := strings.ToLower(raw)
108 + switch {
109 + case strings.HasPrefix(lower, "management_ip:"):
110 + return strings.TrimSpace(raw[len("management_ip:"):])
111 + case strings.HasPrefix(lower, "macaddress:"):
112 + if mac := normalizeMAC(raw[len("macAddress:"):]); mac != "" {
113 + return mac
114 + }
115 + return strings.TrimSpace(raw[len("macAddress:"):])
116 + }
117 + return raw
118 +}
119 +
120 +func topologyCompactSegmentID(segmentID string) string {
121 + segmentID = strings.TrimSpace(segmentID)
122 + if segmentID == "" {
123 + return ""
124 + }
125 + const max = 48
126 + runes := []rune(segmentID)
127 + if len(runes) <= max {
128 + return segmentID
129 + }
130 + return string(runes[:max]) + "..."
131 +}
src/go/pkg/topology/engine/topology_adapter_endpoint_device_hints.go new
+87
@@ -0,0 +1,87 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func filterManagedDeviceHints(hints []string, managedDeviceIDs map[string]struct{}) []string {
11 + if len(hints) == 0 || len(managedDeviceIDs) == 0 {
12 + return hints
13 + }
14 + managed := make([]string, 0, len(hints))
15 + for _, hint := range hints {
16 + hint = strings.TrimSpace(hint)
17 + if hint == "" {
18 + continue
19 + }
20 + if _, ok := managedDeviceIDs[hint]; ok {
21 + managed = append(managed, hint)
22 + }
23 + }
24 + if len(managed) == 0 {
25 + return nil
26 + }
27 + managed = uniqueTopologyStrings(managed)
28 + sort.Strings(managed)
29 + return managed
30 +}
31 +
32 +func topologyEndpointLabelDeviceIDs(labels map[string]string) []string {
33 + out := labelsCSVToSlice(labels, "learned_device_ids")
34 + if len(out) == 0 {
35 + out = labelsCSVToSlice(labels, "device_ids")
36 + }
37 + out = uniqueTopologyStrings(out)
38 + sort.Strings(out)
39 + return out
40 +}
41 +
42 +func resolveTopologyEndpointDeviceHints(
43 + hints []string,
44 + aliasOwnerIDs map[string]map[string]struct{},
45 +) []string {
46 + set := make(map[string]struct{})
47 + for _, hint := range hints {
48 + hint = strings.TrimSpace(hint)
49 + if hint == "" {
50 + continue
51 + }
52 + if alias := normalizeTopologyEndpointDeviceAlias(hint); alias != "" {
53 + if owners := aliasOwnerIDs[alias]; len(owners) > 0 {
54 + for ownerID := range owners {
55 + ownerID = strings.TrimSpace(ownerID)
56 + if ownerID == "" {
57 + continue
58 + }
59 + set[ownerID] = struct{}{}
60 + }
61 + continue
62 + }
63 + }
64 + set[hint] = struct{}{}
65 + }
66 + if len(set) == 0 {
67 + return nil
68 + }
69 + return sortedTopologySet(set)
70 +}
71 +
72 +func normalizeTopologyEndpointDeviceAlias(hint string) string {
73 + hint = strings.TrimSpace(hint)
74 + if hint == "" {
75 + return ""
76 + }
77 + if alias := normalizeFDBEndpointID(hint); alias != "" {
78 + return alias
79 + }
80 + lower := strings.ToLower(hint)
81 + if strings.HasPrefix(lower, "macaddress:") {
82 + if mac := normalizeMAC(hint[len("macAddress:"):]); mac != "" {
83 + return "mac:" + mac
84 + }
85 + }
86 + return ""
87 +}
src/go/pkg/topology/engine/topology_adapter_endpoint_device_hints_test.go new
+18
@@ -0,0 +1,18 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestFilterManagedDeviceHints_ReturnsNilWhenNoManagedHintMatches(t *testing.T) {
12 + filtered := filterManagedDeviceHints(
13 + []string{"ghost-switch", "unmanaged-switch"},
14 + map[string]struct{}{"managed-switch": {}},
15 + )
16 +
17 + require.Nil(t, filtered)
18 +}
src/go/pkg/topology/engine/topology_adapter_endpoints.go new
+248
@@ -0,0 +1,248 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "net/netip"
7 + "sort"
8 + "strconv"
9 + "strings"
10 +
11 + "github.com/netdata/netdata/go/plugins/pkg/topology"
12 +)
13 +
14 +type builtEndpointActors struct {
15 + actors []topology.Actor
16 + count int
17 + matchByEndpointID map[string]topology.Match
18 + labelsByEndpointID map[string]map[string]string
19 +}
20 +
21 +func buildEndpointActors(
22 + attachments []Attachment,
23 + enrichments []Enrichment,
24 + ifaceByDeviceIndex map[string]Interface,
25 + source string,
26 + layer string,
27 + actorIndex map[string]struct{},
28 + actorMACIndex map[string]struct{},
29 +) builtEndpointActors {
30 + accumulators := make(map[string]*endpointActorAccumulator)
31 +
32 + for _, attachment := range attachments {
33 + endpointID := strings.TrimSpace(attachment.EndpointID)
34 + if endpointID == "" {
35 + continue
36 + }
37 + acc := ensureEndpointActorAccumulator(accumulators, endpointID)
38 + addEndpointIDIdentity(acc, endpointID)
39 + if deviceID := strings.TrimSpace(attachment.DeviceID); deviceID != "" {
40 + acc.deviceIDs[deviceID] = struct{}{}
41 + }
42 + if method := strings.TrimSpace(attachment.Method); method != "" {
43 + acc.sources[strings.ToLower(method)] = struct{}{}
44 + }
45 + if attachment.IfIndex > 0 {
46 + acc.ifIndexes[strconv.Itoa(attachment.IfIndex)] = struct{}{}
47 + iface, ok := ifaceByDeviceIndex[deviceIfIndexKey(strings.TrimSpace(attachment.DeviceID), attachment.IfIndex)]
48 + if ok {
49 + if ifName := strings.TrimSpace(iface.IfName); ifName != "" {
50 + acc.ifNames[ifName] = struct{}{}
51 + }
52 + }
53 + }
54 + if ifName := strings.TrimSpace(attachment.Labels["if_name"]); ifName != "" {
55 + acc.ifNames[ifName] = struct{}{}
56 + }
57 + }
58 +
59 + for _, enrichment := range enrichments {
60 + endpointID := strings.TrimSpace(enrichment.EndpointID)
61 + if endpointID == "" {
62 + continue
63 + }
64 + acc := ensureEndpointActorAccumulator(accumulators, endpointID)
65 + addEndpointIDIdentity(acc, endpointID)
66 +
67 + if mac := normalizeMAC(enrichment.MAC); mac != "" {
68 + acc.mac = mac
69 + }
70 + for _, ip := range enrichment.IPs {
71 + if ip.IsValid() {
72 + acc.ips[ip.String()] = ip.Unmap()
73 + }
74 + }
75 + for _, sourceName := range csvToSet(enrichment.Labels["sources"]) {
76 + acc.sources[sourceName] = struct{}{}
77 + }
78 + for _, deviceID := range csvToSet(enrichment.Labels["device_ids"]) {
79 + deviceID = strings.TrimSpace(deviceID)
80 + if deviceID == "" {
81 + continue
82 + }
83 + acc.deviceIDs[deviceID] = struct{}{}
84 + }
85 + for _, ifIndex := range csvToSet(enrichment.Labels["if_indexes"]) {
86 + acc.ifIndexes[ifIndex] = struct{}{}
87 + }
88 + for _, ifName := range csvToSet(enrichment.Labels["if_names"]) {
89 + acc.ifNames[ifName] = struct{}{}
90 + }
91 + }
92 +
93 + if len(accumulators) == 0 {
94 + return builtEndpointActors{
95 + matchByEndpointID: map[string]topology.Match{},
96 + labelsByEndpointID: map[string]map[string]string{},
97 + }
98 + }
99 +
100 + keys := make([]string, 0, len(accumulators))
101 + for endpointID := range accumulators {
102 + keys = append(keys, endpointID)
103 + }
104 + sort.Strings(keys)
105 +
106 + actors := make([]topology.Actor, 0, len(keys))
107 + endpointCount := 0
108 + matchByEndpointID := make(map[string]topology.Match, len(keys))
109 + labelsByEndpointID := make(map[string]map[string]string, len(keys))
110 + for _, endpointID := range keys {
111 + acc := accumulators[endpointID]
112 + if acc == nil {
113 + continue
114 + }
115 +
116 + match := topology.Match{}
117 + if acc.mac != "" {
118 + match.ChassisIDs = []string{acc.mac}
119 + match.MacAddresses = []string{acc.mac}
120 + }
121 + match.IPAddresses = sortedEndpointIPs(acc.ips)
122 + matchByEndpointID[endpointID] = match
123 + labelsByEndpointID[endpointID] = map[string]string{
124 + "learned_sources": strings.Join(sortedTopologySet(acc.sources), ","),
125 + "learned_device_ids": strings.Join(sortedTopologySet(acc.deviceIDs), ","),
126 + "learned_if_indexes": strings.Join(sortedTopologySet(acc.ifIndexes), ","),
127 + "learned_if_names": strings.Join(sortedTopologySet(acc.ifNames), ","),
128 + }
129 +
130 + attrs := map[string]any{
131 + "discovered": true,
132 + "learned_sources": sortedTopologySet(acc.sources),
133 + "learned_device_ids": sortedTopologySet(acc.deviceIDs),
134 + "learned_if_indexes": sortedTopologySet(acc.ifIndexes),
135 + "learned_if_names": sortedTopologySet(acc.ifNames),
136 + }
137 + derivedVendor, derivedPrefix := inferTopologyVendorFromMatch(match)
138 + if derivedVendor != "" {
139 + attrs["vendor"] = derivedVendor
140 + attrs["vendor_source"] = "mac_oui"
141 + attrs["vendor_confidence"] = "low"
142 + attrs["vendor_match_prefix"] = derivedPrefix
143 + attrs["vendor_derived"] = derivedVendor
144 + attrs["vendor_derived_source"] = "mac_oui"
145 + attrs["vendor_derived_confidence"] = "low"
146 + attrs["vendor_derived_match_prefix"] = derivedPrefix
147 + }
148 + actor := topology.Actor{
149 + ActorType: "endpoint",
150 + Layer: layer,
151 + Source: source,
152 + Match: match,
153 + Attributes: pruneTopologyAttributes(attrs),
154 + }
155 +
156 + keys := topologyMatchIdentityKeys(actor.Match)
157 + if len(keys) == 0 {
158 + continue
159 + }
160 + macKeys := topologyMatchHardwareIdentityKeys(actor.Match)
161 + if len(macKeys) > 0 {
162 + if topologyIdentityIndexOverlaps(actorMACIndex, macKeys) {
163 + continue
164 + }
165 + addTopologyIdentityKeys(actorMACIndex, macKeys)
166 + } else if topologyIdentityIndexOverlaps(actorIndex, keys) {
167 + continue
168 + }
169 + addTopologyIdentityKeys(actorIndex, keys)
170 +
171 + actors = append(actors, actor)
172 + endpointCount++
173 + }
174 +
175 + return builtEndpointActors{
176 + actors: actors,
177 + count: endpointCount,
178 + matchByEndpointID: matchByEndpointID,
179 + labelsByEndpointID: labelsByEndpointID,
180 + }
181 +}
182 +
183 +func ensureEndpointActorAccumulator(accumulators map[string]*endpointActorAccumulator, endpointID string) *endpointActorAccumulator {
184 + acc := accumulators[endpointID]
185 + if acc != nil {
186 + return acc
187 + }
188 + acc = &endpointActorAccumulator{
189 + endpointID: endpointID,
190 + ips: make(map[string]netip.Addr),
191 + sources: make(map[string]struct{}),
192 + deviceIDs: make(map[string]struct{}),
193 + ifIndexes: make(map[string]struct{}),
194 + ifNames: make(map[string]struct{}),
195 + }
196 + accumulators[endpointID] = acc
197 + return acc
198 +}
199 +
200 +func addEndpointIDIdentity(acc *endpointActorAccumulator, endpointID string) {
201 + if acc == nil {
202 + return
203 + }
204 + kind, value, ok := strings.Cut(strings.TrimSpace(endpointID), ":")
205 + if !ok {
206 + return
207 + }
208 + switch strings.ToLower(strings.TrimSpace(kind)) {
209 + case "mac":
210 + if mac := normalizeMAC(value); mac != "" {
211 + acc.mac = mac
212 + }
213 + case "ip":
214 + if addr := parseAddr(value); addr.IsValid() {
215 + acc.ips[addr.String()] = addr.Unmap()
216 + }
217 + }
218 +}
219 +
220 +func discoveredDeviceCount(devices []Device, localDeviceID string) int {
221 + if len(devices) == 0 {
222 + return 0
223 + }
224 +
225 + localDeviceID = strings.TrimSpace(localDeviceID)
226 + if localDeviceID == "" {
227 + return maxIntValue(len(devices)-1, 0)
228 + }
229 +
230 + count := 0
231 + for _, dev := range devices {
232 + if strings.TrimSpace(dev.ID) == "" {
233 + continue
234 + }
235 + if dev.ID == localDeviceID {
236 + continue
237 + }
238 + count++
239 + }
240 + return count
241 +}
242 +
243 +func maxIntValue(a, b int) int {
244 + if a > b {
245 + return a
246 + }
247 + return b
248 +}
src/go/pkg/topology/engine/topology_adapter_fdb.go new
+150
@@ -0,0 +1,150 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func buildFDBReporterAliases(
11 + deviceByID map[string]Device,
12 + ifaceByDeviceIndex map[string]Interface,
13 +) map[string][]string {
14 + aliases := make(map[string]map[string]struct{}, len(deviceByID))
15 +
16 + for _, device := range deviceByID {
17 + deviceID := strings.TrimSpace(device.ID)
18 + if deviceID == "" {
19 + continue
20 + }
21 + chassisMAC := normalizeMAC(device.ChassisID)
22 + if chassisMAC == "" {
23 + continue
24 + }
25 + if aliases[deviceID] == nil {
26 + aliases[deviceID] = make(map[string]struct{})
27 + }
28 + aliases[deviceID]["mac:"+chassisMAC] = struct{}{}
29 + }
30 +
31 + for _, iface := range ifaceByDeviceIndex {
32 + deviceID := strings.TrimSpace(iface.DeviceID)
33 + if deviceID == "" {
34 + continue
35 + }
36 + ifaceMAC := normalizeMAC(iface.MAC)
37 + if ifaceMAC == "" {
38 + continue
39 + }
40 + if aliases[deviceID] == nil {
41 + aliases[deviceID] = make(map[string]struct{})
42 + }
43 + aliases[deviceID]["mac:"+ifaceMAC] = struct{}{}
44 + }
45 +
46 + out := make(map[string][]string, len(aliases))
47 + for deviceID, set := range aliases {
48 + values := sortedTopologySet(set)
49 + if len(values) == 0 {
50 + continue
51 + }
52 + out[deviceID] = values
53 + }
54 +
55 + return out
56 +}
57 +
58 +func buildFDBReporterObservations(macLinks []bridgeMacLinkRecord) fdbReporterObservation {
59 + obs := fdbReporterObservation{
60 + byEndpoint: make(map[string]map[string]map[string]struct{}),
61 + byReporter: make(map[string]map[string]map[string]struct{}),
62 + }
63 + for _, link := range macLinks {
64 + if strings.ToLower(strings.TrimSpace(link.method)) != "fdb" {
65 + continue
66 + }
67 + reporterID := strings.TrimSpace(link.port.deviceID)
68 + if reporterID == "" {
69 + continue
70 + }
71 + endpointID := normalizeFDBEndpointID(link.endpointID)
72 + if endpointID == "" {
73 + continue
74 + }
75 + portKey := bridgePortObservationKey(link.port)
76 + if portKey == "" {
77 + continue
78 + }
79 +
80 + byEndpointReporter := obs.byEndpoint[endpointID]
81 + if byEndpointReporter == nil {
82 + byEndpointReporter = make(map[string]map[string]struct{})
83 + obs.byEndpoint[endpointID] = byEndpointReporter
84 + }
85 + if byEndpointReporter[reporterID] == nil {
86 + byEndpointReporter[reporterID] = make(map[string]struct{})
87 + }
88 + byEndpointReporter[reporterID][portKey] = struct{}{}
89 +
90 + byReporterEndpoint := obs.byReporter[reporterID]
91 + if byReporterEndpoint == nil {
92 + byReporterEndpoint = make(map[string]map[string]struct{})
93 + obs.byReporter[reporterID] = byReporterEndpoint
94 + }
95 + if byReporterEndpoint[endpointID] == nil {
96 + byReporterEndpoint[endpointID] = make(map[string]struct{})
97 + }
98 + byReporterEndpoint[endpointID][portKey] = struct{}{}
99 + }
100 + return obs
101 +}
102 +
103 +func normalizeFDBEndpointID(endpointID string) string {
104 + kind, value, ok := strings.Cut(strings.TrimSpace(endpointID), ":")
105 + if !ok {
106 + return ""
107 + }
108 + switch strings.ToLower(strings.TrimSpace(kind)) {
109 + case "mac":
110 + if mac := normalizeMAC(value); mac != "" {
111 + return "mac:" + mac
112 + }
113 + }
114 + return ""
115 +}
116 +
117 +func buildFDBAliasOwnerMap(reporterAliases map[string][]string) map[string]map[string]struct{} {
118 + if len(reporterAliases) == 0 {
119 + return nil
120 + }
121 + aliasOwners := make(map[string]map[string]struct{})
122 + reporterIDs := make([]string, 0, len(reporterAliases))
123 + for reporterID := range reporterAliases {
124 + reporterID = strings.TrimSpace(reporterID)
125 + if reporterID == "" {
126 + continue
127 + }
128 + reporterIDs = append(reporterIDs, reporterID)
129 + }
130 + sort.Strings(reporterIDs)
131 + for _, reporterID := range reporterIDs {
132 + aliases := uniqueTopologyStrings(reporterAliases[reporterID])
133 + for _, alias := range aliases {
134 + alias = normalizeFDBEndpointID(alias)
135 + if alias == "" {
136 + continue
137 + }
138 + owners := aliasOwners[alias]
139 + if owners == nil {
140 + owners = make(map[string]struct{})
141 + aliasOwners[alias] = owners
142 + }
143 + owners[reporterID] = struct{}{}
144 + }
145 + }
146 + if len(aliasOwners) == 0 {
147 + return nil
148 + }
149 + return aliasOwners
150 +}
src/go/pkg/topology/engine/topology_adapter_fdb_inference.go new
+145
@@ -0,0 +1,145 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func inferFDBPairwiseBridgeLinks(
11 + attachments []Attachment,
12 + ifaceByDeviceIndex map[string]Interface,
13 + reporterAliases map[string][]string,
14 +) []bridgeBridgeLinkRecord {
15 + if len(attachments) == 0 || len(reporterAliases) == 0 {
16 + return nil
17 + }
18 +
19 + aliasOwnerIDs := buildFDBAliasOwnerMap(reporterAliases)
20 + if len(aliasOwnerIDs) == 0 {
21 + return nil
22 + }
23 +
24 + // reporterA -> reporterB -> unique reporter ports where A learns aliases of B.
25 + pairs := make(map[string]map[string]map[string]bridgePortRef)
26 + for _, attachment := range attachments {
27 + if !strings.EqualFold(strings.TrimSpace(attachment.Method), "fdb") {
28 + continue
29 + }
30 + reporterID := strings.TrimSpace(attachment.DeviceID)
31 + if reporterID == "" {
32 + continue
33 + }
34 + endpointID := normalizeFDBEndpointID(attachment.EndpointID)
35 + if endpointID == "" {
36 + continue
37 + }
38 + owners := aliasOwnerIDs[endpointID]
39 + if len(owners) == 0 {
40 + continue
41 + }
42 + port := bridgePortFromAttachment(attachment, ifaceByDeviceIndex)
43 + portKey := bridgePortObservationKey(port)
44 + if portKey == "" {
45 + continue
46 + }
47 + for ownerID := range owners {
48 + ownerID = strings.TrimSpace(ownerID)
49 + if ownerID == "" || strings.EqualFold(ownerID, reporterID) {
50 + continue
51 + }
52 + byPeer := pairs[reporterID]
53 + if byPeer == nil {
54 + byPeer = make(map[string]map[string]bridgePortRef)
55 + pairs[reporterID] = byPeer
56 + }
57 + ports := byPeer[ownerID]
58 + if ports == nil {
59 + ports = make(map[string]bridgePortRef)
60 + byPeer[ownerID] = ports
61 + }
62 + ports[portKey] = port
63 + }
64 + }
65 + if len(pairs) == 0 {
66 + return nil
67 + }
68 +
69 + records := make([]bridgeBridgeLinkRecord, 0)
70 + seen := make(map[string]struct{})
71 + leftIDs := make([]string, 0, len(pairs))
72 + for leftID := range pairs {
73 + leftIDs = append(leftIDs, leftID)
74 + }
75 + sort.Strings(leftIDs)
76 + for _, leftID := range leftIDs {
77 + neighbors := pairs[leftID]
78 + if len(neighbors) == 0 {
79 + continue
80 + }
81 + rightIDs := make([]string, 0, len(neighbors))
82 + for rightID := range neighbors {
83 + rightIDs = append(rightIDs, rightID)
84 + }
85 + sort.Strings(rightIDs)
86 + for _, rightID := range rightIDs {
87 + if leftID >= rightID {
88 + continue
89 + }
90 + leftPorts := pairs[leftID][rightID]
91 + rightPorts := pairs[rightID][leftID]
92 + if len(leftPorts) != 1 || len(rightPorts) != 1 {
93 + // Conservative rule: infer direct bridge link only when each side reports
94 + // exactly one reciprocal managed-alias learning port.
95 + continue
96 + }
97 + leftPort := firstSortedBridgePort(leftPorts)
98 + rightPort := firstSortedBridgePort(rightPorts)
99 + if bridgePortObservationKey(leftPort) == "" || bridgePortObservationKey(rightPort) == "" {
100 + continue
101 + }
102 + key := bridgePairKey(leftPort, rightPort)
103 + if key == "" {
104 + continue
105 + }
106 + if _, ok := seen[key]; ok {
107 + continue
108 + }
109 + seen[key] = struct{}{}
110 +
111 + designated := leftPort
112 + other := rightPort
113 + if bridgePortRefSortKey(leftPort) > bridgePortRefSortKey(rightPort) {
114 + designated = rightPort
115 + other = leftPort
116 + }
117 + records = append(records, bridgeBridgeLinkRecord{
118 + port: other,
119 + designatedPort: designated,
120 + method: "fdb_pairwise",
121 + })
122 + }
123 + }
124 + if len(records) == 0 {
125 + return nil
126 + }
127 + sort.SliceStable(records, func(i, j int) bool {
128 + li := portSortKey(records[i].designatedPort) + keySep + portSortKey(records[i].port)
129 + lj := portSortKey(records[j].designatedPort) + keySep + portSortKey(records[j].port)
130 + return li < lj
131 + })
132 + return records
133 +}
134 +
135 +func firstSortedBridgePort(ports map[string]bridgePortRef) bridgePortRef {
136 + if len(ports) == 0 {
137 + return bridgePortRef{}
138 + }
139 + keys := make([]string, 0, len(ports))
140 + for key := range ports {
141 + keys = append(keys, key)
142 + }
143 + sort.Strings(keys)
144 + return ports[keys[0]]
145 +}
src/go/pkg/topology/engine/topology_adapter_fdb_owners.go new
+234
@@ -0,0 +1,234 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func inferFDBEndpointOwners(
11 + observations fdbReporterObservation,
12 + reporterAliases map[string][]string,
13 + switchFacingPortKeys map[string]struct{},
14 +) map[string]fdbEndpointOwner {
15 + if len(observations.byEndpoint) == 0 {
16 + return nil
17 + }
18 +
19 + owners := make(map[string]fdbEndpointOwner, len(observations.byEndpoint))
20 + endpointIDs := make([]string, 0, len(observations.byEndpoint))
21 + for endpointID := range observations.byEndpoint {
22 + endpointIDs = append(endpointIDs, endpointID)
23 + }
24 + sort.Strings(endpointIDs)
25 +
26 + for _, endpointID := range endpointIDs {
27 + reportersMap := observations.byEndpoint[endpointID]
28 + if len(reportersMap) < 2 {
29 + continue
30 + }
31 +
32 + reporterIDs := make([]string, 0, len(reportersMap))
33 + for reporterID := range reportersMap {
34 + reporterIDs = append(reporterIDs, reporterID)
35 + }
36 + sort.Strings(reporterIDs)
37 +
38 + validPortsByReporter := make(map[string][]string)
39 + for _, reporterID := range reporterIDs {
40 + ports := sortedTopologySet(reportersMap[reporterID])
41 + if len(ports) == 0 {
42 + continue
43 + }
44 +
45 + for _, endpointPort := range ports {
46 + if _, isSwitchFacingPort := switchFacingPortKeys[endpointPort]; isSwitchFacingPort {
47 + continue
48 + }
49 + if !reporterSatisfiesFDBOwnerRule(endpointPort, reporterID, reporterIDs, observations.byReporter, reporterAliases) {
50 + continue
51 + }
52 + validPortsByReporter[reporterID] = append(validPortsByReporter[reporterID], endpointPort)
53 + }
54 + }
55 +
56 + if len(validPortsByReporter) != 1 {
57 + continue
58 + }
59 + for _, ports := range validPortsByReporter {
60 + ports = uniqueTopologyStrings(ports)
61 + if len(ports) == 0 {
62 + continue
63 + }
64 + owners[endpointID] = fdbEndpointOwner{
65 + portKey: ports[0],
66 + source: "reporter_matrix",
67 + }
68 + }
69 + }
70 +
71 + if len(owners) == 0 {
72 + return nil
73 + }
74 + return owners
75 +}
76 +
77 +func reporterSatisfiesFDBOwnerRule(
78 + endpointPort string,
79 + reporterID string,
80 + reporterIDs []string,
81 + reporterObservations map[string]map[string]map[string]struct{},
82 + reporterAliases map[string][]string,
83 +) bool {
84 + reporterEndpoints := reporterObservations[reporterID]
85 + if len(reporterEndpoints) == 0 {
86 + return false
87 + }
88 +
89 + for _, otherReporterID := range reporterIDs {
90 + if otherReporterID == reporterID {
91 + continue
92 + }
93 + aliases := reporterAliases[otherReporterID]
94 + if len(aliases) == 0 {
95 + return false
96 + }
97 +
98 + seenOtherOnDifferentPort := false
99 + for _, alias := range aliases {
100 + ports := reporterEndpoints[alias]
101 + if len(ports) == 0 {
102 + continue
103 + }
104 + for observedPort := range ports {
105 + if observedPort == endpointPort {
106 + return false
107 + }
108 + seenOtherOnDifferentPort = true
109 + }
110 + }
111 + if !seenOtherOnDifferentPort {
112 + return false
113 + }
114 + }
115 +
116 + return true
117 +}
118 +
119 +func inferSinglePortEndpointOwners(
120 + macLinks []bridgeMacLinkRecord,
121 + switchFacingPortKeys map[string]struct{},
122 +) map[string]fdbEndpointOwner {
123 + if len(macLinks) == 0 {
124 + return nil
125 + }
126 +
127 + type portScope struct {
128 + portKey string
129 + portVLANKey string
130 + port bridgePortRef
131 + endpointIDs map[string]struct{}
132 + }
133 +
134 + byPortScope := make(map[string]*portScope)
135 + for _, link := range macLinks {
136 + if strings.ToLower(strings.TrimSpace(link.method)) != "fdb" {
137 + continue
138 + }
139 + endpointID := normalizeFDBEndpointID(link.endpointID)
140 + if endpointID == "" {
141 + continue
142 + }
143 +
144 + portKey := bridgePortObservationKey(link.port)
145 + if portKey == "" {
146 + continue
147 + }
148 + if _, isSwitchFacingPort := switchFacingPortKeys[portKey]; isSwitchFacingPort {
149 + continue
150 + }
151 + portVLANKey := bridgePortObservationVLANKey(link.port)
152 + if portVLANKey == "" {
153 + portVLANKey = portKey
154 + }
155 + if _, isSwitchFacingPort := switchFacingPortKeys[portVLANKey]; isSwitchFacingPort {
156 + continue
157 + }
158 +
159 + scope := byPortScope[portVLANKey]
160 + if scope == nil {
161 + scope = &portScope{
162 + portKey: portKey,
163 + portVLANKey: portVLANKey,
164 + port: link.port,
165 + endpointIDs: make(map[string]struct{}),
166 + }
167 + byPortScope[portVLANKey] = scope
168 + }
169 + scope.endpointIDs[endpointID] = struct{}{}
170 + }
171 +
172 + if len(byPortScope) == 0 {
173 + return nil
174 + }
175 +
176 + candidatesByEndpoint := make(map[string]map[string]fdbEndpointOwner)
177 + scopeKeys := make([]string, 0, len(byPortScope))
178 + for key := range byPortScope {
179 + scopeKeys = append(scopeKeys, key)
180 + }
181 + sort.Strings(scopeKeys)
182 +
183 + for _, scopeKey := range scopeKeys {
184 + scope := byPortScope[scopeKey]
185 + if scope == nil || len(scope.endpointIDs) != 1 {
186 + continue
187 + }
188 + endpointID := ""
189 + for id := range scope.endpointIDs {
190 + endpointID = id
191 + break
192 + }
193 + if endpointID == "" {
194 + continue
195 + }
196 + candidates := candidatesByEndpoint[endpointID]
197 + if candidates == nil {
198 + candidates = make(map[string]fdbEndpointOwner)
199 + candidatesByEndpoint[endpointID] = candidates
200 + }
201 + candidates[scope.portVLANKey] = fdbEndpointOwner{
202 + portKey: scope.portKey,
203 + portVLANKey: scope.portVLANKey,
204 + port: scope.port,
205 + source: "single_port_mac",
206 + }
207 + }
208 +
209 + if len(candidatesByEndpoint) == 0 {
210 + return nil
211 + }
212 +
213 + owners := make(map[string]fdbEndpointOwner)
214 + endpointIDs := make([]string, 0, len(candidatesByEndpoint))
215 + for endpointID := range candidatesByEndpoint {
216 + endpointIDs = append(endpointIDs, endpointID)
217 + }
218 + sort.Strings(endpointIDs)
219 +
220 + for _, endpointID := range endpointIDs {
221 + candidates := candidatesByEndpoint[endpointID]
222 + if len(candidates) != 1 {
223 + continue
224 + }
225 + for _, owner := range candidates {
226 + owners[endpointID] = owner
227 + }
228 + }
229 +
230 + if len(owners) == 0 {
231 + return nil
232 + }
233 + return owners
234 +}
src/go/pkg/topology/engine/topology_adapter_identity.go new
+321
@@ -0,0 +1,321 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strconv"
8 + "strings"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/topology"
11 +)
12 +
13 +var interfaceNameLookupSanitizer = strings.NewReplacer(
14 + " ", "",
15 + "-", "",
16 + "_", "",
17 + ".", "",
18 + "\t", "",
19 + "\n", "",
20 + "\r", "",
21 +)
22 +
23 +func deviceIfNameKey(deviceID, ifName string) string {
24 + return strings.TrimSpace(deviceID) + keySep + strings.ToLower(strings.TrimSpace(ifName))
25 +}
26 +
27 +func interfaceNameLookupAliases(values ...string) []string {
28 + set := make(map[string]struct{}, len(values)*2)
29 + for _, value := range values {
30 + trimmed := strings.TrimSpace(value)
31 + if trimmed == "" {
32 + continue
33 + }
34 + set[trimmed] = struct{}{}
35 + if normalized := normalizeInterfaceNameForLookup(trimmed); normalized != "" && normalized != strings.ToLower(trimmed) {
36 + set[normalized] = struct{}{}
37 + }
38 + }
39 + if len(set) == 0 {
40 + return nil
41 + }
42 + out := make([]string, 0, len(set))
43 + for value := range set {
44 + out = append(out, value)
45 + }
46 + sort.Strings(out)
47 + return out
48 +}
49 +
50 +func normalizeInterfaceNameForLookup(value string) string {
51 + value = strings.TrimSpace(strings.ToLower(value))
52 + if value == "" {
53 + return ""
54 + }
55 + return interfaceNameLookupSanitizer.Replace(value)
56 +}
57 +
58 +func resolveIfIndexByPortName(deviceID, port string, ifIndexByDeviceName map[string]int) int {
59 + deviceID = strings.TrimSpace(deviceID)
60 + port = strings.TrimSpace(port)
61 + if deviceID == "" || port == "" {
62 + return 0
63 + }
64 + if idx, ok := ifIndexByDeviceName[deviceIfNameKey(deviceID, port)]; ok && idx > 0 {
65 + return idx
66 + }
67 + if normalized := normalizeInterfaceNameForLookup(port); normalized != "" {
68 + if idx, ok := ifIndexByDeviceName[deviceIfNameKey(deviceID, normalized)]; ok && idx > 0 {
69 + return idx
70 + }
71 + }
72 + if parsed, err := strconv.Atoi(port); err == nil && parsed > 0 {
73 + return parsed
74 + }
75 + return 0
76 +}
77 +
78 +func topologyIdentityIndexOverlaps(index map[string]struct{}, keys []string) bool {
79 + if len(index) == 0 || len(keys) == 0 {
80 + return false
81 + }
82 + for _, key := range keys {
83 + if _, ok := index[key]; ok {
84 + return true
85 + }
86 + }
87 + return false
88 +}
89 +
90 +func addTopologyIdentityKeys(index map[string]struct{}, keys []string) {
91 + if index == nil || len(keys) == 0 {
92 + return
93 + }
94 + for _, key := range keys {
95 + index[key] = struct{}{}
96 + }
97 +}
98 +
99 +func buildDeviceIdentityKeySetByID(
100 + deviceByID map[string]Device,
101 + adjacencies []Adjacency,
102 + ifaceByDeviceIndex map[string]Interface,
103 +) map[string]topologyIdentityKeySet {
104 + if len(deviceByID) == 0 {
105 + return nil
106 + }
107 + out := make(map[string]topologyIdentityKeySet, len(deviceByID))
108 + for _, device := range deviceByID {
109 + deviceID := strings.TrimSpace(device.ID)
110 + if deviceID == "" {
111 + continue
112 + }
113 + keys := topologyMatchIdentityKeys(
114 + deviceToTopologyActor(device, "", "", "", topologyDeviceInterfaceSummary{}, nil).Match,
115 + )
116 + if len(keys) == 0 {
117 + continue
118 + }
119 + set := make(topologyIdentityKeySet, len(keys))
120 + for _, key := range keys {
121 + key = strings.TrimSpace(key)
122 + if key == "" {
123 + continue
124 + }
125 + set[key] = struct{}{}
126 + }
127 + if len(set) == 0 {
128 + continue
129 + }
130 + out[deviceID] = set
131 + }
132 + for _, adjacency := range adjacencies {
133 + protocol := strings.ToLower(strings.TrimSpace(adjacency.Protocol))
134 + if protocol != "lldp" && protocol != "cdp" {
135 + continue
136 + }
137 + if mac := normalizeMAC(adjacency.SourcePort); mac != "" {
138 + deviceID := strings.TrimSpace(adjacency.SourceID)
139 + if deviceID != "" {
140 + if out[deviceID] == nil {
141 + out[deviceID] = make(topologyIdentityKeySet)
142 + }
143 + out[deviceID]["hw:"+mac] = struct{}{}
144 + }
145 + }
146 + if mac := normalizeMAC(adjacency.TargetPort); mac != "" {
147 + deviceID := strings.TrimSpace(adjacency.TargetID)
148 + if deviceID != "" {
149 + if out[deviceID] == nil {
150 + out[deviceID] = make(topologyIdentityKeySet)
151 + }
152 + out[deviceID]["hw:"+mac] = struct{}{}
153 + }
154 + }
155 + }
156 + for _, iface := range ifaceByDeviceIndex {
157 + deviceID := strings.TrimSpace(iface.DeviceID)
158 + if deviceID == "" {
159 + continue
160 + }
161 + ifaceMAC := normalizeMAC(iface.MAC)
162 + if ifaceMAC == "" {
163 + continue
164 + }
165 + if out[deviceID] == nil {
166 + out[deviceID] = make(topologyIdentityKeySet)
167 + }
168 + out[deviceID]["hw:"+ifaceMAC] = struct{}{}
169 + }
170 + if len(out) == 0 {
171 + return nil
172 + }
173 + return out
174 +}
175 +
176 +func topologyMatchIdentityKeys(match topology.Match) []string {
177 + seen := make(map[string]struct{}, 8)
178 + add := func(kind, value string) {
179 + value = strings.TrimSpace(value)
180 + if value == "" {
181 + return
182 + }
183 + key := kind + ":" + value
184 + seen[key] = struct{}{}
185 + }
186 +
187 + for _, value := range match.ChassisIDs {
188 + value = strings.TrimSpace(value)
189 + if value == "" {
190 + continue
191 + }
192 + if mac := normalizeMAC(value); mac != "" {
193 + add("hw", mac)
194 + continue
195 + }
196 + if ip := normalizeTopologyIP(value); ip != "" {
197 + add("ip", ip)
198 + continue
199 + }
200 + add("chassis", strings.ToLower(value))
201 + }
202 +
203 + for _, value := range match.MacAddresses {
204 + if mac := normalizeMAC(value); mac != "" {
205 + add("hw", mac)
206 + }
207 + }
208 + for _, value := range match.IPAddresses {
209 + if ip := normalizeTopologyIP(value); ip != "" {
210 + add("ip", ip)
211 + continue
212 + }
213 + add("ipraw", strings.ToLower(strings.TrimSpace(value)))
214 + }
215 + for _, value := range match.Hostnames {
216 + add("hostname", strings.ToLower(strings.TrimSpace(value)))
217 + }
218 + for _, value := range match.DNSNames {
219 + add("dns", strings.ToLower(strings.TrimSpace(value)))
220 + }
221 + if sysName := strings.TrimSpace(match.SysName); sysName != "" {
222 + add("sysname", strings.ToLower(sysName))
223 + }
224 +
225 + if len(seen) == 0 {
226 + return nil
227 + }
228 +
229 + keys := make([]string, 0, len(seen))
230 + for key := range seen {
231 + keys = append(keys, key)
232 + }
233 + sort.Strings(keys)
234 + return keys
235 +}
236 +
237 +func topologyMatchHardwareIdentityKeys(match topology.Match) []string {
238 + seen := make(map[string]struct{}, len(match.MacAddresses)+len(match.ChassisIDs))
239 + add := func(value string) {
240 + if mac := normalizeMAC(value); mac != "" {
241 + seen["hw:"+mac] = struct{}{}
242 + }
243 + }
244 +
245 + for _, value := range match.MacAddresses {
246 + add(value)
247 + }
248 + for _, value := range match.ChassisIDs {
249 + add(value)
250 + }
251 +
252 + if len(seen) == 0 {
253 + return nil
254 + }
255 + keys := make([]string, 0, len(seen))
256 + for key := range seen {
257 + keys = append(keys, key)
258 + }
259 + sort.Strings(keys)
260 + return keys
261 +}
262 +
263 +func endpointMatchOverlappingKnownDeviceIDs(
264 + endpointMatch topology.Match,
265 + deviceIdentityByID map[string]topologyIdentityKeySet,
266 +) []string {
267 + if len(deviceIdentityByID) == 0 {
268 + return nil
269 + }
270 +
271 + endpointKeys := topologyMatchHardwareIdentityKeys(endpointMatch)
272 + if len(endpointKeys) == 0 {
273 + endpointKeys = topologyMatchIdentityKeys(endpointMatch)
274 + }
275 + if len(endpointKeys) == 0 {
276 + return nil
277 + }
278 +
279 + deviceIDs := make([]string, 0, len(deviceIdentityByID))
280 + for deviceID := range deviceIdentityByID {
281 + deviceID = strings.TrimSpace(deviceID)
282 + if deviceID == "" {
283 + continue
284 + }
285 + deviceIDs = append(deviceIDs, deviceID)
286 + }
287 + sort.Strings(deviceIDs)
288 + if len(deviceIDs) == 0 {
289 + return nil
290 + }
291 +
292 + matches := make([]string, 0, 2)
293 + for _, deviceID := range deviceIDs {
294 + deviceKeys := deviceIdentityByID[deviceID]
295 + if len(deviceKeys) == 0 {
296 + continue
297 + }
298 + for _, endpointKey := range endpointKeys {
299 + if _, ok := deviceKeys[endpointKey]; ok {
300 + matches = append(matches, deviceID)
301 + break
302 + }
303 + }
304 + }
305 + if len(matches) == 0 {
306 + return nil
307 + }
308 + return matches
309 +}
310 +
311 +func normalizeTopologyIP(value string) string {
312 + value = strings.TrimSpace(value)
313 + if value == "" {
314 + return ""
315 + }
316 + addr := parseAddr(value)
317 + if !addr.IsValid() {
318 + return ""
319 + }
320 + return addr.Unmap().String()
321 +}
src/go/pkg/topology/engine/topology_adapter_identity_assignment.go new
+349
@@ -0,0 +1,349 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "fmt"
7 + "sort"
8 + "strings"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/topology"
11 +)
12 +
13 +type topologyMatchLookup struct {
14 + canonical string
15 + identityKeys []string
16 +}
17 +
18 +type topologyActorSortEntry struct {
19 + actor topology.Actor
20 + key string
21 +}
22 +
23 +type topologyLinkSortEntry struct {
24 + link topology.Link
25 + key string
26 +}
27 +
28 +func canonicalTopologyMatchKey(match topology.Match) string {
29 + if key := canonicalTopologyPrimaryMACKey(match); key != "" {
30 + return "mac:" + key
31 + }
32 + if key := canonicalTopologyHardwareKey(match.ChassisIDs); key != "" {
33 + return "chassis:" + key
34 + }
35 + if key := canonicalTopologyIPListKey(match.IPAddresses); key != "" {
36 + return "ip:" + key
37 + }
38 + if key := canonicalTopologyStringListKey(match.Hostnames); key != "" {
39 + return "hostname:" + key
40 + }
41 + if key := canonicalTopologyStringListKey(match.DNSNames); key != "" {
42 + return "dns:" + key
43 + }
44 + if sysName := strings.ToLower(strings.TrimSpace(match.SysName)); sysName != "" {
45 + return "sysname:" + sysName
46 + }
47 + if match.SysObjectID != "" {
48 + return "sysobjectid:" + match.SysObjectID
49 + }
50 + return ""
51 +}
52 +
53 +func assignTopologyActorIDsAndLinkEndpoints(actors []topology.Actor, links []topology.Link) {
54 + if len(actors) == 0 {
55 + return
56 + }
57 +
58 + usedActorIDs := make(map[string]int, len(actors))
59 + actorIDByCanonicalMatch := make(map[string]string, len(actors))
60 + actorIDByIdentityKey := make(map[string]string, len(actors)*4)
61 + actorLookups := make([]topologyMatchLookup, len(actors))
62 +
63 + for i := range actors {
64 + actorLookups[i] = newTopologyMatchLookup(actors[i].Match)
65 +
66 + baseID := actorLookups[i].canonical
67 + if baseID == "" {
68 + actorType := strings.ToLower(strings.TrimSpace(actors[i].ActorType))
69 + if actorType == "" {
70 + actorType = "actor"
71 + }
72 + baseID = "generated:" + actorType
73 + }
74 +
75 + actorID := responseScopedActorID(baseID, usedActorIDs)
76 + actors[i].ActorID = actorID
77 +
78 + if actorLookups[i].canonical != "" {
79 + if _, exists := actorIDByCanonicalMatch[actorLookups[i].canonical]; !exists {
80 + actorIDByCanonicalMatch[actorLookups[i].canonical] = actorID
81 + }
82 + }
83 + for _, key := range actorLookups[i].identityKeys {
84 + if _, exists := actorIDByIdentityKey[key]; !exists {
85 + actorIDByIdentityKey[key] = actorID
86 + }
87 + }
88 + }
89 +
90 + for i := range links {
91 + srcLookup := newTopologyMatchLookup(links[i].Src.Match)
92 + dstLookup := newTopologyMatchLookup(links[i].Dst.Match)
93 + links[i].SrcActorID = resolveTopologyEndpointActorID(srcLookup, actorIDByCanonicalMatch, actorIDByIdentityKey)
94 + links[i].DstActorID = resolveTopologyEndpointActorID(dstLookup, actorIDByCanonicalMatch, actorIDByIdentityKey)
95 + }
96 +}
97 +
98 +func newTopologyMatchLookup(match topology.Match) topologyMatchLookup {
99 + return topologyMatchLookup{
100 + canonical: canonicalTopologyMatchKey(match),
101 + identityKeys: topologyMatchIdentityKeys(match),
102 + }
103 +}
104 +
105 +func responseScopedActorID(base string, used map[string]int) string {
106 + base = strings.ToLower(strings.TrimSpace(base))
107 + if base == "" {
108 + base = "generated:actor"
109 + }
110 +
111 + count := used[base]
112 + count++
113 + used[base] = count
114 + if count == 1 {
115 + return base
116 + }
117 + return fmt.Sprintf("%s#%d", base, count)
118 +}
119 +
120 +func resolveTopologyEndpointActorID(lookup topologyMatchLookup, byCanonicalMatch map[string]string, byIdentityKey map[string]string) string {
121 + if lookup.canonical != "" {
122 + if actorID := strings.TrimSpace(byCanonicalMatch[lookup.canonical]); actorID != "" {
123 + return actorID
124 + }
125 + }
126 + for _, key := range lookup.identityKeys {
127 + if actorID := strings.TrimSpace(byIdentityKey[key]); actorID != "" {
128 + return actorID
129 + }
130 + }
131 + return ""
132 +}
133 +
134 +func enrichTopologyPortTablesWithLinkCounts(actors []topology.Actor, links []topology.Link) {
135 + type actorPort struct {
136 + actorID string
137 + portName string
138 + }
139 + counts := make(map[actorPort]int, len(links)*2)
140 +
141 + for _, link := range links {
142 + if link.SrcActorID != "" {
143 + if ifName, ok := link.Src.Attributes["if_name"]; ok {
144 + name := strings.TrimSpace(fmt.Sprintf("%v", ifName))
145 + if name != "" {
146 + counts[actorPort{link.SrcActorID, name}]++
147 + }
148 + }
149 + }
150 + if link.DstActorID != "" {
151 + if ifName, ok := link.Dst.Attributes["if_name"]; ok {
152 + name := strings.TrimSpace(fmt.Sprintf("%v", ifName))
153 + if name != "" {
154 + counts[actorPort{link.DstActorID, name}]++
155 + }
156 + }
157 + }
158 + }
159 +
160 + for i := range actors {
161 + portRows := actors[i].Tables["ports"]
162 + if len(portRows) == 0 {
163 + continue
164 + }
165 + for j := range portRows {
166 + name := strings.TrimSpace(fmt.Sprintf("%v", portRows[j]["name"]))
167 + if name == "" {
168 + continue
169 + }
170 + if c := counts[actorPort{actors[i].ActorID, name}]; c > 0 {
171 + portRows[j]["link_count"] = c
172 + }
173 + }
174 + }
175 +}
176 +
177 +func canonicalTopologyPrimaryMACKey(match topology.Match) string {
178 + set := make(map[string]struct{}, len(match.MacAddresses)+len(match.ChassisIDs))
179 + for _, value := range match.MacAddresses {
180 + if mac := normalizeMAC(value); mac != "" {
181 + set[mac] = struct{}{}
182 + }
183 + }
184 + for _, value := range match.ChassisIDs {
185 + if mac := normalizeMAC(value); mac != "" {
186 + set[mac] = struct{}{}
187 + }
188 + }
189 + if len(set) == 0 {
190 + return ""
191 + }
192 + keys := sortedTopologySet(set)
193 + if len(keys) == 0 {
194 + return ""
195 + }
196 + return strings.Join(keys, ",")
197 +}
198 +
199 +func canonicalTopologyHardwareKey(values []string) string {
200 + if len(values) == 0 {
201 + return ""
202 + }
203 + out := make([]string, 0, len(values))
204 + for _, value := range values {
205 + value = strings.TrimSpace(value)
206 + if value == "" {
207 + continue
208 + }
209 + if mac := normalizeMAC(value); mac != "" {
210 + out = append(out, mac)
211 + continue
212 + }
213 + if ip := normalizeTopologyIP(value); ip != "" {
214 + out = append(out, ip)
215 + continue
216 + }
217 + out = append(out, strings.ToLower(value))
218 + }
219 + if len(out) == 0 {
220 + return ""
221 + }
222 + sort.Strings(out)
223 + out = uniqueTopologyStrings(out)
224 + return strings.Join(out, ",")
225 +}
226 +
227 +func canonicalTopologyIPListKey(values []string) string {
228 + if len(values) == 0 {
229 + return ""
230 + }
231 + out := make([]string, 0, len(values))
232 + for _, value := range values {
233 + value = strings.TrimSpace(value)
234 + if value == "" {
235 + continue
236 + }
237 + if ip := normalizeTopologyIP(value); ip != "" {
238 + out = append(out, ip)
239 + continue
240 + }
241 + out = append(out, strings.ToLower(value))
242 + }
243 + if len(out) == 0 {
244 + return ""
245 + }
246 + sort.Strings(out)
247 + out = uniqueTopologyStrings(out)
248 + return strings.Join(out, ",")
249 +}
250 +
251 +func canonicalTopologyStringListKey(values []string) string {
252 + if len(values) == 0 {
253 + return ""
254 + }
255 + out := make([]string, 0, len(values))
256 + for _, value := range values {
257 + value = strings.ToLower(strings.TrimSpace(value))
258 + if value == "" {
259 + continue
260 + }
261 + out = append(out, value)
262 + }
263 + if len(out) == 0 {
264 + return ""
265 + }
266 + sort.Strings(out)
267 + out = uniqueTopologyStrings(out)
268 + return strings.Join(out, ",")
269 +}
270 +
271 +func topologyLinkSortKey(link topology.Link) string {
272 + return strings.Join([]string{
273 + link.Protocol,
274 + link.Direction,
275 + canonicalTopologyMatchKey(link.Src.Match),
276 + canonicalTopologyMatchKey(link.Dst.Match),
277 + topologyAttrKey(link.Src.Attributes, "if_index"),
278 + topologyAttrKey(link.Src.Attributes, "if_name"),
279 + topologyAttrKey(link.Src.Attributes, "port_id"),
280 + topologyAttrKey(link.Dst.Attributes, "if_index"),
281 + topologyAttrKey(link.Dst.Attributes, "if_name"),
282 + topologyAttrKey(link.Dst.Attributes, "port_id"),
283 + link.State,
284 + }, keySep)
285 +}
286 +
287 +func topologyActorSortKey(actor topology.Actor) string {
288 + return strings.Join([]string{
289 + actor.ActorType,
290 + canonicalTopologyMatchKey(actor.Match),
291 + actor.Source,
292 + actor.Layer,
293 + }, keySep)
294 +}
295 +
296 +func topologyAttrKey(attrs map[string]any, key string) string {
297 + if len(attrs) == 0 {
298 + return ""
299 + }
300 + value, ok := attrs[key]
301 + if !ok || value == nil {
302 + return ""
303 + }
304 + return fmt.Sprint(value)
305 +}
306 +
307 +func sortTopologyActors(actors []topology.Actor) {
308 + if len(actors) < 2 {
309 + return
310 + }
311 +
312 + entries := make([]topologyActorSortEntry, len(actors))
313 + for i := range actors {
314 + entries[i] = topologyActorSortEntry{
315 + actor: actors[i],
316 + key: topologyActorSortKey(actors[i]),
317 + }
318 + }
319 +
320 + sort.SliceStable(entries, func(i, j int) bool {
321 + return entries[i].key < entries[j].key
322 + })
323 +
324 + for i := range entries {
325 + actors[i] = entries[i].actor
326 + }
327 +}
328 +
329 +func sortTopologyLinks(links []topology.Link) {
330 + if len(links) < 2 {
331 + return
332 + }
333 +
334 + entries := make([]topologyLinkSortEntry, len(links))
335 + for i := range links {
336 + entries[i] = topologyLinkSortEntry{
337 + link: links[i],
338 + key: topologyLinkSortKey(links[i]),
339 + }
340 + }
341 +
342 + sort.SliceStable(entries, func(i, j int) bool {
343 + return entries[i].key < entries[j].key
344 + })
345 +
346 + for i := range entries {
347 + links[i] = entries[i].link
348 + }
349 +}
src/go/pkg/topology/engine/topology_adapter_identity_assignment_test.go new
+162
@@ -0,0 +1,162 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/netdata/netdata/go/plugins/pkg/topology"
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestCanonicalTopologyKeyHelpers_NormalizeDeterministically(t *testing.T) {
13 + require.Equal(t,
14 + "00:11:22:33:44:55,0a000001,switch-a",
15 + canonicalTopologyHardwareKey([]string{" Switch-A ", "0A000001", "00:11:22:33:44:55", "switch-a"}),
16 + )
17 + require.Equal(t,
18 + "example.net,switch-a",
19 + canonicalTopologyStringListKey([]string{" Switch-A ", "", "example.net", "switch-a"}),
20 + )
21 +}
22 +
23 +func TestAssignTopologyActorIDsAndLinkEndpoints_IsDeterministic(t *testing.T) {
24 + actors := []topology.Actor{
25 + {
26 + ActorType: "device",
27 + Layer: "l2",
28 + Source: "snmp",
29 + Match: topology.Match{
30 + MacAddresses: []string{"00:11:22:33:44:55"},
31 + Hostnames: []string{"switch-a"},
32 + },
33 + },
34 + {
35 + ActorType: "device",
36 + Layer: "l2",
37 + Source: "snmp",
38 + Match: topology.Match{
39 + MacAddresses: []string{"00-11-22-33-44-55"},
40 + Hostnames: []string{"switch-a-duplicate"},
41 + },
42 + },
43 + {
44 + ActorType: "endpoint",
45 + Layer: "l2",
46 + Source: "derived",
47 + Match: topology.Match{
48 + IPAddresses: []string{"10.0.0.2"},
49 + },
50 + },
51 + {
52 + ActorType: "segment",
53 + Layer: "l2",
54 + Source: "derived",
55 + Match: topology.Match{},
56 + },
57 + }
58 + links := []topology.Link{
59 + {
60 + Protocol: "lldp",
61 + Direction: "outbound",
62 + State: "up",
63 + Src: topology.LinkEndpoint{
64 + Match: topology.Match{MacAddresses: []string{"00:11:22:33:44:55"}},
65 + Attributes: map[string]any{
66 + "if_name": "eth0",
67 + },
68 + },
69 + Dst: topology.LinkEndpoint{
70 + Match: topology.Match{IPAddresses: []string{"10.0.0.2"}},
71 + Attributes: map[string]any{
72 + "if_name": "eth9",
73 + },
74 + },
75 + },
76 + {
77 + Protocol: "lldp",
78 + Direction: "outbound",
79 + State: "up",
80 + Src: topology.LinkEndpoint{
81 + Match: topology.Match{IPAddresses: []string{"10.0.0.2"}},
82 + Attributes: map[string]any{
83 + "if_name": "eth9",
84 + },
85 + },
86 + Dst: topology.LinkEndpoint{
87 + Match: topology.Match{MacAddresses: []string{"00:11:22:33:44:55"}},
88 + Attributes: map[string]any{
89 + "if_name": "eth0",
90 + },
91 + },
92 + },
93 + }
94 +
95 + assignTopologyActorIDsAndLinkEndpoints(actors, links)
96 +
97 + require.Equal(t, "mac:00:11:22:33:44:55", actors[0].ActorID)
98 + require.Equal(t, "mac:00:11:22:33:44:55#2", actors[1].ActorID)
99 + require.Equal(t, "ip:10.0.0.2", actors[2].ActorID)
100 + require.Equal(t, "generated:segment", actors[3].ActorID)
101 + require.Equal(t, "mac:00:11:22:33:44:55", links[0].SrcActorID)
102 + require.Equal(t, "ip:10.0.0.2", links[0].DstActorID)
103 + require.Equal(t, "ip:10.0.0.2", links[1].SrcActorID)
104 + require.Equal(t, "mac:00:11:22:33:44:55", links[1].DstActorID)
105 +
106 + sortTopologyActors(actors)
107 + require.Equal(t, []string{
108 + "mac:00:11:22:33:44:55",
109 + "mac:00:11:22:33:44:55#2",
110 + "ip:10.0.0.2",
111 + "generated:segment",
112 + }, []string{actors[0].ActorID, actors[1].ActorID, actors[2].ActorID, actors[3].ActorID})
113 +
114 + sortTopologyLinks(links)
115 + require.Equal(t, "ip:10.0.0.2", links[0].SrcActorID)
116 + require.Equal(t, "mac:00:11:22:33:44:55", links[0].DstActorID)
117 + require.Equal(t, "mac:00:11:22:33:44:55", links[1].SrcActorID)
118 + require.Equal(t, "ip:10.0.0.2", links[1].DstActorID)
119 +}
120 +
121 +func TestEnrichTopologyPortTablesWithLinkCounts_AddsCountsToMatchingPorts(t *testing.T) {
122 + actors := []topology.Actor{
123 + {
124 + ActorID: "device-a",
125 + Tables: map[string][]map[string]any{
126 + "ports": {
127 + {"name": "eth0"},
128 + {"name": "eth1"},
129 + },
130 + },
131 + },
132 + {
133 + ActorID: "device-b",
134 + Tables: map[string][]map[string]any{
135 + "ports": {
136 + {"name": "xe-0/0/0"},
137 + },
138 + },
139 + },
140 + }
141 + links := []topology.Link{
142 + {
143 + SrcActorID: "device-a",
144 + DstActorID: "device-b",
145 + Src: topology.LinkEndpoint{Attributes: map[string]any{"if_name": "eth0"}},
146 + Dst: topology.LinkEndpoint{Attributes: map[string]any{"if_name": "xe-0/0/0"}},
147 + },
148 + {
149 + SrcActorID: "device-a",
150 + DstActorID: "device-b",
151 + Src: topology.LinkEndpoint{Attributes: map[string]any{"if_name": "eth0"}},
152 + Dst: topology.LinkEndpoint{Attributes: map[string]any{"if_name": "xe-0/0/0"}},
153 + },
154 + }
155 +
156 + enrichTopologyPortTablesWithLinkCounts(actors, links)
157 +
158 + require.Equal(t, 2, actors[0].Tables["ports"][0]["link_count"])
159 + _, exists := actors[0].Tables["ports"][1]["link_count"]
160 + require.False(t, exists)
161 + require.Equal(t, 2, actors[1].Tables["ports"][0]["link_count"])
162 +}
src/go/pkg/topology/engine/topology_adapter_projection.go new
+31
@@ -0,0 +1,31 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "github.com/netdata/netdata/go/plugins/pkg/topology"
6 +
7 +type builtAdjacencyLink struct {
8 + adj Adjacency
9 + protocol string
10 + link topology.Link
11 +}
12 +
13 +type pairedLinkAccumulator struct {
14 + all []*builtAdjacencyLink
15 +}
16 +
17 +type projectedLinks struct {
18 + links []topology.Link
19 + lldp int
20 + cdp int
21 + bidirectionalCount int
22 + unidirectionalCount int
23 +}
24 +
25 +type bridgePortRef struct {
26 + deviceID string
27 + ifIndex int
28 + ifName string
29 + bridgePort string
30 + vlanID string
31 +}
src/go/pkg/topology/engine/topology_adapter_projection_pairs.go new
+316
@@ -0,0 +1,316 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strings"
7 + "time"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/topology"
10 +)
11 +
12 +func projectAdjacencyLinks(
13 + adjacencies []Adjacency,
14 + layer string,
15 + collectedAt time.Time,
16 + deviceByID map[string]Device,
17 + ifIndexByDeviceName map[string]int,
18 + ifaceByDeviceIndex map[string]Interface,
19 +) projectedLinks {
20 + out := projectedLinks{
21 + links: make([]topology.Link, 0, len(adjacencies)),
22 + }
23 + if len(adjacencies) == 0 {
24 + return out
25 + }
26 +
27 + pairs := make(map[string]*pairedLinkAccumulator)
28 + pairOrder := make([]string, 0)
29 +
30 + for _, adj := range adjacencies {
31 + protocol := strings.ToLower(strings.TrimSpace(adj.Protocol))
32 + link := adjacencyToTopologyLink(adj, protocol, layer, collectedAt, deviceByID, ifIndexByDeviceName, ifaceByDeviceIndex)
33 +
34 + pairID := strings.TrimSpace(adj.Labels[adjacencyLabelPairID])
35 + if pairID != "" {
36 + acc := pairs[pairID]
37 + if acc == nil {
38 + acc = &pairedLinkAccumulator{}
39 + pairs[pairID] = acc
40 + pairOrder = append(pairOrder, pairID)
41 + }
42 +
43 + entry := &builtAdjacencyLink{
44 + adj: adj,
45 + protocol: protocol,
46 + link: link,
47 + }
48 + acc.all = append(acc.all, entry)
49 + continue
50 + }
51 +
52 + out.links = append(out.links, link)
53 + incrementProjectedProtocolCounters(&out, protocol, false)
54 + }
55 +
56 + for _, pairID := range pairOrder {
57 + acc := pairs[pairID]
58 + if acc == nil {
59 + continue
60 + }
61 +
62 + if left, right, ok := reversePairEntriesForBidirectionalMerge(acc.all); ok {
63 + merged := left.link
64 + merged.Direction = "bidirectional"
65 + merged.Src = mergeEndpointIPHints(left.link.Src, right.link.Dst)
66 + merged.Dst = mergeEndpointIPHints(right.link.Src, left.link.Dst)
67 + merged.Metrics = buildPairedLinkMetrics(left.adj.Labels, right.adj.Labels)
68 + out.links = append(out.links, merged)
69 + incrementProjectedProtocolCounters(&out, left.protocol, true)
70 + continue
71 + }
72 +
73 + backfillPairGroupMissingEndpointPorts(acc.all)
74 + for _, entry := range acc.all {
75 + if entry == nil {
76 + continue
77 + }
78 + out.links = append(out.links, entry.link)
79 + incrementProjectedProtocolCounters(&out, entry.protocol, false)
80 + }
81 + }
82 +
83 + sortTopologyLinks(out.links)
84 + return out
85 +}
86 +
87 +func reversePairEntriesForBidirectionalMerge(entries []*builtAdjacencyLink) (left, right *builtAdjacencyLink, ok bool) {
88 + if len(entries) != 2 || entries[0] == nil || entries[1] == nil {
89 + return nil, nil, false
90 + }
91 +
92 + a := entries[0]
93 + b := entries[1]
94 + aSrc := strings.TrimSpace(a.adj.SourceID)
95 + aDst := strings.TrimSpace(a.adj.TargetID)
96 + bSrc := strings.TrimSpace(b.adj.SourceID)
97 + bDst := strings.TrimSpace(b.adj.TargetID)
98 + if aSrc == "" || aDst == "" || bSrc == "" || bDst == "" {
99 + return nil, nil, false
100 + }
101 + if aSrc != bDst || aDst != bSrc {
102 + return nil, nil, false
103 + }
104 +
105 + if pairedEntryDeterministicKey(b) < pairedEntryDeterministicKey(a) {
106 + a, b = b, a
107 + }
108 + return a, b, true
109 +}
110 +
111 +func pairedEntryDeterministicKey(entry *builtAdjacencyLink) string {
112 + if entry == nil {
113 + return ""
114 + }
115 + return strings.Join([]string{
116 + strings.TrimSpace(entry.protocol),
117 + strings.TrimSpace(entry.adj.SourceID),
118 + strings.TrimSpace(entry.adj.SourcePort),
119 + strings.TrimSpace(entry.adj.TargetID),
120 + strings.TrimSpace(entry.adj.TargetPort),
121 + }, keySep)
122 +}
123 +
124 +func backfillPairGroupMissingEndpointPorts(entries []*builtAdjacencyLink) {
125 + if len(entries) < 2 {
126 + return
127 + }
128 +
129 + directionToIndexes := make(map[string][]int, len(entries))
130 + for i, entry := range entries {
131 + if entry == nil {
132 + continue
133 + }
134 + src := strings.TrimSpace(entry.adj.SourceID)
135 + dst := strings.TrimSpace(entry.adj.TargetID)
136 + if src == "" || dst == "" {
137 + continue
138 + }
139 + key := src + keySep + dst
140 + directionToIndexes[key] = append(directionToIndexes[key], i)
141 + }
142 +
143 + for i, entry := range entries {
144 + if entry == nil {
145 + continue
146 + }
147 + src := strings.TrimSpace(entry.adj.SourceID)
148 + dst := strings.TrimSpace(entry.adj.TargetID)
149 + if src == "" || dst == "" {
150 + continue
151 + }
152 +
153 + reverseKey := dst + keySep + src
154 + candidates := directionToIndexes[reverseKey]
155 + if len(candidates) != 1 {
156 + continue
157 + }
158 +
159 + reverseEntry := entries[candidates[0]]
160 + if reverseEntry == nil || candidates[0] == i {
161 + continue
162 + }
163 +
164 + entry.link.Src = backfillEndpointPortFromPeer(entry.link.Src, reverseEntry.link.Dst)
165 + entry.link.Dst = backfillEndpointPortFromPeer(entry.link.Dst, reverseEntry.link.Src)
166 + }
167 +}
168 +
169 +func endpointHasKnownCanonicalPort(endpoint topology.LinkEndpoint) bool {
170 + return strings.TrimSpace(topologyCanonicalPortName(endpoint.Attributes)) != ""
171 +}
172 +
173 +func backfillEndpointPortFromPeer(endpoint topology.LinkEndpoint, peer topology.LinkEndpoint) topology.LinkEndpoint {
174 + if endpointHasKnownCanonicalPort(endpoint) || !endpointHasKnownCanonicalPort(peer) {
175 + return endpoint
176 + }
177 +
178 + attrs := cloneAnyMap(endpoint.Attributes)
179 + if attrs == nil {
180 + attrs = make(map[string]any)
181 + }
182 + peerAttrs := peer.Attributes
183 + if len(peerAttrs) == 0 {
184 + return endpoint
185 + }
186 +
187 + if topologyAttrInt(attrs, "if_index") <= 0 {
188 + if ifIndex := topologyAttrInt(peerAttrs, "if_index"); ifIndex > 0 {
189 + attrs["if_index"] = ifIndex
190 + }
191 + }
192 +
193 + copyIfMissing := func(key string) {
194 + if topologyAttrString(attrs, key) != "" {
195 + return
196 + }
197 + if value := topologyAttrString(peerAttrs, key); value != "" {
198 + attrs[key] = value
199 + }
200 + }
201 +
202 + copyIfMissing("if_name")
203 + copyIfMissing("if_descr")
204 + copyIfMissing("if_alias")
205 + copyIfMissing("port_id")
206 + copyIfMissing("port_name")
207 + copyIfMissing("bridge_port")
208 + copyIfMissing("if_admin_status")
209 + copyIfMissing("if_oper_status")
210 +
211 + endpoint.Attributes = pruneTopologyAttributes(attrs)
212 + return endpoint
213 +}
214 +
215 +func adjacencyToTopologyLink(
216 + adj Adjacency,
217 + protocol string,
218 + layer string,
219 + collectedAt time.Time,
220 + deviceByID map[string]Device,
221 + ifIndexByDeviceName map[string]int,
222 + ifaceByDeviceIndex map[string]Interface,
223 +) topology.Link {
224 + src := adjacencySideToEndpoint(deviceByID[adj.SourceID], adj.SourcePort, ifIndexByDeviceName, ifaceByDeviceIndex)
225 + dst := adjacencySideToEndpoint(deviceByID[adj.TargetID], adj.TargetPort, ifIndexByDeviceName, ifaceByDeviceIndex)
226 + if rawAddress := strings.TrimSpace(adj.Labels["remote_address_raw"]); rawAddress != "" {
227 + dst.Match.IPAddresses = uniqueTopologyStrings(append(dst.Match.IPAddresses, rawAddress))
228 + }
229 +
230 + link := topology.Link{
231 + Layer: layer,
232 + Protocol: protocol,
233 + LinkType: protocol,
234 + Direction: "unidirectional",
235 + Src: src,
236 + Dst: dst,
237 + DiscoveredAt: topologyTimePtr(collectedAt),
238 + LastSeen: topologyTimePtr(collectedAt),
239 + }
240 + if len(adj.Labels) > 0 {
241 + link.Metrics = mapStringStringToAny(adj.Labels)
242 + }
243 + return link
244 +}
245 +
246 +func buildPairedLinkMetrics(sourceLabels, targetLabels map[string]string) map[string]any {
247 + metrics := make(map[string]any)
248 +
249 + pairID := strings.TrimSpace(sourceLabels[adjacencyLabelPairID])
250 + if pairID == "" {
251 + pairID = strings.TrimSpace(targetLabels[adjacencyLabelPairID])
252 + }
253 + if pairID != "" {
254 + metrics[adjacencyLabelPairID] = pairID
255 + }
256 +
257 + pairPass := strings.TrimSpace(sourceLabels[adjacencyLabelPairPass])
258 + if pairPass == "" {
259 + pairPass = strings.TrimSpace(targetLabels[adjacencyLabelPairPass])
260 + }
261 + if pairPass != "" {
262 + metrics[adjacencyLabelPairPass] = pairPass
263 + }
264 + metrics["pair_consistent"] = true
265 +
266 + for key, value := range sourceLabels {
267 + key = strings.TrimSpace(key)
268 + value = strings.TrimSpace(value)
269 + if key == "" || value == "" || isPairLabelKey(key) {
270 + continue
271 + }
272 + metrics["src_"+key] = value
273 + }
274 + for key, value := range targetLabels {
275 + key = strings.TrimSpace(key)
276 + value = strings.TrimSpace(value)
277 + if key == "" || value == "" || isPairLabelKey(key) {
278 + continue
279 + }
280 + metrics["dst_"+key] = value
281 + }
282 +
283 + if len(metrics) == 0 {
284 + return nil
285 + }
286 + return metrics
287 +}
288 +
289 +func mergeEndpointIPHints(base, extra topology.LinkEndpoint) topology.LinkEndpoint {
290 + if len(extra.Match.IPAddresses) == 0 {
291 + return base
292 + }
293 + base.Match.IPAddresses = uniqueTopologyStrings(append(base.Match.IPAddresses, extra.Match.IPAddresses...))
294 + return base
295 +}
296 +
297 +func isPairLabelKey(key string) bool {
298 + return key == adjacencyLabelPairID || key == adjacencyLabelPairPass
299 +}
300 +
301 +func incrementProjectedProtocolCounters(out *projectedLinks, protocol string, bidirectional bool) {
302 + if out == nil {
303 + return
304 + }
305 + switch protocol {
306 + case "lldp":
307 + out.lldp++
308 + case "cdp":
309 + out.cdp++
310 + }
311 + if bidirectional {
312 + out.bidirectionalCount++
313 + return
314 + }
315 + out.unidirectionalCount++
316 +}
src/go/pkg/topology/engine/topology_adapter_pruning.go new
+280
@@ -0,0 +1,280 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/topology"
10 +)
11 +
12 +func pruneSegmentArtifacts(actors []topology.Actor, links []topology.Link) ([]topology.Actor, []topology.Link, int) {
13 + if len(actors) == 0 || len(links) == 0 {
14 + return actors, links, 0
15 + }
16 +
17 + segmentKeys := make(map[string]struct{})
18 + segmentOrder := make([]string, 0)
19 + for _, actor := range actors {
20 + if !strings.EqualFold(strings.TrimSpace(actor.ActorType), "segment") {
21 + continue
22 + }
23 + key := canonicalTopologyMatchKey(actor.Match)
24 + if key == "" {
25 + continue
26 + }
27 + if _, seen := segmentKeys[key]; seen {
28 + continue
29 + }
30 + segmentKeys[key] = struct{}{}
31 + segmentOrder = append(segmentOrder, key)
32 + }
33 + if len(segmentKeys) == 0 {
34 + return actors, links, 0
35 + }
36 + sort.Strings(segmentOrder)
37 +
38 + discoveryPairs := make(map[string]struct{})
39 + for _, link := range links {
40 + protocol := strings.ToLower(strings.TrimSpace(link.Protocol))
41 + if protocol != "lldp" && protocol != "cdp" {
42 + continue
43 + }
44 + src := canonicalTopologyMatchKey(link.Src.Match)
45 + dst := canonicalTopologyMatchKey(link.Dst.Match)
46 + if src == "" || dst == "" {
47 + continue
48 + }
49 + if _, srcSegment := segmentKeys[src]; srcSegment {
50 + continue
51 + }
52 + if _, dstSegment := segmentKeys[dst]; dstSegment {
53 + continue
54 + }
55 + if pair := topologyUndirectedPairKey(src, dst); pair != "" {
56 + discoveryPairs[pair] = struct{}{}
57 + }
58 + }
59 +
60 + suppressed := make(map[string]struct{})
61 + for {
62 + changed := false
63 + neighborsBySegment := make(map[string]map[string]struct{})
64 + for _, link := range links {
65 + src := canonicalTopologyMatchKey(link.Src.Match)
66 + dst := canonicalTopologyMatchKey(link.Dst.Match)
67 + if src == "" || dst == "" {
68 + continue
69 + }
70 + if _, srcSuppressed := suppressed[src]; srcSuppressed {
71 + continue
72 + }
73 + if _, dstSuppressed := suppressed[dst]; dstSuppressed {
74 + continue
75 + }
76 +
77 + _, srcSegment := segmentKeys[src]
78 + _, dstSegment := segmentKeys[dst]
79 +
80 + if srcSegment && !dstSegment {
81 + neighbors := neighborsBySegment[src]
82 + if neighbors == nil {
83 + neighbors = make(map[string]struct{})
84 + neighborsBySegment[src] = neighbors
85 + }
86 + neighbors[dst] = struct{}{}
87 + }
88 + if dstSegment && !srcSegment {
89 + neighbors := neighborsBySegment[dst]
90 + if neighbors == nil {
91 + neighbors = make(map[string]struct{})
92 + neighborsBySegment[dst] = neighbors
93 + }
94 + neighbors[src] = struct{}{}
95 + }
96 + }
97 +
98 + for _, segmentKey := range segmentOrder {
99 + if _, alreadySuppressed := suppressed[segmentKey]; alreadySuppressed {
100 + continue
101 + }
102 +
103 + neighbors := neighborsBySegment[segmentKey]
104 + if len(neighbors) < 2 {
105 + suppressed[segmentKey] = struct{}{}
106 + changed = true
107 + continue
108 + }
109 +
110 + if len(neighbors) == 2 {
111 + pairValues := make([]string, 0, 2)
112 + for neighbor := range neighbors {
113 + pairValues = append(pairValues, neighbor)
114 + }
115 + if len(pairValues) == 2 {
116 + if pair := topologyUndirectedPairKey(pairValues[0], pairValues[1]); pair != "" {
117 + if _, found := discoveryPairs[pair]; found {
118 + suppressed[segmentKey] = struct{}{}
119 + changed = true
120 + }
121 + }
122 + }
123 + }
124 + }
125 +
126 + if !changed {
127 + break
128 + }
129 + }
130 +
131 + if len(suppressed) == 0 {
132 + return actors, links, 0
133 + }
134 +
135 + filteredActors := make([]topology.Actor, 0, len(actors))
136 + for _, actor := range actors {
137 + key := canonicalTopologyMatchKey(actor.Match)
138 + if key == "" {
139 + filteredActors = append(filteredActors, actor)
140 + continue
141 + }
142 + if _, isSuppressed := suppressed[key]; isSuppressed && strings.EqualFold(strings.TrimSpace(actor.ActorType), "segment") {
143 + continue
144 + }
145 + filteredActors = append(filteredActors, actor)
146 + }
147 +
148 + filteredLinks := make([]topology.Link, 0, len(links))
149 + for _, link := range links {
150 + src := canonicalTopologyMatchKey(link.Src.Match)
151 + dst := canonicalTopologyMatchKey(link.Dst.Match)
152 + if src != "" {
153 + if _, srcSuppressed := suppressed[src]; srcSuppressed {
154 + continue
155 + }
156 + }
157 + if dst != "" {
158 + if _, dstSuppressed := suppressed[dst]; dstSuppressed {
159 + continue
160 + }
161 + }
162 + filteredLinks = append(filteredLinks, link)
163 + }
164 +
165 + return filteredActors, filteredLinks, len(suppressed)
166 +}
167 +
168 +func topologyUndirectedPairKey(left, right string) string {
169 + left = strings.TrimSpace(left)
170 + right = strings.TrimSpace(right)
171 + if left == "" || right == "" {
172 + return ""
173 + }
174 + if left <= right {
175 + return left + keySep + right
176 + }
177 + return right + keySep + left
178 +}
179 +
180 +type topologyLinkCounts struct {
181 + lldp int
182 + cdp int
183 + fdb int
184 + arp int
185 + bidirectional int
186 + unidirectional int
187 +}
188 +
189 +func summarizeTopologyLinks(links []topology.Link) topologyLinkCounts {
190 + var counts topologyLinkCounts
191 + for _, link := range links {
192 + switch strings.ToLower(strings.TrimSpace(link.Protocol)) {
193 + case "lldp":
194 + counts.lldp++
195 + case "cdp":
196 + counts.cdp++
197 + case "bridge", "fdb":
198 + counts.fdb++
199 + case "arp":
200 + counts.arp++
201 + }
202 +
203 + switch strings.ToLower(strings.TrimSpace(link.Direction)) {
204 + case "bidirectional":
205 + counts.bidirectional++
206 + case "unidirectional":
207 + counts.unidirectional++
208 + }
209 + }
210 + return counts
211 +}
212 +
213 +func pruneManagedOverlapUnlinkedEndpointActors(
214 + actors []topology.Actor,
215 + links []topology.Link,
216 + suppressedEndpointIDs map[string]struct{},
217 +) ([]topology.Actor, int) {
218 + if len(actors) == 0 || len(suppressedEndpointIDs) == 0 {
219 + return actors, 0
220 + }
221 +
222 + suppressedIdentityKeys := make(map[string]struct{})
223 + for endpointID := range suppressedEndpointIDs {
224 + endpointID = normalizeFDBEndpointID(endpointID)
225 + if endpointID == "" {
226 + continue
227 + }
228 + match := endpointMatchFromID(endpointID)
229 + for _, key := range topologyMatchIdentityKeys(match) {
230 + key = strings.TrimSpace(key)
231 + if key == "" {
232 + continue
233 + }
234 + suppressedIdentityKeys[key] = struct{}{}
235 + }
236 + }
237 + if len(suppressedIdentityKeys) == 0 {
238 + return actors, 0
239 + }
240 +
241 + linkedIdentityKeys := make(map[string]struct{}, len(links)*2)
242 + for _, link := range links {
243 + for _, key := range topologyMatchIdentityKeys(link.Src.Match) {
244 + key = strings.TrimSpace(key)
245 + if key == "" {
246 + continue
247 + }
248 + linkedIdentityKeys[key] = struct{}{}
249 + }
250 + for _, key := range topologyMatchIdentityKeys(link.Dst.Match) {
251 + key = strings.TrimSpace(key)
252 + if key == "" {
253 + continue
254 + }
255 + linkedIdentityKeys[key] = struct{}{}
256 + }
257 + }
258 +
259 + filtered := make([]topology.Actor, 0, len(actors))
260 + suppressedCount := 0
261 + for _, actor := range actors {
262 + if !strings.EqualFold(strings.TrimSpace(actor.ActorType), "endpoint") {
263 + filtered = append(filtered, actor)
264 + continue
265 + }
266 +
267 + actorKeys := topologyMatchIdentityKeys(actor.Match)
268 + if !topologyIdentityKeysOverlap(actorKeys, suppressedIdentityKeys) {
269 + filtered = append(filtered, actor)
270 + continue
271 + }
272 + // Keep endpoint actors that still participate in at least one emitted link.
273 + if topologyIdentityKeysOverlap(actorKeys, linkedIdentityKeys) {
274 + filtered = append(filtered, actor)
275 + continue
276 + }
277 + suppressedCount++
278 + }
279 + return filtered, suppressedCount
280 +}
src/go/pkg/topology/engine/topology_adapter_regression_test.go new
+173
@@ -0,0 +1,173 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/netdata/netdata/go/plugins/pkg/topology"
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestBackfillPairGroupMissingEndpointPortsCopiesPeerInterfaceAttributes(t *testing.T) {
13 + entries := []*builtAdjacencyLink{
14 + {
15 + adj: Adjacency{
16 + SourceID: "device-a",
17 + TargetID: "device-b",
18 + },
19 + link: topology.Link{
20 + Src: topology.LinkEndpoint{Attributes: map[string]any{}},
21 + Dst: topology.LinkEndpoint{Attributes: map[string]any{}},
22 + },
23 + },
24 + {
25 + adj: Adjacency{
26 + SourceID: "device-b",
27 + TargetID: "device-a",
28 + },
29 + link: topology.Link{
30 + Src: topology.LinkEndpoint{Attributes: map[string]any{
31 + "if_index": 2,
32 + "if_name": "Gi0/2",
33 + "port_id": "Gi0/2",
34 + }},
35 + Dst: topology.LinkEndpoint{Attributes: map[string]any{
36 + "if_index": 1,
37 + "if_name": "Gi0/1",
38 + "port_id": "Gi0/1",
39 + }},
40 + },
41 + },
42 + }
43 +
44 + backfillPairGroupMissingEndpointPorts(entries)
45 +
46 + require.Equal(t, 1, topologyAttrInt(entries[0].link.Src.Attributes, "if_index"))
47 + require.Equal(t, "Gi0/1", topologyAttrString(entries[0].link.Src.Attributes, "if_name"))
48 + require.Equal(t, "Gi0/1", topologyAttrString(entries[0].link.Src.Attributes, "port_id"))
49 + require.Equal(t, 2, topologyAttrInt(entries[0].link.Dst.Attributes, "if_index"))
50 + require.Equal(t, "Gi0/2", topologyAttrString(entries[0].link.Dst.Attributes, "if_name"))
51 + require.Equal(t, "Gi0/2", topologyAttrString(entries[0].link.Dst.Attributes, "port_id"))
52 +}
53 +
54 +func TestBackfillPairGroupMissingEndpointPortsSkipsAmbiguousReverseCandidates(t *testing.T) {
55 + entries := []*builtAdjacencyLink{
56 + {
57 + adj: Adjacency{
58 + SourceID: "device-a",
59 + TargetID: "device-b",
60 + },
61 + link: topology.Link{
62 + Src: topology.LinkEndpoint{Attributes: map[string]any{}},
63 + Dst: topology.LinkEndpoint{Attributes: map[string]any{}},
64 + },
65 + },
66 + {
67 + adj: Adjacency{
68 + SourceID: "device-b",
69 + TargetID: "device-a",
70 + },
71 + link: topology.Link{
72 + Src: topology.LinkEndpoint{Attributes: map[string]any{
73 + "if_name": "Gi0/2",
74 + }},
75 + Dst: topology.LinkEndpoint{Attributes: map[string]any{
76 + "if_name": "Gi0/1",
77 + }},
78 + },
79 + },
80 + {
81 + adj: Adjacency{
82 + SourceID: "device-b",
83 + TargetID: "device-a",
84 + },
85 + link: topology.Link{
86 + Src: topology.LinkEndpoint{Attributes: map[string]any{
87 + "if_name": "Gi0/22",
88 + }},
89 + Dst: topology.LinkEndpoint{Attributes: map[string]any{
90 + "if_name": "Gi0/11",
91 + }},
92 + },
93 + },
94 + }
95 +
96 + backfillPairGroupMissingEndpointPorts(entries)
97 +
98 + require.Equal(t, "", topologyAttrString(entries[0].link.Src.Attributes, "if_name"))
99 + require.Equal(t, "", topologyAttrString(entries[0].link.Dst.Attributes, "if_name"))
100 +}
101 +
102 +func TestBackfillEndpointPortFromPeerPreservesExistingCanonicalPort(t *testing.T) {
103 + endpoint := topology.LinkEndpoint{
104 + Attributes: map[string]any{
105 + "if_name": "Gi0/10",
106 + },
107 + }
108 + peer := topology.LinkEndpoint{
109 + Attributes: map[string]any{
110 + "if_index": 7,
111 + "if_name": "Gi0/7",
112 + "port_id": "Gi0/7",
113 + },
114 + }
115 +
116 + backfilled := backfillEndpointPortFromPeer(endpoint, peer)
117 +
118 + require.Equal(t, "Gi0/10", topologyAttrString(backfilled.Attributes, "if_name"))
119 + require.Zero(t, topologyAttrInt(backfilled.Attributes, "if_index"))
120 + require.Equal(t, "", topologyAttrString(backfilled.Attributes, "port_id"))
121 +}
122 +
123 +func TestSegmentProjectionBuilderPruneSegmentsWithoutLinksRemovesEmptySegments(t *testing.T) {
124 + builder := &segmentProjectionBuilder{
125 + segmentIDs: []string{"segment-a", "segment-b"},
126 + out: projectedSegments{
127 + actors: []topology.Actor{
128 + {
129 + ActorID: "segment-a",
130 + ActorType: "segment",
131 + Attributes: map[string]any{
132 + "segment_id": "segment-a",
133 + },
134 + },
135 + {
136 + ActorID: "segment-b",
137 + ActorType: "segment",
138 + Attributes: map[string]any{
139 + "segment_id": "segment-b",
140 + },
141 + },
142 + },
143 + links: []topology.Link{
144 + {
145 + Protocol: "fdb",
146 + Direction: "bidirectional",
147 + Metrics: map[string]any{
148 + "bridge_domain": "segment-a",
149 + },
150 + },
151 + {
152 + Protocol: "fdb",
153 + Direction: "bidirectional",
154 + Metrics: map[string]any{
155 + "bridge_domain": "segment-b",
156 + },
157 + },
158 + },
159 + },
160 + }
161 +
162 + builder.pruneSegmentsWithoutLinks(map[string]struct{}{
163 + "segment-a": {},
164 + })
165 +
166 + require.Len(t, builder.out.actors, 1)
167 + require.Equal(t, "segment-a", builder.out.actors[0].ActorID)
168 + require.Len(t, builder.out.links, 1)
169 + require.Equal(t, "segment-a", topologyMetricString(builder.out.links[0].Metrics, "bridge_domain"))
170 + require.Equal(t, 1, builder.out.linksFdb)
171 + require.Equal(t, 1, builder.out.bidirectionalCount)
172 + require.Equal(t, 1, builder.out.endpointLinksEmitted)
173 +}
src/go/pkg/topology/engine/topology_adapter_reporter_hints.go new
+239
@@ -0,0 +1,239 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strconv"
8 + "strings"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/topology"
11 +)
12 +
13 +func collectTopologyEndpointIDs(
14 + endpointMatchByID map[string]topology.Match,
15 + endpointLabelsByID map[string]map[string]string,
16 + endpointSegmentCandidates map[string][]string,
17 + rawFDBObservations fdbReporterObservation,
18 + filteredFDBObservations fdbReporterObservation,
19 +) []string {
20 + set := make(map[string]struct{})
21 + for endpointID := range endpointMatchByID {
22 + endpointID = strings.TrimSpace(endpointID)
23 + if endpointID != "" {
24 + set[endpointID] = struct{}{}
25 + }
26 + }
27 + for endpointID := range endpointLabelsByID {
28 + endpointID = strings.TrimSpace(endpointID)
29 + if endpointID != "" {
30 + set[endpointID] = struct{}{}
31 + }
32 + }
33 + for endpointID := range endpointSegmentCandidates {
34 + endpointID = strings.TrimSpace(endpointID)
35 + if endpointID != "" {
36 + set[endpointID] = struct{}{}
37 + }
38 + }
39 + for endpointID := range rawFDBObservations.byEndpoint {
40 + endpointID = strings.TrimSpace(endpointID)
41 + if endpointID != "" {
42 + set[endpointID] = struct{}{}
43 + }
44 + }
45 + for endpointID := range filteredFDBObservations.byEndpoint {
46 + endpointID = strings.TrimSpace(endpointID)
47 + if endpointID != "" {
48 + set[endpointID] = struct{}{}
49 + }
50 + }
51 + return sortedTopologySet(set)
52 +}
53 +
54 +func buildFDBEndpointReporterHints(macLinks []bridgeMacLinkRecord) map[string]map[string][]bridgePortRef {
55 + if len(macLinks) == 0 {
56 + return nil
57 + }
58 +
59 + byEndpointReporterPorts := make(map[string]map[string]map[string]bridgePortRef)
60 + for _, link := range macLinks {
61 + if strings.ToLower(strings.TrimSpace(link.method)) != "fdb" {
62 + continue
63 + }
64 + endpointID := normalizeFDBEndpointID(link.endpointID)
65 + reporterID := strings.TrimSpace(link.port.deviceID)
66 + portKey := bridgePortObservationVLANKey(link.port)
67 + if endpointID == "" || reporterID == "" || portKey == "" {
68 + continue
69 + }
70 + reporters := byEndpointReporterPorts[endpointID]
71 + if reporters == nil {
72 + reporters = make(map[string]map[string]bridgePortRef)
73 + byEndpointReporterPorts[endpointID] = reporters
74 + }
75 + ports := reporters[reporterID]
76 + if ports == nil {
77 + ports = make(map[string]bridgePortRef)
78 + reporters[reporterID] = ports
79 + }
80 + ports[portKey] = link.port
81 + }
82 +
83 + out := make(map[string]map[string][]bridgePortRef, len(byEndpointReporterPorts))
84 + for endpointID, reporters := range byEndpointReporterPorts {
85 + reporterHints := make(map[string][]bridgePortRef, len(reporters))
86 + reporterIDs := make([]string, 0, len(reporters))
87 + for reporterID := range reporters {
88 + reporterIDs = append(reporterIDs, reporterID)
89 + }
90 + sort.Strings(reporterIDs)
91 + for _, reporterID := range reporterIDs {
92 + portsMap := reporters[reporterID]
93 + if len(portsMap) == 0 {
94 + continue
95 + }
96 + portKeys := make([]string, 0, len(portsMap))
97 + for key := range portsMap {
98 + portKeys = append(portKeys, key)
99 + }
100 + sort.Strings(portKeys)
101 + ports := make([]bridgePortRef, 0, len(portKeys))
102 + for _, key := range portKeys {
103 + ports = append(ports, portsMap[key])
104 + }
105 + reporterHints[reporterID] = ports
106 + }
107 + if len(reporterHints) > 0 {
108 + out[endpointID] = reporterHints
109 + }
110 + }
111 + if len(out) == 0 {
112 + return nil
113 + }
114 + return out
115 +}
116 +
117 +func buildSegmentReporterIndex(
118 + segmentIDs []string,
119 + segmentByID map[string]*bridgeDomainSegment,
120 +) segmentReporterIndex {
121 + index := segmentReporterIndex{
122 + byDevice: make(map[string]map[string]struct{}),
123 + byDeviceIfIndex: make(map[string]map[string]struct{}),
124 + byDeviceIfName: make(map[string]map[string]struct{}),
125 + }
126 + for _, segmentID := range segmentIDs {
127 + segment := segmentByID[segmentID]
128 + if segment == nil {
129 + continue
130 + }
131 + for _, port := range segment.ports {
132 + deviceID := strings.TrimSpace(port.deviceID)
133 + if deviceID == "" {
134 + continue
135 + }
136 + addStringSet(index.byDevice, deviceID, segmentID)
137 + if port.ifIndex > 0 {
138 + addStringSet(index.byDeviceIfIndex, deviceID+keySep+strconv.Itoa(port.ifIndex), segmentID)
139 + }
140 + if ifName := strings.ToLower(strings.TrimSpace(port.ifName)); ifName != "" {
141 + addStringSet(index.byDeviceIfName, deviceID+keySep+ifName, segmentID)
142 + }
143 + }
144 + }
145 + return index
146 +}
147 +
148 +func addStringSet(out map[string]map[string]struct{}, key string, value string) {
149 + key = strings.TrimSpace(key)
150 + value = strings.TrimSpace(value)
151 + if key == "" || value == "" {
152 + return
153 + }
154 + set := out[key]
155 + if set == nil {
156 + set = make(map[string]struct{})
157 + out[key] = set
158 + }
159 + set[value] = struct{}{}
160 +}
161 +
162 +func probableCandidateSegmentsFromReporterHints(
163 + endpointLabels map[string]string,
164 + fdbReporters map[string]map[string]struct{},
165 + reporterSegmentIndex segmentReporterIndex,
166 + aliasOwnerIDs map[string]map[string]struct{},
167 + managedDeviceIDs map[string]struct{},
168 +) []string {
169 + deviceIDs := resolveTopologyEndpointDeviceHints(
170 + topologyEndpointLabelDeviceIDs(endpointLabels),
171 + aliasOwnerIDs,
172 + )
173 + if len(deviceIDs) == 0 {
174 + for reporterID := range fdbReporters {
175 + reporterID = strings.TrimSpace(reporterID)
176 + if reporterID == "" {
177 + continue
178 + }
179 + deviceIDs = append(deviceIDs, reporterID)
180 + }
181 + deviceIDs = resolveTopologyEndpointDeviceHints(deviceIDs, aliasOwnerIDs)
182 + }
183 + deviceIDs = filterManagedDeviceHints(deviceIDs, managedDeviceIDs)
184 + if len(deviceIDs) == 0 {
185 + return nil
186 + }
187 +
188 + ifIndexes := labelsCSVToSlice(endpointLabels, "learned_if_indexes")
189 + ifNames := labelsCSVToSlice(endpointLabels, "learned_if_names")
190 + hasPortHints := false
191 + for _, ifIndex := range ifIndexes {
192 + if strings.TrimSpace(ifIndex) != "" {
193 + hasPortHints = true
194 + break
195 + }
196 + }
197 + if !hasPortHints {
198 + for _, ifName := range ifNames {
199 + if strings.TrimSpace(ifName) != "" {
200 + hasPortHints = true
201 + break
202 + }
203 + }
204 + }
205 + candidateSet := make(map[string]struct{})
206 +
207 + for _, deviceID := range deviceIDs {
208 + for _, ifIndex := range ifIndexes {
209 + ifIndex = strings.TrimSpace(ifIndex)
210 + if ifIndex == "" {
211 + continue
212 + }
213 + for segmentID := range reporterSegmentIndex.byDeviceIfIndex[deviceID+keySep+ifIndex] {
214 + candidateSet[segmentID] = struct{}{}
215 + }
216 + }
217 + for _, ifName := range ifNames {
218 + ifName = strings.ToLower(strings.TrimSpace(ifName))
219 + if ifName == "" {
220 + continue
221 + }
222 + for segmentID := range reporterSegmentIndex.byDeviceIfName[deviceID+keySep+ifName] {
223 + candidateSet[segmentID] = struct{}{}
224 + }
225 + }
226 + }
227 +
228 + if len(candidateSet) == 0 && !hasPortHints {
229 + for _, deviceID := range deviceIDs {
230 + for segmentID := range reporterSegmentIndex.byDevice[deviceID] {
231 + candidateSet[segmentID] = struct{}{}
232 + }
233 + }
234 + }
235 + if len(candidateSet) == 0 {
236 + return nil
237 + }
238 + return sortedTopologySet(candidateSet)
239 +}
src/go/pkg/topology/engine/topology_adapter_segment_actor.go new
+225
@@ -0,0 +1,225 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strconv"
8 + "strings"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/topology"
11 +)
12 +
13 +func buildBridgeSegmentActor(segmentID string, segment *bridgeDomainSegment, layer string, source string) (topology.Match, topology.Actor) {
14 + parentDevices := make(map[string]struct{})
15 + ifNames := make(map[string]struct{})
16 + ifIndexes := make(map[string]struct{})
17 + bridgePorts := make(map[string]struct{})
18 + vlanIDs := make(map[string]struct{})
19 + if segment != nil {
20 + for _, port := range segment.ports {
21 + if strings.TrimSpace(port.deviceID) != "" {
22 + parentDevices[port.deviceID] = struct{}{}
23 + }
24 + if strings.TrimSpace(port.ifName) != "" {
25 + ifNames[port.ifName] = struct{}{}
26 + }
27 + if port.ifIndex > 0 {
28 + ifIndexes[strconv.Itoa(port.ifIndex)] = struct{}{}
29 + }
30 + if strings.TrimSpace(port.bridgePort) != "" {
31 + bridgePorts[port.bridgePort] = struct{}{}
32 + }
33 + if strings.TrimSpace(port.vlanID) != "" {
34 + vlanIDs[port.vlanID] = struct{}{}
35 + }
36 + }
37 + }
38 +
39 + match := topology.Match{
40 + Hostnames: []string{"segment:" + segmentID},
41 + }
42 +
43 + attrs := map[string]any{
44 + "segment_id": segmentID,
45 + "segment_type": "broadcast_domain",
46 + "parent_devices": sortedTopologySet(parentDevices),
47 + "if_names": sortedTopologySet(ifNames),
48 + "if_indexes": sortedTopologySet(ifIndexes),
49 + "bridge_ports": sortedTopologySet(bridgePorts),
50 + "vlan_ids": sortedTopologySet(vlanIDs),
51 + "ports_total": 0,
52 + "endpoints_total": 0,
53 + }
54 + if segment != nil {
55 + attrs["learned_sources"] = sortedTopologySet(segment.methods)
56 + attrs["ports_total"] = len(segment.ports)
57 + attrs["endpoints_total"] = len(segment.endpointIDs)
58 + if bridgePortRefKey(segment.designatedPort, false, false) != "" {
59 + attrs["designated_port"] = bridgePortRefSortKey(segment.designatedPort)
60 + }
61 + }
62 +
63 + actor := topology.Actor{
64 + ActorType: "segment",
65 + Layer: layer,
66 + Source: source,
67 + Match: match,
68 + Attributes: pruneTopologyAttributes(attrs),
69 + Labels: map[string]string{
70 + "segment_kind": "broadcast_domain",
71 + },
72 + }
73 +
74 + return match, actor
75 +}
76 +
77 +func endpointMatchFromID(endpointID string) topology.Match {
78 + kind, value, ok := strings.Cut(strings.TrimSpace(endpointID), ":")
79 + if !ok {
80 + return topology.Match{}
81 + }
82 + switch strings.ToLower(strings.TrimSpace(kind)) {
83 + case "mac":
84 + mac := normalizeMAC(value)
85 + if mac == "" {
86 + return topology.Match{}
87 + }
88 + return topology.Match{
89 + ChassisIDs: []string{mac},
90 + MacAddresses: []string{mac},
91 + }
92 + case "ip":
93 + addr := normalizeTopologyIP(value)
94 + if addr == "" {
95 + return topology.Match{}
96 + }
97 + return topology.Match{
98 + IPAddresses: []string{addr},
99 + }
100 + }
101 + return topology.Match{}
102 +}
103 +
104 +func annotateEndpointActorsWithDirectOwners(
105 + actors []topology.Actor,
106 + endpointMatchByID map[string]topology.Match,
107 + owners map[string]fdbEndpointOwner,
108 + deviceByID map[string]Device,
109 +) {
110 + if len(actors) == 0 || len(owners) == 0 {
111 + return
112 + }
113 +
114 + ownerByMatchKey := make(map[string]fdbEndpointOwner, len(owners))
115 + endpointIDs := make([]string, 0, len(owners))
116 + for endpointID := range owners {
117 + endpointIDs = append(endpointIDs, endpointID)
118 + }
119 + sort.Strings(endpointIDs)
120 +
121 + for _, endpointID := range endpointIDs {
122 + owner := owners[endpointID]
123 + if !strings.EqualFold(strings.TrimSpace(owner.source), "single_port_mac") {
124 + continue
125 + }
126 + match, ok := endpointMatchByID[endpointID]
127 + if !ok {
128 + match = endpointMatchFromID(endpointID)
129 + }
130 + key := canonicalTopologyMatchKey(match)
131 + if key == "" {
132 + continue
133 + }
134 + ownerByMatchKey[key] = owner
135 + }
136 +
137 + if len(ownerByMatchKey) == 0 {
138 + return
139 + }
140 +
141 + for i := range actors {
142 + actor := &actors[i]
143 + if !strings.EqualFold(strings.TrimSpace(actor.ActorType), "endpoint") {
144 + continue
145 + }
146 + key := canonicalTopologyMatchKey(actor.Match)
147 + if key == "" {
148 + continue
149 + }
150 + owner, ok := ownerByMatchKey[key]
151 + if !ok {
152 + continue
153 + }
154 +
155 + attrs := cloneAnyMap(actor.Attributes)
156 + if attrs == nil {
157 + attrs = make(map[string]any)
158 + }
159 + labels := cloneStringMap(actor.Labels)
160 + if labels == nil {
161 + labels = make(map[string]string)
162 + }
163 +
164 + deviceID := strings.TrimSpace(owner.port.deviceID)
165 + port := bridgePortDisplay(owner.port)
166 + ifName := strings.TrimSpace(owner.port.ifName)
167 + bridgePort := strings.TrimSpace(owner.port.bridgePort)
168 + vlanID := strings.TrimSpace(owner.port.vlanID)
169 +
170 + attrs["attachment_source"] = "single_port_mac"
171 + if deviceID != "" {
172 + attrs["attached_device_id"] = deviceID
173 + labels["attached_device_id"] = deviceID
174 + }
175 + if port != "" {
176 + attrs["attached_port"] = port
177 + labels["attached_port"] = port
178 + }
179 + if ifName != "" {
180 + attrs["attached_if_name"] = ifName
181 + }
182 + if owner.port.ifIndex > 0 {
183 + attrs["attached_if_index"] = owner.port.ifIndex
184 + }
185 + if bridgePort != "" {
186 + attrs["attached_bridge_port"] = bridgePort
187 + }
188 + if vlanID != "" {
189 + attrs["attached_vlan"] = vlanID
190 + attrs["attached_vlan_id"] = vlanID
191 + }
192 + if device, ok := deviceByID[deviceID]; ok {
193 + display := strings.TrimSpace(device.Hostname)
194 + if display == "" {
195 + display = deviceID
196 + }
197 + if display != "" {
198 + attrs["attached_device"] = display
199 + labels["attached_device"] = display
200 + }
201 + }
202 + labels["attached_by"] = "single_port_mac"
203 +
204 + actor.Attributes = pruneTopologyAttributes(attrs)
205 + if len(labels) > 0 {
206 + actor.Labels = labels
207 + }
208 + }
209 +}
210 +
211 +func segmentContainsDevice(segment *bridgeDomainSegment, deviceID string) bool {
212 + if segment == nil {
213 + return false
214 + }
215 + deviceID = strings.TrimSpace(deviceID)
216 + if deviceID == "" {
217 + return false
218 + }
219 + for _, port := range segment.ports {
220 + if strings.EqualFold(strings.TrimSpace(port.deviceID), deviceID) {
221 + return true
222 + }
223 + }
224 + return false
225 +}
src/go/pkg/topology/engine/topology_adapter_segment_hints.go new
+216
@@ -0,0 +1,216 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strconv"
8 + "strings"
9 +)
10 +
11 +func selectProbableEndpointReporterHint(
12 + endpointLabels map[string]string,
13 + reporterHints map[string][]bridgePortRef,
14 + owner fdbEndpointOwner,
15 + aliasOwnerIDs map[string]map[string]struct{},
16 + managedDeviceIDs map[string]struct{},
17 +) probableEndpointReporterHint {
18 + ownerDeviceID := strings.TrimSpace(owner.port.deviceID)
19 + if ownerDeviceID != "" {
20 + if len(managedDeviceIDs) == 0 {
21 + return probableEndpointReporterHint{
22 + deviceID: ownerDeviceID,
23 + ifIndex: owner.port.ifIndex,
24 + ifName: strings.TrimSpace(owner.port.ifName),
25 + }
26 + }
27 + if _, ok := managedDeviceIDs[ownerDeviceID]; ok {
28 + return probableEndpointReporterHint{
29 + deviceID: ownerDeviceID,
30 + ifIndex: owner.port.ifIndex,
31 + ifName: strings.TrimSpace(owner.port.ifName),
32 + }
33 + }
34 + }
35 +
36 + deviceIDs := resolveTopologyEndpointDeviceHints(
37 + topologyEndpointLabelDeviceIDs(endpointLabels),
38 + aliasOwnerIDs,
39 + )
40 + if len(deviceIDs) == 0 {
41 + for reporterID := range reporterHints {
42 + reporterID = strings.TrimSpace(reporterID)
43 + if reporterID == "" {
44 + continue
45 + }
46 + deviceIDs = append(deviceIDs, reporterID)
47 + }
48 + deviceIDs = resolveTopologyEndpointDeviceHints(deviceIDs, aliasOwnerIDs)
49 + }
50 + deviceIDs = filterManagedDeviceHints(deviceIDs, managedDeviceIDs)
51 + if len(deviceIDs) == 0 {
52 + return probableEndpointReporterHint{}
53 + }
54 +
55 + ifIndexes := labelsCSVToSlice(endpointLabels, "learned_if_indexes")
56 + ifNames := labelsCSVToSlice(endpointLabels, "learned_if_names")
57 + parsedIfIndex := 0
58 + for _, value := range ifIndexes {
59 + value = strings.TrimSpace(value)
60 + if value == "" {
61 + continue
62 + }
63 + if n, err := strconv.Atoi(value); err == nil && n > 0 {
64 + parsedIfIndex = n
65 + break
66 + }
67 + }
68 + parsedIfName := ""
69 + for _, value := range ifNames {
70 + value = strings.TrimSpace(value)
71 + if value != "" {
72 + parsedIfName = value
73 + break
74 + }
75 + }
76 +
77 + selectedDeviceID := deviceIDs[0]
78 + for _, deviceID := range deviceIDs {
79 + if len(reporterHints[deviceID]) > 0 {
80 + selectedDeviceID = deviceID
81 + break
82 + }
83 + }
84 + hint := probableEndpointReporterHint{
85 + deviceID: selectedDeviceID,
86 + ifIndex: parsedIfIndex,
87 + ifName: parsedIfName,
88 + }
89 + if ports := reporterHints[selectedDeviceID]; len(ports) > 0 {
90 + sort.SliceStable(ports, func(i, j int) bool {
91 + return bridgePortRefSortKey(ports[i]) < bridgePortRefSortKey(ports[j])
92 + })
93 + port := ports[0]
94 + if hint.ifIndex == 0 && port.ifIndex > 0 {
95 + hint.ifIndex = port.ifIndex
96 + }
97 + if strings.TrimSpace(hint.ifName) == "" && strings.TrimSpace(port.ifName) != "" {
98 + hint.ifName = strings.TrimSpace(port.ifName)
99 + }
100 + }
101 +
102 + if hint.ifIndex == 0 && strings.TrimSpace(hint.ifName) == "" {
103 + hint.ifName = "0"
104 + }
105 + return hint
106 +}
107 +
108 +func ensureManagedProbableReporterHint(
109 + hint probableEndpointReporterHint,
110 + endpointLabels map[string]string,
111 + reporterHints map[string][]bridgePortRef,
112 + aliasOwnerIDs map[string]map[string]struct{},
113 + managedDeviceIDs map[string]struct{},
114 + managedDeviceIDList []string,
115 +) probableEndpointReporterHint {
116 + if len(managedDeviceIDs) == 0 {
117 + return hint
118 + }
119 + deviceID := strings.TrimSpace(hint.deviceID)
120 + if deviceID != "" {
121 + if _, ok := managedDeviceIDs[deviceID]; ok {
122 + return hint
123 + }
124 + }
125 +
126 + deviceIDs := resolveTopologyEndpointDeviceHints(
127 + topologyEndpointLabelDeviceIDs(endpointLabels),
128 + aliasOwnerIDs,
129 + )
130 + if len(deviceIDs) == 0 {
131 + for reporterID := range reporterHints {
132 + reporterID = strings.TrimSpace(reporterID)
133 + if reporterID == "" {
134 + continue
135 + }
136 + deviceIDs = append(deviceIDs, reporterID)
137 + }
138 + deviceIDs = resolveTopologyEndpointDeviceHints(deviceIDs, aliasOwnerIDs)
139 + }
140 + deviceIDs = filterManagedDeviceHints(deviceIDs, managedDeviceIDs)
141 + if len(deviceIDs) == 0 {
142 + deviceIDs = managedDeviceIDList
143 + }
144 + if len(deviceIDs) == 0 {
145 + return probableEndpointReporterHint{}
146 + }
147 +
148 + hint.deviceID = strings.TrimSpace(deviceIDs[0])
149 +
150 + ports := reporterHints[hint.deviceID]
151 + if len(ports) > 0 {
152 + sort.SliceStable(ports, func(i, j int) bool {
153 + return bridgePortRefSortKey(ports[i]) < bridgePortRefSortKey(ports[j])
154 + })
155 + port := ports[0]
156 + if hint.ifIndex == 0 && port.ifIndex > 0 {
157 + hint.ifIndex = port.ifIndex
158 + }
159 + if strings.TrimSpace(hint.ifName) == "" && strings.TrimSpace(port.ifName) != "" {
160 + hint.ifName = strings.TrimSpace(port.ifName)
161 + }
162 + }
163 + if hint.ifIndex == 0 {
164 + for _, value := range labelsCSVToSlice(endpointLabels, "learned_if_indexes") {
165 + value = strings.TrimSpace(value)
166 + if value == "" {
167 + continue
168 + }
169 + if n, err := strconv.Atoi(value); err == nil && n > 0 {
170 + hint.ifIndex = n
171 + break
172 + }
173 + }
174 + }
175 + if strings.TrimSpace(hint.ifName) == "" {
176 + for _, value := range labelsCSVToSlice(endpointLabels, "learned_if_names") {
177 + value = strings.TrimSpace(value)
178 + if value == "" {
179 + continue
180 + }
181 + hint.ifName = value
182 + break
183 + }
184 + }
185 + if hint.ifIndex == 0 && strings.TrimSpace(hint.ifName) == "" {
186 + hint.ifName = "0"
187 + }
188 + return hint
189 +}
190 +
191 +func ensureProbablePortlessSegment(
192 + segmentByID map[string]*bridgeDomainSegment,
193 + hint probableEndpointReporterHint,
194 +) (string, bool) {
195 + deviceID := strings.TrimSpace(hint.deviceID)
196 + if deviceID == "" {
197 + return "", false
198 + }
199 + port := bridgePortRef{
200 + deviceID: deviceID,
201 + ifIndex: hint.ifIndex,
202 + ifName: strings.TrimSpace(hint.ifName),
203 + }
204 + if port.ifIndex == 0 && strings.TrimSpace(port.ifName) == "" {
205 + port.ifName = "0"
206 + }
207 +
208 + segmentID := "bridge-domain:probable:" + bridgePortRefSortKey(port)
209 + if _, ok := segmentByID[segmentID]; ok {
210 + return segmentID, false
211 + }
212 + segment := newBridgeDomainSegment(port)
213 + segment.methods["probable"] = struct{}{}
214 + segmentByID[segmentID] = segment
215 + return segmentID, true
216 +}
src/go/pkg/topology/engine/topology_adapter_segments.go new
+205
@@ -0,0 +1,205 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 + "time"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/topology"
11 +)
12 +
13 +func projectSegmentTopology(
14 + attachments []Attachment,
15 + adjacencies []Adjacency,
16 + layer string,
17 + source string,
18 + collectedAt time.Time,
19 + deviceByID map[string]Device,
20 + ifaceByDeviceIndex map[string]Interface,
21 + ifIndexByDeviceName map[string]int,
22 + bridgeLinks []bridgeBridgeLinkRecord,
23 + reporterAliases map[string][]string,
24 + endpointMatchByID map[string]topology.Match,
25 + endpointLabelsByID map[string]map[string]string,
26 + actorIndex map[string]struct{},
27 + probabilisticConnectivity bool,
28 + strategyConfig topologyInferenceStrategyConfig,
29 +) projectedSegments {
30 + return newSegmentProjectionBuilder(
31 + attachments,
32 + adjacencies,
33 + layer,
34 + source,
35 + collectedAt,
36 + deviceByID,
37 + ifaceByDeviceIndex,
38 + ifIndexByDeviceName,
39 + bridgeLinks,
40 + reporterAliases,
41 + endpointMatchByID,
42 + endpointLabelsByID,
43 + actorIndex,
44 + probabilisticConnectivity,
45 + strategyConfig,
46 + ).build()
47 +}
48 +
49 +func pickProbableSegmentAnchorPortID(
50 + segment *bridgeDomainSegment,
51 + probableEndpoints map[string]struct{},
52 + fdbOwners map[string]fdbEndpointOwner,
53 + managedDeviceIDs map[string]struct{},
54 +) string {
55 + if segment == nil || len(segment.ports) == 0 {
56 + return ""
57 + }
58 +
59 + portIDs := make([]string, 0, len(segment.ports))
60 + portIDByObservation := make(map[string]string, len(segment.ports)*2)
61 + designatedPortID := ""
62 + managedPortIDs := make(map[string]struct{})
63 + for portID, port := range segment.ports {
64 + portIDs = append(portIDs, portID)
65 + if key := bridgePortObservationKey(port); key != "" {
66 + portIDByObservation[key] = portID
67 + }
68 + if key := bridgePortObservationVLANKey(port); key != "" {
69 + portIDByObservation[key] = portID
70 + }
71 + if segment.portIdentityKey(port) == segment.portIdentityKey(segment.designatedPort) {
72 + designatedPortID = portID
73 + }
74 + if len(managedDeviceIDs) == 0 {
75 + managedPortIDs[portID] = struct{}{}
76 + continue
77 + }
78 + if _, ok := managedDeviceIDs[strings.TrimSpace(port.deviceID)]; ok {
79 + managedPortIDs[portID] = struct{}{}
80 + }
81 + }
82 + sort.Strings(portIDs)
83 + preferManaged := len(managedPortIDs) > 0
84 + allowPortID := func(portID string) bool {
85 + if !preferManaged {
86 + return true
87 + }
88 + _, ok := managedPortIDs[portID]
89 + return ok
90 + }
91 +
92 + endpointIDs := sortedTopologySet(probableEndpoints)
93 + for _, endpointID := range endpointIDs {
94 + owner, ok := fdbOwners[endpointID]
95 + if !ok {
96 + continue
97 + }
98 + if portID, ok := portIDByObservation[owner.portVLANKey]; ok {
99 + if allowPortID(portID) {
100 + return portID
101 + }
102 + }
103 + if portID, ok := portIDByObservation[owner.portKey]; ok {
104 + if allowPortID(portID) {
105 + return portID
106 + }
107 + }
108 + }
109 +
110 + if designatedPortID != "" && allowPortID(designatedPortID) {
111 + return designatedPortID
112 + }
113 + if preferManaged {
114 + managedPortIDList := make([]string, 0, len(managedPortIDs))
115 + for portID := range managedPortIDs {
116 + managedPortIDList = append(managedPortIDList, portID)
117 + }
118 + sort.Strings(managedPortIDList)
119 + if len(managedPortIDList) > 0 {
120 + return managedPortIDList[0]
121 + }
122 + }
123 + if designatedPortID != "" {
124 + return designatedPortID
125 + }
126 + return portIDs[0]
127 +}
128 +
129 +func segmentHasManagedPort(segment *bridgeDomainSegment, managedDeviceIDs map[string]struct{}) bool {
130 + if segment == nil || len(segment.ports) == 0 {
131 + return false
132 + }
133 + if len(managedDeviceIDs) == 0 {
134 + return true
135 + }
136 + for _, port := range segment.ports {
137 + if _, ok := managedDeviceIDs[strings.TrimSpace(port.deviceID)]; ok {
138 + return true
139 + }
140 + }
141 + return false
142 +}
143 +
144 +func pickMostProbableSegment(
145 + candidates []string,
146 + endpointLabels map[string]string,
147 + segmentIfIndexes map[string]map[string]struct{},
148 + segmentIfNames map[string]map[string]struct{},
149 +) string {
150 + if len(candidates) == 0 {
151 + return ""
152 + }
153 + if len(candidates) == 1 {
154 + return candidates[0]
155 + }
156 +
157 + ifIndexes := make(map[string]struct{})
158 + for _, ifIndex := range labelsCSVToSlice(endpointLabels, "learned_if_indexes") {
159 + ifIndex = strings.TrimSpace(ifIndex)
160 + if ifIndex == "" {
161 + continue
162 + }
163 + ifIndexes[ifIndex] = struct{}{}
164 + }
165 + ifNames := make(map[string]struct{})
166 + for _, ifName := range labelsCSVToSlice(endpointLabels, "learned_if_names") {
167 + ifName = strings.ToLower(strings.TrimSpace(ifName))
168 + if ifName == "" {
169 + continue
170 + }
171 + ifNames[ifName] = struct{}{}
172 + }
173 +
174 + bestID := ""
175 + bestScore := -1
176 + for _, segmentID := range candidates {
177 + score := 0
178 + for ifIndex := range ifIndexes {
179 + if indexes := segmentIfIndexes[segmentID]; indexes != nil {
180 + if _, ok := indexes[ifIndex]; ok {
181 + score += 2
182 + }
183 + }
184 + }
185 + for ifName := range ifNames {
186 + if names := segmentIfNames[segmentID]; names != nil {
187 + if _, ok := names[ifName]; ok {
188 + score++
189 + }
190 + }
191 + }
192 + if score > bestScore {
193 + bestScore = score
194 + bestID = segmentID
195 + continue
196 + }
197 + if score == bestScore && (bestID == "" || segmentID < bestID) {
198 + bestID = segmentID
199 + }
200 + }
201 + if bestID != "" {
202 + return bestID
203 + }
204 + return candidates[0]
205 +}
src/go/pkg/topology/engine/topology_adapter_segments_builder.go new
+109
@@ -0,0 +1,109 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "time"
7 +
8 + "github.com/netdata/netdata/go/plugins/pkg/topology"
9 +)
10 +
11 +type segmentProjectionBuilder struct {
12 + attachments []Attachment
13 + adjacencies []Adjacency
14 + layer string
15 + source string
16 + collectedAt time.Time
17 + deviceByID map[string]Device
18 + ifaceByDeviceIndex map[string]Interface
19 + ifIndexByDeviceName map[string]int
20 + bridgeLinks []bridgeBridgeLinkRecord
21 + reporterAliases map[string][]string
22 + endpointMatchByID map[string]topology.Match
23 + endpointLabelsByID map[string]map[string]string
24 + actorIndex map[string]struct{}
25 + probabilisticConnectivity bool
26 + strategyConfig topologyInferenceStrategyConfig
27 + out projectedSegments
28 + segmentIDs []string
29 + segmentMatchByID map[string]topology.Match
30 + segmentByID map[string]*bridgeDomainSegment
31 + deviceSegmentEdgeSeen map[string]struct{}
32 + endpointSegmentEdgeSeen map[string]struct{}
33 + endpointSegmentCandidates map[string][]string
34 + segmentPortKeys map[string]map[string]struct{}
35 + segmentIfIndexes map[string]map[string]struct{}
36 + segmentIfNames map[string]map[string]struct{}
37 + rawFDBObservations fdbReporterObservation
38 + rawFDBReporterHints map[string]map[string][]bridgePortRef
39 + fdbObservations fdbReporterObservation
40 + fdbOwners map[string]fdbEndpointOwner
41 + deviceIdentityByID map[string]topologyIdentityKeySet
42 + reporterSegmentIndex segmentReporterIndex
43 + aliasOwnerIDs map[string]map[string]struct{}
44 + managedDeviceIDs map[string]struct{}
45 + managedDeviceIDList []string
46 + allowedEndpointBySegment map[string]map[string]struct{}
47 + strictEndpointBySegment map[string]map[string]struct{}
48 + probableEndpointBySegment map[string]map[string]struct{}
49 + probableAttachmentModes map[string]map[string]string
50 + assignedEndpoints map[string]struct{}
51 + baseCandidatesByEndpoint map[string][]string
52 + probableCandidatesByEP map[string][]string
53 + strictLinkedEndpoints map[string]struct{}
54 +}
55 +
56 +func newSegmentProjectionBuilder(
57 + attachments []Attachment,
58 + adjacencies []Adjacency,
59 + layer string,
60 + source string,
61 + collectedAt time.Time,
62 + deviceByID map[string]Device,
63 + ifaceByDeviceIndex map[string]Interface,
64 + ifIndexByDeviceName map[string]int,
65 + bridgeLinks []bridgeBridgeLinkRecord,
66 + reporterAliases map[string][]string,
67 + endpointMatchByID map[string]topology.Match,
68 + endpointLabelsByID map[string]map[string]string,
69 + actorIndex map[string]struct{},
70 + probabilisticConnectivity bool,
71 + strategyConfig topologyInferenceStrategyConfig,
72 +) *segmentProjectionBuilder {
73 + return &segmentProjectionBuilder{
74 + attachments: attachments,
75 + adjacencies: adjacencies,
76 + layer: layer,
77 + source: source,
78 + collectedAt: collectedAt,
79 + deviceByID: deviceByID,
80 + ifaceByDeviceIndex: ifaceByDeviceIndex,
81 + ifIndexByDeviceName: ifIndexByDeviceName,
82 + bridgeLinks: bridgeLinks,
83 + reporterAliases: reporterAliases,
84 + endpointMatchByID: endpointMatchByID,
85 + endpointLabelsByID: endpointLabelsByID,
86 + actorIndex: actorIndex,
87 + probabilisticConnectivity: probabilisticConnectivity,
88 + strategyConfig: strategyConfig,
89 + out: projectedSegments{
90 + actors: make([]topology.Actor, 0),
91 + links: make([]topology.Link, 0),
92 + },
93 + }
94 +}
95 +
96 +func (b *segmentProjectionBuilder) build() projectedSegments {
97 + if len(b.attachments) == 0 && len(b.adjacencies) == 0 {
98 + return b.out
99 + }
100 + if !b.initializeSegments() {
101 + return b.out
102 + }
103 + endpointIDs := b.initializeEndpointCandidates()
104 + b.assignProbableEndpoints(endpointIDs)
105 + b.assignRemainingProbableEndpoints(endpointIDs)
106 + b.emitLinks()
107 + sortTopologyLinks(b.out.links)
108 + return b.out
109 +}
src/go/pkg/topology/engine/topology_adapter_segments_builder_assignment.go new
+268
@@ -0,0 +1,268 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 +)
9 +
10 +func (b *segmentProjectionBuilder) allowEndpoint(segmentID, endpointID string, probable bool, probableMode string) {
11 + if strings.TrimSpace(segmentID) == "" || strings.TrimSpace(endpointID) == "" {
12 + return
13 + }
14 +
15 + allowed := b.allowedEndpointBySegment[segmentID]
16 + if allowed == nil {
17 + allowed = make(map[string]struct{})
18 + b.allowedEndpointBySegment[segmentID] = allowed
19 + }
20 + allowed[endpointID] = struct{}{}
21 + b.assignedEndpoints[endpointID] = struct{}{}
22 +
23 + if !probable {
24 + strictSet := b.strictEndpointBySegment[segmentID]
25 + if strictSet == nil {
26 + strictSet = make(map[string]struct{})
27 + b.strictEndpointBySegment[segmentID] = strictSet
28 + }
29 + strictSet[endpointID] = struct{}{}
30 + return
31 + }
32 +
33 + probableSet := b.probableEndpointBySegment[segmentID]
34 + if probableSet == nil {
35 + probableSet = make(map[string]struct{})
36 + b.probableEndpointBySegment[segmentID] = probableSet
37 + }
38 + probableSet[endpointID] = struct{}{}
39 + if strings.TrimSpace(probableMode) == "" {
40 + probableMode = "probable_segment"
41 + }
42 + modes := b.probableAttachmentModes[segmentID]
43 + if modes == nil {
44 + modes = make(map[string]string)
45 + b.probableAttachmentModes[segmentID] = modes
46 + }
47 + modes[endpointID] = probableMode
48 +}
49 +
50 +func (b *segmentProjectionBuilder) initializeEndpointCandidates() []string {
51 + endpointIDs := collectTopologyEndpointIDs(
52 + b.endpointMatchByID,
53 + b.endpointLabelsByID,
54 + b.endpointSegmentCandidates,
55 + b.rawFDBObservations,
56 + b.fdbObservations,
57 + )
58 + b.baseCandidatesByEndpoint = make(map[string][]string, len(endpointIDs))
59 + b.probableCandidatesByEP = make(map[string][]string, len(endpointIDs))
60 + b.strictLinkedEndpoints = make(map[string]struct{}, len(endpointIDs))
61 +
62 + for _, endpointID := range endpointIDs {
63 + candidates := b.endpointSegmentCandidates[endpointID]
64 + candidateSet := make(map[string]struct{}, len(candidates))
65 + for _, candidate := range candidates {
66 + candidate = strings.TrimSpace(candidate)
67 + if candidate == "" {
68 + continue
69 + }
70 + candidateSet[candidate] = struct{}{}
71 + }
72 + sortedCandidates := sortedTopologySet(candidateSet)
73 + b.out.endpointLinksCandidates += len(sortedCandidates)
74 + b.baseCandidatesByEndpoint[endpointID] = sortedCandidates
75 + strictSegmentID := ""
76 + probableCandidates := sortedCandidates
77 + if len(sortedCandidates) == 1 {
78 + strictSegmentID = sortedCandidates[0]
79 + } else if owner, ok := b.fdbOwners[endpointID]; ok {
80 + filtered := make([]string, 0, len(sortedCandidates))
81 + for _, segmentID := range sortedCandidates {
82 + portKeys := b.segmentPortKeys[segmentID]
83 + if len(portKeys) == 0 {
84 + continue
85 + }
86 + if _, matchesOwnerPort := portKeys[owner.portVLANKey]; matchesOwnerPort {
87 + filtered = append(filtered, segmentID)
88 + continue
89 + }
90 + if _, matchesOwnerPort := portKeys[owner.portKey]; matchesOwnerPort {
91 + filtered = append(filtered, segmentID)
92 + }
93 + }
94 + if len(filtered) == 1 {
95 + strictSegmentID = filtered[0]
96 + }
97 + if len(filtered) > 0 {
98 + probableCandidates = filtered
99 + }
100 + }
101 + b.probableCandidatesByEP[endpointID] = probableCandidates
102 +
103 + if strictSegmentID != "" {
104 + b.allowEndpoint(strictSegmentID, endpointID, false, "")
105 + b.strictLinkedEndpoints[endpointID] = struct{}{}
106 + }
107 + }
108 +
109 + return endpointIDs
110 +}
111 +
112 +func (b *segmentProjectionBuilder) selectManagedProbableHint(endpointID string) probableEndpointReporterHint {
113 + hint := selectProbableEndpointReporterHint(
114 + b.endpointLabelsByID[endpointID],
115 + b.rawFDBReporterHints[normalizeFDBEndpointID(endpointID)],
116 + b.fdbOwners[endpointID],
117 + b.aliasOwnerIDs,
118 + b.managedDeviceIDs,
119 + )
120 + return ensureManagedProbableReporterHint(
121 + hint,
122 + b.endpointLabelsByID[endpointID],
123 + b.rawFDBReporterHints[normalizeFDBEndpointID(endpointID)],
124 + b.aliasOwnerIDs,
125 + b.managedDeviceIDs,
126 + b.managedDeviceIDList,
127 + )
128 +}
129 +
130 +func (b *segmentProjectionBuilder) registerProbableSegment(endpointID string, hint probableEndpointReporterHint) string {
131 + if strings.TrimSpace(hint.deviceID) == "" {
132 + return ""
133 + }
134 + segmentID, created := ensureProbablePortlessSegment(b.segmentByID, hint)
135 + if strings.TrimSpace(segmentID) == "" {
136 + return ""
137 + }
138 + if created {
139 + b.segmentIDs = append(b.segmentIDs, segmentID)
140 + b.segmentIfIndexes[segmentID] = make(map[string]struct{})
141 + b.segmentIfNames[segmentID] = make(map[string]struct{})
142 + if hint.ifIndex > 0 {
143 + b.segmentIfIndexes[segmentID][strconv.Itoa(hint.ifIndex)] = struct{}{}
144 + }
145 + if ifName := strings.ToLower(strings.TrimSpace(hint.ifName)); ifName != "" {
146 + b.segmentIfNames[segmentID][ifName] = struct{}{}
147 + }
148 + match, actor := buildBridgeSegmentActor(segmentID, b.segmentByID[segmentID], b.layer, b.source)
149 + keys := topologyMatchIdentityKeys(actor.Match)
150 + if len(keys) > 0 && !topologyIdentityIndexOverlaps(b.actorIndex, keys) {
151 + addTopologyIdentityKeys(b.actorIndex, keys)
152 + }
153 + b.out.actors = append(b.out.actors, actor)
154 + b.segmentMatchByID[segmentID] = match
155 + }
156 + if seg := b.segmentByID[segmentID]; seg != nil {
157 + seg.addEndpoint(endpointID, "probable")
158 + }
159 + return segmentID
160 +}
161 +
162 +func (b *segmentProjectionBuilder) ensureProbableManagedSegment(endpointID string) string {
163 + return b.registerProbableSegment(endpointID, b.selectManagedProbableHint(endpointID))
164 +}
165 +
166 +func (b *segmentProjectionBuilder) assignProbableEndpoints(endpointIDs []string) {
167 + if b.probabilisticConnectivity {
168 + for _, endpointID := range endpointIDs {
169 + if _, strictLinked := b.strictLinkedEndpoints[endpointID]; strictLinked {
170 + continue
171 + }
172 +
173 + baseCandidates := b.baseCandidatesByEndpoint[endpointID]
174 + probableCandidates := append([]string(nil), b.probableCandidatesByEP[endpointID]...)
175 + if len(probableCandidates) == 0 {
176 + probableCandidates = probableCandidateSegmentsFromReporterHints(
177 + b.endpointLabelsByID[endpointID],
178 + b.rawFDBObservations.byEndpoint[normalizeFDBEndpointID(endpointID)],
179 + b.reporterSegmentIndex,
180 + b.aliasOwnerIDs,
181 + b.managedDeviceIDs,
182 + )
183 + }
184 +
185 + segmentID := pickMostProbableSegment(
186 + probableCandidates,
187 + b.endpointLabelsByID[endpointID],
188 + b.segmentIfIndexes,
189 + b.segmentIfNames,
190 + )
191 + if segmentID == "" && len(probableCandidates) > 0 {
192 + segmentID = probableCandidates[0]
193 + }
194 + if segmentID != "" && !segmentHasManagedPort(b.segmentByID[segmentID], b.managedDeviceIDs) {
195 + segmentID = b.ensureProbableManagedSegment(endpointID)
196 + }
197 + if segmentID == "" {
198 + segmentID = b.ensureProbableManagedSegment(endpointID)
199 + }
200 +
201 + if segmentID != "" {
202 + probableMode := "probable_segment"
203 + if strings.HasPrefix(segmentID, "bridge-domain:probable:") {
204 + probableMode = "probable_portless"
205 + }
206 + b.allowEndpoint(segmentID, endpointID, true, probableMode)
207 + if len(baseCandidates) > 1 {
208 + b.out.endpointLinksSuppressed += len(baseCandidates) - 1
209 + }
210 + continue
211 + }
212 +
213 + if len(baseCandidates) > 1 {
214 + b.out.endpointsWithAmbiguousSegment++
215 + b.out.endpointLinksSuppressed += len(baseCandidates)
216 + }
217 + }
218 + return
219 + }
220 +
221 + for _, endpointID := range endpointIDs {
222 + if _, strictLinked := b.strictLinkedEndpoints[endpointID]; strictLinked {
223 + continue
224 + }
225 + baseCandidates := b.baseCandidatesByEndpoint[endpointID]
226 + if len(baseCandidates) > 1 {
227 + b.out.endpointsWithAmbiguousSegment++
228 + b.out.endpointLinksSuppressed += len(baseCandidates)
229 + }
230 + }
231 +}
232 +
233 +func (b *segmentProjectionBuilder) assignRemainingProbableEndpoints(endpointIDs []string) {
234 + if !b.probabilisticConnectivity || len(b.managedDeviceIDs) == 0 {
235 + return
236 + }
237 +
238 + for _, endpointID := range endpointIDs {
239 + if _, alreadyAssigned := b.assignedEndpoints[endpointID]; alreadyAssigned {
240 + continue
241 + }
242 + segmentID := b.ensureProbableManagedSegment(endpointID)
243 + if strings.TrimSpace(segmentID) == "" {
244 + continue
245 + }
246 + b.allowEndpoint(segmentID, endpointID, true, "probable_portless")
247 + }
248 +}
249 +
250 +func (b *segmentProjectionBuilder) buildProbableOnlyAnchorPortIDBySegment() map[string]string {
251 + out := make(map[string]string)
252 + for _, segmentID := range b.segmentIDs {
253 + if len(b.probableEndpointBySegment[segmentID]) == 0 {
254 + continue
255 + }
256 + if len(b.strictEndpointBySegment[segmentID]) > 0 {
257 + continue
258 + }
259 + segment := b.segmentByID[segmentID]
260 + if segment == nil {
261 + continue
262 + }
263 + if portID := pickProbableSegmentAnchorPortID(segment, b.probableEndpointBySegment[segmentID], b.fdbOwners, b.managedDeviceIDs); portID != "" {
264 + out[segmentID] = portID
265 + }
266 + }
267 + return out
268 +}
src/go/pkg/topology/engine/topology_adapter_segments_builder_emit.go new
+291
@@ -0,0 +1,291 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/topology"
10 +)
11 +
12 +func (b *segmentProjectionBuilder) emitLinks() {
13 + sort.Strings(b.segmentIDs)
14 + probableOnlyAnchorPortIDBySegment := b.buildProbableOnlyAnchorPortIDBySegment()
15 + segmentsWithAnyLinks := make(map[string]struct{})
16 +
17 + for _, segmentID := range b.segmentIDs {
18 + segment := b.segmentByID[segmentID]
19 + if segment == nil {
20 + continue
21 + }
22 + segmentEndpoint := topology.LinkEndpoint{
23 + Match: b.segmentMatchByID[segmentID],
24 + Attributes: map[string]any{
25 + "segment_id": segmentID,
26 + },
27 + }
28 +
29 + portIDs := make([]string, 0, len(segment.ports))
30 + for portID := range segment.ports {
31 + portIDs = append(portIDs, portID)
32 + }
33 + sort.Strings(portIDs)
34 + probableOnlyAnchorPortID := probableOnlyAnchorPortIDBySegment[segmentID]
35 + for _, portID := range portIDs {
36 + if probableOnlyAnchorPortID != "" && portID != probableOnlyAnchorPortID {
37 + continue
38 + }
39 + port := segment.ports[portID]
40 + device, ok := b.deviceByID[port.deviceID]
41 + if !ok {
42 + continue
43 + }
44 + localPort := bridgePortDisplay(port)
45 + if localPort == "" {
46 + continue
47 + }
48 + edgeKey := segmentID + keySep + portID
49 + if _, seen := b.deviceSegmentEdgeSeen[edgeKey]; seen {
50 + continue
51 + }
52 + b.deviceSegmentEdgeSeen[edgeKey] = struct{}{}
53 +
54 + metrics := map[string]any{
55 + "bridge_domain": segmentID,
56 + }
57 + if segment.portIdentityKey(port) == segment.portIdentityKey(segment.designatedPort) {
58 + metrics["designated"] = true
59 + }
60 + b.out.links = append(b.out.links, topology.Link{
61 + Layer: b.layer,
62 + Protocol: "bridge",
63 + LinkType: "bridge",
64 + Direction: "bidirectional",
65 + Src: adjacencySideToEndpoint(device, localPort, b.ifIndexByDeviceName, b.ifaceByDeviceIndex),
66 + Dst: segmentEndpoint,
67 + DiscoveredAt: topologyTimePtr(b.collectedAt),
68 + LastSeen: topologyTimePtr(b.collectedAt),
69 + Metrics: metrics,
70 + })
71 + b.out.linksFdb++
72 + b.out.bidirectionalCount++
73 + segmentsWithAnyLinks[segmentID] = struct{}{}
74 + }
75 +
76 + allowedEndpoints := b.allowedEndpointBySegment[segmentID]
77 + if len(allowedEndpoints) == 0 {
78 + continue
79 + }
80 + endpointSet := make(map[string]struct{}, len(segment.endpointIDs)+len(allowedEndpoints))
81 + for endpointID := range segment.endpointIDs {
82 + endpointSet[endpointID] = struct{}{}
83 + }
84 + for endpointID := range allowedEndpoints {
85 + endpointSet[endpointID] = struct{}{}
86 + }
87 + endpointIDs := sortedTopologySet(endpointSet)
88 + for _, endpointID := range endpointIDs {
89 + if _, ok := allowedEndpoints[endpointID]; !ok {
90 + continue
91 + }
92 +
93 + endpointMatch, ok := b.endpointMatchByID[endpointID]
94 + if !ok {
95 + endpointMatch = endpointMatchFromID(endpointID)
96 + if len(topologyMatchIdentityKeys(endpointMatch)) == 0 {
97 + continue
98 + }
99 + }
100 + overlappingDeviceIDs := endpointMatchOverlappingKnownDeviceIDs(endpointMatch, b.deviceIdentityByID)
101 + if len(overlappingDeviceIDs) > 0 {
102 + matchedManagedDeviceIDs := make([]string, 0, len(overlappingDeviceIDs))
103 + for _, overlapID := range overlappingDeviceIDs {
104 + if _, ok := b.deviceByID[overlapID]; ok {
105 + matchedManagedDeviceIDs = append(matchedManagedDeviceIDs, overlapID)
106 + }
107 + }
108 + if len(matchedManagedDeviceIDs) > 0 {
109 + if len(matchedManagedDeviceIDs) == 1 {
110 + matchedDeviceID := matchedManagedDeviceIDs[0]
111 + if segmentContainsDevice(segment, matchedDeviceID) {
112 + if b.out.suppressedManagedOverlapIDs == nil {
113 + b.out.suppressedManagedOverlapIDs = make(map[string]struct{})
114 + }
115 + b.out.suppressedManagedOverlapIDs[normalizeFDBEndpointID(endpointID)] = struct{}{}
116 + b.out.endpointLinksSuppressed++
117 + continue
118 + }
119 + if matchedDevice, ok := b.deviceByID[matchedDeviceID]; ok {
120 + edgeKey := segmentID + "|managed-device|" + matchedDeviceID
121 + if _, seen := b.endpointSegmentEdgeSeen[edgeKey]; !seen {
122 + b.endpointSegmentEdgeSeen[edgeKey] = struct{}{}
123 + b.out.links = append(b.out.links, topology.Link{
124 + Layer: b.layer,
125 + Protocol: "fdb",
126 + LinkType: "fdb",
127 + Direction: "bidirectional",
128 + Src: segmentEndpoint,
129 + Dst: adjacencySideToEndpoint(matchedDevice, "", b.ifIndexByDeviceName, b.ifaceByDeviceIndex),
130 + DiscoveredAt: topologyTimePtr(b.collectedAt),
131 + LastSeen: topologyTimePtr(b.collectedAt),
132 + Metrics: map[string]any{
133 + "bridge_domain": segmentID,
134 + "attachment_mode": "managed_device_overlap",
135 + },
136 + })
137 + b.out.linksFdb++
138 + b.out.bidirectionalCount++
139 + b.out.endpointLinksEmitted++
140 + segmentsWithAnyLinks[segmentID] = struct{}{}
141 + }
142 + if b.out.suppressedManagedOverlapIDs == nil {
143 + b.out.suppressedManagedOverlapIDs = make(map[string]struct{})
144 + }
145 + b.out.suppressedManagedOverlapIDs[normalizeFDBEndpointID(endpointID)] = struct{}{}
146 + continue
147 + }
148 + }
149 + if b.out.suppressedManagedOverlapIDs == nil {
150 + b.out.suppressedManagedOverlapIDs = make(map[string]struct{})
151 + }
152 + b.out.suppressedManagedOverlapIDs[normalizeFDBEndpointID(endpointID)] = struct{}{}
153 + b.out.endpointLinksSuppressed++
154 + continue
155 + }
156 + if !b.probabilisticConnectivity {
157 + b.out.endpointLinksSuppressed++
158 + continue
159 + }
160 + b.allowEndpoint(segmentID, endpointID, true, "probable_segment")
161 + }
162 +
163 + if owner, hasOwner := b.out.endpointDirectOwners[endpointID]; hasOwner &&
164 + strings.EqualFold(strings.TrimSpace(owner.source), "single_port_mac") {
165 + device, ok := b.deviceByID[owner.port.deviceID]
166 + if ok {
167 + localPort := bridgePortDisplay(owner.port)
168 + if localPort != "" {
169 + edgeKey := "direct" + keySep + bridgePortObservationVLANKey(owner.port) + keySep + endpointID
170 + if _, seen := b.endpointSegmentEdgeSeen[edgeKey]; !seen {
171 + b.endpointSegmentEdgeSeen[edgeKey] = struct{}{}
172 + metrics := map[string]any{
173 + "attachment_mode": "direct",
174 + }
175 + linkState := ""
176 + if probableSet := b.probableEndpointBySegment[segmentID]; len(probableSet) > 0 {
177 + if _, isProbable := probableSet[endpointID]; isProbable {
178 + metrics["attachment_mode"] = "probable_direct"
179 + metrics["inference"] = "probable"
180 + metrics["confidence"] = "low"
181 + linkState = "probable"
182 + }
183 + }
184 + b.out.links = append(b.out.links, topology.Link{
185 + Layer: b.layer,
186 + Protocol: "fdb",
187 + LinkType: "fdb",
188 + Direction: "bidirectional",
189 + Src: adjacencySideToEndpoint(device, localPort, b.ifIndexByDeviceName, b.ifaceByDeviceIndex),
190 + Dst: topology.LinkEndpoint{Match: endpointMatch},
191 + DiscoveredAt: topologyTimePtr(b.collectedAt),
192 + LastSeen: topologyTimePtr(b.collectedAt),
193 + State: linkState,
194 + Metrics: metrics,
195 + })
196 + b.out.linksFdb++
197 + b.out.bidirectionalCount++
198 + b.out.endpointLinksEmitted++
199 + continue
200 + }
201 + }
202 + }
203 + }
204 +
205 + edgeKey := segmentID + keySep + endpointID
206 + if _, seen := b.endpointSegmentEdgeSeen[edgeKey]; seen {
207 + continue
208 + }
209 + b.endpointSegmentEdgeSeen[edgeKey] = struct{}{}
210 +
211 + metrics := map[string]any{
212 + "bridge_domain": segmentID,
213 + }
214 + linkState := ""
215 + if probableSet := b.probableEndpointBySegment[segmentID]; len(probableSet) > 0 {
216 + if _, isProbable := probableSet[endpointID]; isProbable {
217 + probableMode := ""
218 + if modes := b.probableAttachmentModes[segmentID]; len(modes) > 0 {
219 + probableMode = strings.TrimSpace(modes[endpointID])
220 + }
221 + if probableMode == "" {
222 + probableMode = "probable_segment"
223 + }
224 + metrics["attachment_mode"] = probableMode
225 + metrics["inference"] = "probable"
226 + metrics["confidence"] = "low"
227 + linkState = "probable"
228 + }
229 + }
230 +
231 + b.out.links = append(b.out.links, topology.Link{
232 + Layer: b.layer,
233 + Protocol: "fdb",
234 + LinkType: "fdb",
235 + Direction: "bidirectional",
236 + Src: segmentEndpoint,
237 + Dst: topology.LinkEndpoint{Match: endpointMatch},
238 + DiscoveredAt: topologyTimePtr(b.collectedAt),
239 + LastSeen: topologyTimePtr(b.collectedAt),
240 + State: linkState,
241 + Metrics: metrics,
242 + })
243 + b.out.linksFdb++
244 + b.out.bidirectionalCount++
245 + b.out.endpointLinksEmitted++
246 + segmentsWithAnyLinks[segmentID] = struct{}{}
247 + }
248 + }
249 +
250 + b.pruneSegmentsWithoutLinks(segmentsWithAnyLinks)
251 +}
252 +
253 +func (b *segmentProjectionBuilder) pruneSegmentsWithoutLinks(segmentsWithAnyLinks map[string]struct{}) {
254 + if len(segmentsWithAnyLinks) >= len(b.segmentIDs) {
255 + return
256 + }
257 +
258 + filteredActors := make([]topology.Actor, 0, len(b.out.actors))
259 + for _, actor := range b.out.actors {
260 + segmentID := topologyAttrString(actor.Attributes, "segment_id")
261 + if segmentID == "" {
262 + continue
263 + }
264 + if _, ok := segmentsWithAnyLinks[segmentID]; ok {
265 + filteredActors = append(filteredActors, actor)
266 + }
267 + }
268 + b.out.actors = filteredActors
269 +
270 + filteredLinks := make([]topology.Link, 0, len(b.out.links))
271 + b.out.linksFdb = 0
272 + b.out.bidirectionalCount = 0
273 + b.out.endpointLinksEmitted = 0
274 + for _, link := range b.out.links {
275 + segmentID := topologyMetricString(link.Metrics, "bridge_domain")
276 + if segmentID != "" {
277 + if _, ok := segmentsWithAnyLinks[segmentID]; !ok {
278 + continue
279 + }
280 + }
281 + filteredLinks = append(filteredLinks, link)
282 + b.out.linksFdb++
283 + if strings.EqualFold(strings.TrimSpace(link.Direction), "bidirectional") {
284 + b.out.bidirectionalCount++
285 + }
286 + if strings.EqualFold(strings.TrimSpace(link.Protocol), "fdb") {
287 + b.out.endpointLinksEmitted++
288 + }
289 + }
290 + b.out.links = filteredLinks
291 +}
src/go/pkg/topology/engine/topology_adapter_segments_builder_init.go new
+145
@@ -0,0 +1,145 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "sort"
7 + "strconv"
8 + "strings"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/topology"
11 +)
12 +
13 +func (b *segmentProjectionBuilder) initializeSegments() bool {
14 + // Hard deterministic rule: LLDP/CDP-adjacent ports are transit ports.
15 + // FDB data learned on those ports belongs to the neighbor domain and must
16 + // not create parallel inferred segment paths on top of direct discovery.
17 + //
18 + // NOTE:
19 + // switch-facing/trunk classification must not suppress endpoint ownership.
20 + // It is a topology correlation/confidence signal, not a hard endpoint
21 + // placement filter.
22 + deterministicTransitPortKeys := buildDeterministicTransitPortKeySet(b.adjacencies, b.ifIndexByDeviceName)
23 + seedMacLinks := collectBridgeMacLinkRecords(b.attachments, b.ifaceByDeviceIndex, deterministicTransitPortKeys)
24 + b.rawFDBObservations = buildFDBReporterObservations(seedMacLinks)
25 + model := buildBridgeDomainModel(b.bridgeLinks, seedMacLinks)
26 + if len(model.domains) == 0 {
27 + return false
28 + }
29 +
30 + b.segmentMatchByID = make(map[string]topology.Match)
31 + b.segmentByID = make(map[string]*bridgeDomainSegment)
32 + for _, domain := range model.domains {
33 + if domain == nil {
34 + continue
35 + }
36 + for _, segment := range domain.segments {
37 + if segment == nil || len(segment.endpointIDs) == 0 {
38 + continue
39 + }
40 + segmentID := bridgeDomainSegmentID(segment)
41 + if _, exists := b.segmentByID[segmentID]; exists {
42 + continue
43 + }
44 + b.segmentByID[segmentID] = segment
45 + b.segmentIDs = append(b.segmentIDs, segmentID)
46 + }
47 + }
48 + sort.Strings(b.segmentIDs)
49 + if len(b.segmentIDs) == 0 {
50 + return false
51 + }
52 +
53 + for _, segmentID := range b.segmentIDs {
54 + segment := b.segmentByID[segmentID]
55 + if segment == nil {
56 + continue
57 + }
58 + match, actor := buildBridgeSegmentActor(segmentID, segment, b.layer, b.source)
59 + keys := topologyMatchIdentityKeys(actor.Match)
60 + if len(keys) > 0 && !topologyIdentityIndexOverlaps(b.actorIndex, keys) {
61 + addTopologyIdentityKeys(b.actorIndex, keys)
62 + }
63 + b.out.actors = append(b.out.actors, actor)
64 + b.segmentMatchByID[segmentID] = match
65 + }
66 +
67 + b.deviceSegmentEdgeSeen = make(map[string]struct{})
68 + b.endpointSegmentEdgeSeen = make(map[string]struct{})
69 + b.endpointSegmentCandidates = make(map[string][]string)
70 + b.segmentPortKeys = make(map[string]map[string]struct{}, len(b.segmentIDs))
71 + b.segmentIfIndexes = make(map[string]map[string]struct{}, len(b.segmentIDs))
72 + b.segmentIfNames = make(map[string]map[string]struct{}, len(b.segmentIDs))
73 + for _, segmentID := range b.segmentIDs {
74 + segment := b.segmentByID[segmentID]
75 + if segment == nil {
76 + continue
77 + }
78 + portKeys := make(map[string]struct{}, len(segment.ports))
79 + ifIndexes := make(map[string]struct{}, len(segment.ports))
80 + ifNames := make(map[string]struct{}, len(segment.ports))
81 + for _, port := range segment.ports {
82 + if portKey := bridgePortObservationKey(port); portKey != "" {
83 + portKeys[portKey] = struct{}{}
84 + }
85 + if portVLANKey := bridgePortObservationVLANKey(port); portVLANKey != "" {
86 + portKeys[portVLANKey] = struct{}{}
87 + }
88 + if port.ifIndex > 0 {
89 + ifIndexes[strconv.Itoa(port.ifIndex)] = struct{}{}
90 + }
91 + if ifName := strings.TrimSpace(port.ifName); ifName != "" {
92 + ifNames[strings.ToLower(ifName)] = struct{}{}
93 + }
94 + }
95 + b.segmentPortKeys[segmentID] = portKeys
96 + b.segmentIfIndexes[segmentID] = ifIndexes
97 + b.segmentIfNames[segmentID] = ifNames
98 + for endpointID := range segment.endpointIDs {
99 + endpointID = strings.TrimSpace(endpointID)
100 + if endpointID == "" {
101 + continue
102 + }
103 + b.endpointSegmentCandidates[endpointID] = append(b.endpointSegmentCandidates[endpointID], segmentID)
104 + }
105 + }
106 +
107 + b.rawFDBReporterHints = buildFDBEndpointReporterHints(seedMacLinks)
108 + b.fdbObservations = buildFDBReporterObservations(seedMacLinks)
109 + b.fdbOwners = inferFDBEndpointOwners(b.fdbObservations, b.reporterAliases, deterministicTransitPortKeys)
110 + for endpointID, owner := range inferSinglePortEndpointOwners(seedMacLinks, deterministicTransitPortKeys) {
111 + if strings.TrimSpace(endpointID) == "" {
112 + continue
113 + }
114 + if b.fdbOwners == nil {
115 + b.fdbOwners = make(map[string]fdbEndpointOwner)
116 + }
117 + // Port-centric ownership has precedence: if a port carries a single learned
118 + // MAC in the same snapshot/VLAN scope, use it to resolve ambiguous placement.
119 + b.fdbOwners[endpointID] = owner
120 + if b.out.endpointDirectOwners == nil {
121 + b.out.endpointDirectOwners = make(map[string]fdbEndpointOwner)
122 + }
123 + b.out.endpointDirectOwners[endpointID] = owner
124 + }
125 +
126 + b.deviceIdentityByID = buildDeviceIdentityKeySetByID(b.deviceByID, b.adjacencies, b.ifaceByDeviceIndex)
127 + b.reporterSegmentIndex = buildSegmentReporterIndex(b.segmentIDs, b.segmentByID)
128 + b.aliasOwnerIDs = buildFDBAliasOwnerMap(b.reporterAliases)
129 + b.managedDeviceIDs = make(map[string]struct{}, len(b.deviceByID))
130 + for deviceID := range b.deviceByID {
131 + deviceID = strings.TrimSpace(deviceID)
132 + if deviceID == "" {
133 + continue
134 + }
135 + b.managedDeviceIDs[deviceID] = struct{}{}
136 + }
137 + b.managedDeviceIDList = sortedTopologySet(b.managedDeviceIDs)
138 + b.allowedEndpointBySegment = make(map[string]map[string]struct{})
139 + b.strictEndpointBySegment = make(map[string]map[string]struct{})
140 + b.probableEndpointBySegment = make(map[string]map[string]struct{})
141 + b.probableAttachmentModes = make(map[string]map[string]string)
142 + b.assignedEndpoints = make(map[string]struct{}, len(b.endpointMatchByID))
143 +
144 + return true
145 +}
src/go/pkg/topology/engine/topology_adapter_test.go new
+3012
@@ -0,0 +1,3012 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "net/netip"
7 + "slices"
8 + "strings"
9 + "testing"
10 + "time"
11 +
12 + "github.com/netdata/netdata/go/plugins/pkg/topology"
13 + "github.com/stretchr/testify/require"
14 +)
15 +
16 +func TestToTopologyData_ProjectsResult(t *testing.T) {
17 + collectedAt := time.Date(2026, time.February, 20, 4, 5, 6, 0, time.UTC)
18 +
19 + result := Result{
20 + CollectedAt: collectedAt,
21 + Devices: []Device{
22 + {
23 + ID: "local-device",
24 + Hostname: "sw1",
25 + ChassisID: "00:11:22:33:44:55",
26 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
27 + Labels: map[string]string{"protocols_observed": "bridge,fdb,stp"},
28 + },
29 + {
30 + ID: "remote-device",
31 + Hostname: "sw2",
32 + ChassisID: "aa:bb:cc:dd:ee:ff",
33 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
34 + Labels: map[string]string{"inferred": "true"},
35 + },
36 + },
37 + Interfaces: []Interface{
38 + {DeviceID: "local-device", IfIndex: 3, IfName: "Gi0/3", IfDescr: "Gi0/3", Labels: map[string]string{"admin_status": "up", "oper_status": "up"}},
39 + {DeviceID: "local-device", IfIndex: 4, IfName: "Gi0/4", IfDescr: "Gi0/4", Labels: map[string]string{"admin_status": "up", "oper_status": "lowerLayerDown"}},
40 + },
41 + Adjacencies: []Adjacency{
42 + {
43 + Protocol: "lldp",
44 + SourceID: "local-device",
45 + SourcePort: "Gi0/3",
46 + TargetID: "remote-device",
47 + TargetPort: "Gi0/1",
48 + },
49 + },
50 + Attachments: []Attachment{
51 + {DeviceID: "local-device", IfIndex: 4, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
52 + },
53 + Enrichments: []Enrichment{
54 + {
55 + EndpointID: "mac:70:49:a2:65:72:cd",
56 + MAC: "70:49:a2:65:72:cd",
57 + IPs: []netip.Addr{netip.MustParseAddr("10.20.4.84")},
58 + Labels: map[string]string{
59 + "sources": "arp",
60 + "if_indexes": "4",
61 + "if_names": "Gi0/4",
62 + },
63 + },
64 + },
65 + }
66 +
67 + data := ToTopologyData(result, TopologyDataOptions{
68 + SchemaVersion: "2.0",
69 + Source: "snmp",
70 + Layer: "2",
71 + View: "summary",
72 + AgentID: "agent-1",
73 + LocalDeviceID: "local-device",
74 + })
75 +
76 + require.Equal(t, "2.0", data.SchemaVersion)
77 + require.Equal(t, "snmp", data.Source)
78 + require.Equal(t, "2", data.Layer)
79 + require.Equal(t, "agent-1", data.AgentID)
80 + require.Equal(t, collectedAt, data.CollectedAt)
81 +
82 + require.Len(t, data.Actors, 3)
83 + require.Len(t, data.Links, 2)
84 + lldpLink := findLinkByProtocol(data.Links, "lldp")
85 + require.NotNil(t, lldpLink)
86 + require.Equal(t, "Gi0/3", lldpLink.Src.Attributes["if_name"])
87 + require.Equal(t, "Gi0/3", lldpLink.Src.Attributes["port_id"])
88 + require.Equal(t, "up", lldpLink.Src.Attributes["if_admin_status"])
89 + require.Equal(t, "up", lldpLink.Src.Attributes["if_oper_status"])
90 + require.Equal(t, "sw2", lldpLink.Dst.Attributes["sys_name"])
91 +
92 + localActor := findActorBySysName(data.Actors, "sw1")
93 + require.NotNil(t, localActor)
94 + require.Equal(t, false, localActor.Attributes["discovered"])
95 + require.Equal(t, false, localActor.Attributes["inferred"])
96 + require.Equal(t, []string{"bridge", "fdb", "stp"}, localActor.Attributes["protocols"])
97 + require.Equal(t, []string{"bridge", "fdb", "stp"}, localActor.Attributes["protocols_collected"])
98 + require.Equal(t, 2, localActor.Attributes["ports_total"])
99 + require.NotNil(t, localActor.Attributes["if_admin_status_counts"])
100 + require.NotNil(t, localActor.Attributes["if_oper_status_counts"])
101 + require.NotNil(t, localActor.Attributes["if_link_mode_counts"])
102 + require.NotNil(t, localActor.Attributes["if_topology_role_counts"])
103 + require.NotNil(t, localActor.Attributes["if_statuses"])
104 + remoteActor := findActorBySysName(data.Actors, "sw2")
105 + require.NotNil(t, remoteActor)
106 + require.Equal(t, true, remoteActor.Attributes["inferred"])
107 +
108 + endpointActor := findActorByMAC(data.Actors, "70:49:a2:65:72:cd")
109 + require.NotNil(t, endpointActor)
110 + require.Equal(t, "endpoint", endpointActor.ActorType)
111 + require.Equal(t, []string{"10.20.4.84"}, endpointActor.Match.IPAddresses)
112 + require.Equal(t, []string{"arp", "fdb"}, endpointActor.Attributes["learned_sources"])
113 + require.Equal(t, "single_port_mac", endpointActor.Attributes["attachment_source"])
114 + require.Equal(t, "sw1", endpointActor.Attributes["attached_device"])
115 + require.Equal(t, "Gi0/4", endpointActor.Attributes["attached_port"])
116 +
117 + require.Equal(t, 2, data.Stats["devices_total"])
118 + require.Equal(t, 1, data.Stats["devices_discovered"])
119 + require.Equal(t, 2, data.Stats["links_total"])
120 + require.Equal(t, 1, data.Stats["links_lldp"])
121 + require.Equal(t, 0, data.Stats["links_cdp"])
122 + require.Equal(t, 1, data.Stats["links_fdb"])
123 + require.Equal(t, 0, data.Stats["links_arp"])
124 + require.Equal(t, 1, data.Stats["links_bidirectional"])
125 + require.Equal(t, 1, data.Stats["links_unidirectional"])
126 + require.Equal(t, 3, data.Stats["actors_total"])
127 + require.Equal(t, 1, data.Stats["endpoints_total"])
128 +}
129 +
130 +func TestToTopologyData_ClassifiesPortLinkModesFromFDBAndSTPEvidence(t *testing.T) {
131 + result := Result{
132 + Devices: []Device{
133 + {
134 + ID: "sw1",
135 + Hostname: "sw1",
136 + ChassisID: "00:11:22:33:44:55",
137 + },
138 + {
139 + ID: "sw2",
140 + Hostname: "sw2",
141 + ChassisID: "00:11:22:33:44:66",
142 + },
143 + },
144 + Interfaces: []Interface{
145 + {DeviceID: "sw1", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
146 + {DeviceID: "sw1", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
147 + {DeviceID: "sw1", IfIndex: 3, IfName: "Gi0/3", IfDescr: "Gi0/3"},
148 + {DeviceID: "sw1", IfIndex: 4, IfName: "Gi0/4", IfDescr: "Gi0/4"},
149 + {DeviceID: "sw2", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
150 + },
151 + Adjacencies: []Adjacency{
152 + {
153 + Protocol: "lldp",
154 + SourceID: "sw1",
155 + SourcePort: "Gi0/3",
156 + TargetID: "sw2",
157 + TargetPort: "Gi0/1",
158 + },
159 + {
160 + Protocol: "stp",
161 + SourceID: "sw1",
162 + SourcePort: "Gi0/1",
163 + TargetID: "sw2",
164 + TargetPort: "Gi0/1",
165 + Labels: map[string]string{
166 + "vlan_id": "20",
167 + },
168 + },
169 + },
170 + Attachments: []Attachment{
171 + {
172 + DeviceID: "sw1",
173 + IfIndex: 1,
174 + EndpointID: "mac:00:00:00:00:10:01",
175 + Method: "fdb",
176 + Labels: map[string]string{
177 + "fdb_status": "learned",
178 + "vlan_id": "10",
179 + },
180 + },
181 + {
182 + DeviceID: "sw1",
183 + IfIndex: 1,
184 + EndpointID: "mac:00:00:00:00:20:01",
185 + Method: "fdb",
186 + Labels: map[string]string{
187 + "fdb_status": "learned",
188 + "vlan_id": "20",
189 + },
190 + },
191 + {
192 + DeviceID: "sw1",
193 + IfIndex: 2,
194 + EndpointID: "mac:00:00:00:00:30:01",
195 + Method: "fdb",
196 + Labels: map[string]string{
197 + "fdb_status": "learned",
198 + "vlan_id": "30",
199 + },
200 + },
201 + {
202 + DeviceID: "sw1",
203 + IfIndex: 3,
204 + EndpointID: "mac:00:00:00:00:40:01",
205 + Method: "fdb",
206 + Labels: map[string]string{
207 + "fdb_status": "learned",
208 + "vlan_id": "40",
209 + },
210 + },
211 + {
212 + DeviceID: "sw1",
213 + IfIndex: 4,
214 + EndpointID: "mac:00:00:00:00:50:01",
215 + Method: "fdb",
216 + Labels: map[string]string{
217 + "fdb_status": "learned",
218 + },
219 + },
220 + {
221 + DeviceID: "sw1",
222 + IfIndex: 4,
223 + EndpointID: "mac:00:00:00:00:50:02",
224 + Method: "fdb",
225 + Labels: map[string]string{
226 + "fdb_status": "learned",
227 + },
228 + },
229 + },
230 + }
231 +
232 + data := ToTopologyData(result, TopologyDataOptions{
233 + Source: "snmp",
234 + Layer: "2",
235 + View: "summary",
236 + })
237 +
238 + actor := findActorBySysName(data.Actors, "sw1")
239 + require.NotNil(t, actor)
240 +
241 + modeCounts, ok := actor.Attributes["if_link_mode_counts"].(map[string]any)
242 + require.True(t, ok)
243 + require.Equal(t, 1, modeCounts["trunk"])
244 + require.Equal(t, 1, modeCounts["access"])
245 + require.Equal(t, 2, modeCounts["unknown"])
246 +
247 + roleCounts, ok := actor.Attributes["if_topology_role_counts"].(map[string]any)
248 + require.True(t, ok)
249 + require.Equal(t, 1, roleCounts["switch_facing"])
250 + require.Equal(t, 1, roleCounts["host_facing"])
251 + require.Equal(t, 1, roleCounts["host_candidate"])
252 + require.Equal(t, 1, roleCounts["unknown"])
253 +
254 + statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
255 + require.True(t, ok)
256 +
257 + port1 := findInterfaceStatusByIndex(statuses, 1)
258 + require.Equal(t, "trunk", port1["link_mode"])
259 + require.Equal(t, "high", port1["link_mode_confidence"])
260 + require.Equal(t, []string{"fdb", "stp"}, port1["link_mode_sources"])
261 + require.Equal(t, []string{"10", "20"}, port1["vlan_ids"])
262 + require.Equal(t, "unknown", port1["topology_role"])
263 + require.Equal(t, "low", port1["topology_role_confidence"])
264 + require.Equal(t, []string{"stp", "fdb"}, port1["topology_role_sources"])
265 +
266 + port2 := findInterfaceStatusByIndex(statuses, 2)
267 + require.Equal(t, "access", port2["link_mode"])
268 + require.Equal(t, "medium", port2["link_mode_confidence"])
269 + require.Equal(t, []string{"fdb"}, port2["link_mode_sources"])
270 + require.Equal(t, []string{"30"}, port2["vlan_ids"])
271 + require.Equal(t, "host_facing", port2["topology_role"])
272 + require.Equal(t, "medium", port2["topology_role_confidence"])
273 + require.Equal(t, []string{"fdb"}, port2["topology_role_sources"])
274 +
275 + port3 := findInterfaceStatusByIndex(statuses, 3)
276 + require.Equal(t, "unknown", port3["link_mode"])
277 + require.Equal(t, "low", port3["link_mode_confidence"])
278 + require.Equal(t, []string{"fdb", "peer_link"}, port3["link_mode_sources"])
279 + require.Equal(t, []string{"40"}, port3["vlan_ids"])
280 + require.Equal(t, "switch_facing", port3["topology_role"])
281 + require.Equal(t, "high", port3["topology_role_confidence"])
282 + require.Equal(t, []string{"peer_link", "bridge_link", "fdb"}, port3["topology_role_sources"])
283 +
284 + port4 := findInterfaceStatusByIndex(statuses, 4)
285 + require.Equal(t, "unknown", port4["link_mode"])
286 + require.Equal(t, "low", port4["link_mode_confidence"])
287 + require.Equal(t, []string{"fdb"}, port4["link_mode_sources"])
288 + _, hasVLANs := port4["vlan_ids"]
289 + require.False(t, hasVLANs)
290 + require.Equal(t, "host_candidate", port4["topology_role"])
291 + require.Equal(t, "low", port4["topology_role_confidence"])
292 + require.Equal(t, []string{"fdb"}, port4["topology_role_sources"])
293 +}
294 +
295 +func TestToTopologyData_IgnoresIgnoredFDBStatusForLinkModeClassification(t *testing.T) {
296 + result := Result{
297 + Devices: []Device{
298 + {
299 + ID: "sw1",
300 + Hostname: "sw1",
301 + },
302 + },
303 + Interfaces: []Interface{
304 + {DeviceID: "sw1", IfIndex: 10, IfName: "Gi0/10", IfDescr: "Gi0/10"},
305 + },
306 + Attachments: []Attachment{
307 + {
308 + DeviceID: "sw1",
309 + IfIndex: 10,
310 + EndpointID: "mac:00:00:00:00:50:01",
311 + Method: "fdb",
312 + Labels: map[string]string{
313 + "fdb_status": "ignored",
314 + "vlan_id": "50",
315 + },
316 + },
317 + },
318 + }
319 +
320 + data := ToTopologyData(result, TopologyDataOptions{
321 + Source: "snmp",
322 + Layer: "2",
323 + View: "summary",
324 + })
325 +
326 + actor := findActorBySysName(data.Actors, "sw1")
327 + require.NotNil(t, actor)
328 + statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
329 + require.True(t, ok)
330 + port := findInterfaceStatusByIndex(statuses, 10)
331 + require.Equal(t, "unknown", port["link_mode"])
332 + require.Equal(t, "low", port["link_mode_confidence"])
333 + _, hasSources := port["link_mode_sources"]
334 + require.False(t, hasSources)
335 + _, hasVLANs := port["vlan_ids"]
336 + require.False(t, hasVLANs)
337 + require.Equal(t, "unknown", port["topology_role"])
338 + require.Equal(t, "low", port["topology_role_confidence"])
339 + _, hasRoleSources := port["topology_role_sources"]
340 + require.False(t, hasRoleSources)
341 +}
342 +
343 +func TestToTopologyData_ClassifiesSTPCorroboratedManagedAliasAsSwitchFacing(t *testing.T) {
344 + result := Result{
345 + Devices: []Device{
346 + {
347 + ID: "sw1",
348 + Hostname: "sw1",
349 + ChassisID: "00:11:22:33:44:55",
350 + },
351 + {
352 + ID: "sw2",
353 + Hostname: "sw2",
354 + ChassisID: "00:11:22:33:44:66",
355 + },
356 + },
357 + Interfaces: []Interface{
358 + {DeviceID: "sw1", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
359 + },
360 + Adjacencies: []Adjacency{
361 + {
362 + Protocol: "stp",
363 + SourceID: "sw1",
364 + SourcePort: "Gi0/1",
365 + TargetID: "stp-root",
366 + Labels: map[string]string{
367 + "vlan_id": "10",
368 + },
369 + },
370 + },
371 + Attachments: []Attachment{
372 + {
373 + DeviceID: "sw1",
374 + IfIndex: 1,
375 + EndpointID: "mac:00:11:22:33:44:66",
376 + Method: "fdb",
377 + Labels: map[string]string{
378 + "fdb_status": "learned",
379 + "vlan_id": "10",
380 + },
381 + },
382 + },
383 + }
384 +
385 + data := ToTopologyData(result, TopologyDataOptions{
386 + Source: "snmp",
387 + Layer: "2",
388 + View: "summary",
389 + })
390 +
391 + actor := findActorBySysName(data.Actors, "sw1")
392 + require.NotNil(t, actor)
393 +
394 + statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
395 + require.True(t, ok)
396 + port1 := findInterfaceStatusByIndex(statuses, 1)
397 + require.Equal(t, "switch_facing", port1["topology_role"])
398 + require.Equal(t, "medium", port1["topology_role_confidence"])
399 + require.Equal(t, []string{"stp", "fdb", "fdb_managed_alias"}, port1["topology_role_sources"])
400 +}
401 +
402 +func TestToTopologyData_EnrichesPortStatusesWithNeighborsFDBAndSTP(t *testing.T) {
403 + result := Result{
404 + Devices: []Device{
405 + {
406 + ID: "sw1",
407 + Hostname: "sw1",
408 + ChassisID: "00:11:22:33:44:55",
409 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
410 + },
411 + {
412 + ID: "sw2",
413 + Hostname: "sw2",
414 + ChassisID: "aa:bb:cc:dd:ee:ff",
415 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
416 + Labels: map[string]string{
417 + "capabilities_enabled": "bridge,router",
418 + },
419 + },
420 + },
421 + Interfaces: []Interface{
422 + {
423 + DeviceID: "sw1",
424 + IfIndex: 1,
425 + IfName: "Gi0/1",
426 + IfDescr: "Gi0/1",
427 + MAC: "00:11:22:33:44:55",
428 + Labels: map[string]string{
429 + "admin_status": "up",
430 + "oper_status": "up",
431 + "if_alias": "uplink-core",
432 + "speed_bps": "1000000000",
433 + "last_change": "12345",
434 + "duplex": "full",
435 + },
436 + },
437 + {
438 + DeviceID: "sw1",
439 + IfIndex: 2,
440 + IfName: "Gi0/2",
441 + IfDescr: "Gi0/2",
442 + MAC: "00:11:22:33:44:66",
443 + Labels: map[string]string{
444 + "admin_status": "down",
445 + "oper_status": "down",
446 + "if_alias": "server-a",
447 + "speed_bps": "100000000",
448 + "last_change": "54321",
449 + "duplex": "half",
450 + },
451 + },
452 + },
453 + Adjacencies: []Adjacency{
454 + {
455 + Protocol: "lldp",
456 + SourceID: "sw1",
457 + SourcePort: "Gi0/1",
458 + TargetID: "sw2",
459 + TargetPort: "Gi0/24",
460 + },
461 + {
462 + Protocol: "cdp",
463 + SourceID: "sw1",
464 + SourcePort: "Gi0/1",
465 + TargetID: "sw2",
466 + TargetPort: "Gi0/24",
467 + },
468 + {
469 + Protocol: "stp",
470 + SourceID: "sw1",
471 + SourcePort: "Gi0/1",
472 + TargetID: "sw2",
473 + TargetPort: "Gi0/24",
474 + Labels: map[string]string{
475 + "stp_state": "forwarding",
476 + "vlan_id": "10",
477 + },
478 + },
479 + {
480 + Protocol: "stp",
481 + SourceID: "sw1",
482 + SourcePort: "Gi0/1",
483 + TargetID: "sw2",
484 + TargetPort: "Gi0/24",
485 + Labels: map[string]string{
486 + "stp_state": "blocking",
487 + "vlan_id": "20",
488 + },
489 + },
490 + },
491 + Attachments: []Attachment{
492 + {
493 + DeviceID: "sw1",
494 + IfIndex: 1,
495 + EndpointID: "mac:00:00:00:00:10:01",
496 + Method: "fdb",
497 + Labels: map[string]string{
498 + "fdb_status": "learned",
499 + "vlan_id": "10",
500 + },
501 + },
502 + {
503 + DeviceID: "sw1",
504 + IfIndex: 1,
505 + EndpointID: "mac:00:00:00:00:20:01",
506 + Method: "fdb",
507 + Labels: map[string]string{
508 + "fdb_status": "learned",
509 + "vlan_id": "20",
510 + },
511 + },
512 + {
513 + DeviceID: "sw1",
514 + IfIndex: 2,
515 + EndpointID: "mac:00:00:00:00:30:01",
516 + Method: "fdb",
517 + Labels: map[string]string{
518 + "fdb_status": "learned",
519 + },
520 + },
521 + },
522 + }
523 +
524 + data := ToTopologyData(result, TopologyDataOptions{
525 + Source: "snmp",
526 + Layer: "2",
527 + View: "summary",
528 + })
529 +
530 + actor := findActorBySysName(data.Actors, "sw1")
531 + require.NotNil(t, actor)
532 + require.Equal(t, 1, actor.Attributes["ports_up"])
533 + require.Equal(t, 1, actor.Attributes["ports_down"])
534 + require.Equal(t, 1, actor.Attributes["ports_admin_down"])
535 + require.EqualValues(t, 1_000_000_000, actor.Attributes["total_bandwidth_bps"])
536 + require.Equal(t, 3, actor.Attributes["fdb_total_macs"])
537 + require.Equal(t, 2, actor.Attributes["vlan_count"])
538 + require.Equal(t, 1, actor.Attributes["lldp_neighbor_count"])
539 + require.Equal(t, 1, actor.Attributes["cdp_neighbor_count"])
540 +
541 + statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
542 + require.True(t, ok)
543 +
544 + port1 := findInterfaceStatusByIndex(statuses, 1)
545 + require.Equal(t, "Gi0/1", port1["if_descr"])
546 + require.Equal(t, "uplink-core", port1["if_alias"])
547 + require.Equal(t, "00:11:22:33:44:55", port1["mac"])
548 + require.EqualValues(t, 1_000_000_000, port1["speed"])
549 + require.EqualValues(t, 12345, port1["last_change"])
550 + require.Equal(t, "full", port1["duplex"])
551 + require.Equal(t, 2, port1["fdb_mac_count"])
552 + require.Equal(t, "blocking", port1["stp_state"])
553 + vlans, ok := port1["vlans"].([]map[string]any)
554 + require.True(t, ok)
555 + require.Len(t, vlans, 2)
556 + require.Equal(t, "10", vlans[0]["vlan_id"])
557 + require.Equal(t, true, vlans[0]["tagged"])
558 + require.Equal(t, "20", vlans[1]["vlan_id"])
559 + require.Equal(t, true, vlans[1]["tagged"])
560 + neighbors, ok := port1["neighbors"].([]map[string]any)
561 + require.True(t, ok)
562 + require.Len(t, neighbors, 2)
563 +
564 + cdpNeighbor := findNeighborByProtocol(neighbors, "cdp")
565 + require.NotNil(t, cdpNeighbor)
566 + require.Equal(t, "sw2", cdpNeighbor["remote_device"])
567 + require.Equal(t, "Gi0/24", cdpNeighbor["remote_port"])
568 + require.Equal(t, "10.0.0.2", cdpNeighbor["remote_ip"])
569 + require.Equal(t, "aa:bb:cc:dd:ee:ff", cdpNeighbor["remote_chassis_id"])
570 + require.Equal(t, []string{"bridge", "router"}, cdpNeighbor["remote_capabilities"])
571 +
572 + lldpNeighbor := findNeighborByProtocol(neighbors, "lldp")
573 + require.NotNil(t, lldpNeighbor)
574 + require.Equal(t, "sw2", lldpNeighbor["remote_device"])
575 + require.Equal(t, "Gi0/24", lldpNeighbor["remote_port"])
576 + require.Equal(t, "10.0.0.2", lldpNeighbor["remote_ip"])
577 + require.Equal(t, "aa:bb:cc:dd:ee:ff", lldpNeighbor["remote_chassis_id"])
578 + require.Equal(t, []string{"bridge", "router"}, lldpNeighbor["remote_capabilities"])
579 +
580 + port2 := findInterfaceStatusByIndex(statuses, 2)
581 + require.Equal(t, "server-a", port2["if_alias"])
582 + require.Equal(t, "00:11:22:33:44:66", port2["mac"])
583 + require.EqualValues(t, 100_000_000, port2["speed"])
584 + require.EqualValues(t, 54321, port2["last_change"])
585 + require.Equal(t, "half", port2["duplex"])
586 + require.Equal(t, 1, port2["fdb_mac_count"])
587 + _, hasNeighbors := port2["neighbors"]
588 + require.False(t, hasNeighbors)
589 +}
590 +
591 +func TestToTopologyData_InfersVendorFromMACOUI(t *testing.T) {
592 + result := Result{
593 + Devices: []Device{
594 + {
595 + ID: "sw1",
596 + Hostname: "sw1",
597 + ChassisID: "00:11:22:33:44:55",
598 + },
599 + {
600 + ID: "remote-device",
601 + Hostname: "edge-remote",
602 + ChassisID: "28:6f:b9:00:00:22",
603 + Labels: map[string]string{"inferred": "true"},
604 + },
605 + },
606 + Interfaces: []Interface{
607 + {DeviceID: "sw1", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
608 + },
609 + Attachments: []Attachment{
610 + {DeviceID: "sw1", IfIndex: 1, EndpointID: "mac:08:ea:44:11:22:33", Method: "fdb"},
611 + },
612 + Enrichments: []Enrichment{
613 + {EndpointID: "mac:08:ea:44:11:22:33", MAC: "08:ea:44:11:22:33"},
614 + },
615 + }
616 +
617 + data := ToTopologyData(result, TopologyDataOptions{
618 + Source: "snmp",
619 + Layer: "2",
620 + View: "summary",
621 + })
622 +
623 + remote := findActorBySysName(data.Actors, "edge-remote")
624 + require.NotNil(t, remote)
625 + require.Equal(t, "Nokia Shanghai Bell Co., Ltd.", remote.Attributes["vendor"])
626 + require.Equal(t, "mac_oui", remote.Attributes["vendor_source"])
627 + require.Equal(t, "low", remote.Attributes["vendor_confidence"])
628 + require.Equal(t, "Nokia Shanghai Bell Co., Ltd.", remote.Attributes["vendor_derived"])
629 + require.Equal(t, "mac_oui", remote.Attributes["vendor_derived_source"])
630 + require.Equal(t, "low", remote.Attributes["vendor_derived_confidence"])
631 + require.NotEmpty(t, remote.Attributes["vendor_derived_match_prefix"])
632 +
633 + endpoint := findActorByMAC(data.Actors, "08:ea:44:11:22:33")
634 + require.NotNil(t, endpoint)
635 + require.Equal(t, "endpoint", endpoint.ActorType)
636 + require.Equal(t, "Extreme Networks Headquarters", endpoint.Attributes["vendor"])
637 + require.Equal(t, "mac_oui", endpoint.Attributes["vendor_source"])
638 + require.Equal(t, "low", endpoint.Attributes["vendor_confidence"])
639 + require.Equal(t, "Extreme Networks Headquarters", endpoint.Attributes["vendor_derived"])
640 + require.Equal(t, "mac_oui", endpoint.Attributes["vendor_derived_source"])
641 + require.Equal(t, "low", endpoint.Attributes["vendor_derived_confidence"])
642 + require.NotEmpty(t, endpoint.Attributes["vendor_derived_match_prefix"])
643 +}
644 +
645 +func TestDeviceToTopologyActor_DoesNotOverrideExplicitVendor(t *testing.T) {
646 + actor := deviceToTopologyActor(
647 + Device{
648 + ID: "switch-a",
649 + Hostname: "switch-a",
650 + ChassisID: "08:ea:44:99:88:77",
651 + Labels: map[string]string{
652 + "vendor": "Explicit Vendor",
653 + },
654 + },
655 + "snmp",
656 + "2",
657 + "",
658 + topologyDeviceInterfaceSummary{},
659 + nil,
660 + )
661 +
662 + require.Equal(t, "Explicit Vendor", actor.Attributes["vendor"])
663 + require.Equal(t, "labels", actor.Attributes["vendor_source"])
664 + require.Equal(t, "high", actor.Attributes["vendor_confidence"])
665 + require.Equal(t, "Extreme Networks Headquarters", actor.Attributes["vendor_derived"])
666 + require.Equal(t, "mac_oui", actor.Attributes["vendor_derived_source"])
667 + require.Equal(t, "low", actor.Attributes["vendor_derived_confidence"])
668 + require.NotEmpty(t, actor.Attributes["vendor_derived_match_prefix"])
669 +}
670 +
671 +func TestToTopologyData_DefaultDiscoveredCountWithoutLocalID(t *testing.T) {
672 + result := Result{
673 + Devices: []Device{
674 + {ID: "a", Hostname: "a"},
675 + {ID: "b", Hostname: "b"},
676 + {ID: "c", Hostname: "c"},
677 + },
678 + }
679 +
680 + data := ToTopologyData(result, TopologyDataOptions{})
681 + require.Equal(t, 2, data.Stats["devices_discovered"])
682 +}
683 +
684 +func TestToTopologyData_AssignsDeterministicActorIDsAndLinkActorIDs(t *testing.T) {
685 + result := Result{
686 + Devices: []Device{
687 + {
688 + ID: "sw1",
689 + Hostname: "sw1",
690 + ChassisID: "00:11:22:33:44:55",
691 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
692 + },
693 + {
694 + ID: "sw2",
695 + Hostname: "sw2",
696 + ChassisID: "00:11:22:33:44:66",
697 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
698 + },
699 + },
700 + Adjacencies: []Adjacency{
701 + {
702 + Protocol: "lldp",
703 + SourceID: "sw1",
704 + SourcePort: "Gi0/1",
705 + TargetID: "sw2",
706 + TargetPort: "Gi0/2",
707 + },
708 + },
709 + }
710 +
711 + data := ToTopologyData(result, TopologyDataOptions{
712 + Source: "snmp",
713 + Layer: "2",
714 + View: "summary",
715 + })
716 +
717 + require.Len(t, data.Actors, 2)
718 + actorIDs := make(map[string]struct{}, len(data.Actors))
719 + for _, actor := range data.Actors {
720 + require.NotEmpty(t, actor.ActorID)
721 + _, exists := actorIDs[actor.ActorID]
722 + require.False(t, exists, "duplicate actor_id %q", actor.ActorID)
723 + actorIDs[actor.ActorID] = struct{}{}
724 + }
725 +
726 + require.Len(t, data.Links, 1)
727 + require.NotEmpty(t, data.Links[0].SrcActorID)
728 + require.NotEmpty(t, data.Links[0].DstActorID)
729 + _, srcExists := actorIDs[data.Links[0].SrcActorID]
730 + _, dstExists := actorIDs[data.Links[0].DstActorID]
731 + require.True(t, srcExists)
732 + require.True(t, dstExists)
733 +}
734 +
735 +func TestToTopologyData_DeterministicAcrossRepeatedCalls(t *testing.T) {
736 + collectedAt := time.Date(2026, time.February, 20, 4, 5, 6, 0, time.UTC)
737 +
738 + result := Result{
739 + CollectedAt: collectedAt,
740 + Devices: []Device{
741 + {
742 + ID: "local-device",
743 + Hostname: "sw1",
744 + ChassisID: "00:11:22:33:44:55",
745 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
746 + Labels: map[string]string{"protocols_observed": "bridge,fdb,stp"},
747 + },
748 + {
749 + ID: "remote-device",
750 + Hostname: "sw2",
751 + ChassisID: "aa:bb:cc:dd:ee:ff",
752 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
753 + Labels: map[string]string{"inferred": "true"},
754 + },
755 + },
756 + Interfaces: []Interface{
757 + {DeviceID: "local-device", IfIndex: 3, IfName: "Gi0/3", IfDescr: "Gi0/3", Labels: map[string]string{"admin_status": "up", "oper_status": "up"}},
758 + {DeviceID: "local-device", IfIndex: 4, IfName: "Gi0/4", IfDescr: "Gi0/4", Labels: map[string]string{"admin_status": "up", "oper_status": "lowerLayerDown"}},
759 + },
760 + Adjacencies: []Adjacency{
761 + {
762 + Protocol: "lldp",
763 + SourceID: "local-device",
764 + SourcePort: "Gi0/3",
765 + TargetID: "remote-device",
766 + TargetPort: "Gi0/1",
767 + },
768 + },
769 + Attachments: []Attachment{
770 + {DeviceID: "local-device", IfIndex: 4, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
771 + },
772 + Enrichments: []Enrichment{
773 + {
774 + EndpointID: "mac:70:49:a2:65:72:cd",
775 + MAC: "70:49:a2:65:72:cd",
776 + IPs: []netip.Addr{netip.MustParseAddr("10.20.4.84")},
777 + Labels: map[string]string{
778 + "sources": "arp",
779 + "if_indexes": "4",
780 + "if_names": "Gi0/4",
781 + },
782 + },
783 + },
784 + }
785 +
786 + opts := TopologyDataOptions{
787 + SchemaVersion: "2.0",
788 + Source: "snmp",
789 + Layer: "2",
790 + View: "summary",
791 + AgentID: "agent-1",
792 + LocalDeviceID: "local-device",
793 + }
794 +
795 + baseline := ToTopologyData(result, opts)
796 + for range 10 {
797 + next := ToTopologyData(result, opts)
798 + require.Equal(t, baseline, next)
799 + }
800 +}
801 +
802 +func TestToTopologyData_DeduplicatesEndpointActorOverlappingManagedDevice(t *testing.T) {
803 + result := Result{
804 + Devices: []Device{
805 + {
806 + ID: "sw1",
807 + Hostname: "sw1",
808 + ChassisID: "7049a26572cd",
809 + Addresses: []netip.Addr{netip.MustParseAddr("10.20.4.84")},
810 + },
811 + },
812 + Attachments: []Attachment{
813 + {
814 + DeviceID: "sw1",
815 + IfIndex: 1,
816 + EndpointID: "mac:70:49:a2:65:72:cd",
817 + Method: "fdb",
818 + },
819 + },
820 + Enrichments: []Enrichment{
821 + {
822 + EndpointID: "mac:70:49:a2:65:72:cd",
823 + MAC: "70:49:a2:65:72:cd",
824 + IPs: []netip.Addr{netip.MustParseAddr("10.20.4.84")},
825 + },
826 + },
827 + }
828 +
829 + data := ToTopologyData(result, TopologyDataOptions{
830 + Source: "snmp",
831 + Layer: "2",
832 + View: "summary",
833 + })
834 +
835 + require.Len(t, data.Actors, 1)
836 + require.Equal(t, "device", data.Actors[0].ActorType)
837 + require.Equal(t, 0, data.Stats["endpoints_total"])
838 + require.Equal(t, 1, data.Stats["actors_total"])
839 + require.Equal(t, 0, data.Stats["links_total"])
840 + require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
841 + require.Equal(t, 1, data.Stats["segments_suppressed"])
842 +}
843 +
844 +func TestCanonicalTopologyMatchKey_NormalizesEquivalentMACRepresentations(t *testing.T) {
845 + raw := topology.Match{
846 + ChassisIDs: []string{"7049a26572cd"},
847 + }
848 + colon := topology.Match{
849 + ChassisIDs: []string{"70:49:A2:65:72:CD"},
850 + }
851 +
852 + require.Equal(t, canonicalTopologyMatchKey(raw), canonicalTopologyMatchKey(colon))
853 + require.Equal(t, "mac:70:49:a2:65:72:cd", canonicalTopologyMatchKey(raw))
854 +}
855 +
856 +func TestToTopologyData_UsesDeterministicPrimaryManagementIP(t *testing.T) {
857 + result := Result{
858 + Devices: []Device{
859 + {
860 + ID: "device-a",
861 + Hostname: "device-a",
862 + ChassisID: "aa:bb:cc:dd:ee:ff",
863 + Addresses: []netip.Addr{
864 + netip.MustParseAddr("10.0.0.9"),
865 + netip.MustParseAddr("10.0.0.2"),
866 + netip.MustParseAddr("10.0.0.9"),
867 + },
868 + },
869 + },
870 + }
871 +
872 + data := ToTopologyData(result, TopologyDataOptions{
873 + Source: "snmp",
874 + Layer: "2",
875 + View: "summary",
876 + })
877 +
878 + actor := findActorBySysName(data.Actors, "device-a")
879 + require.NotNil(t, actor)
880 + require.Equal(t, "10.0.0.2", actor.Attributes["management_ip"])
881 + require.Equal(t, []string{"10.0.0.2", "10.0.0.9"}, actor.Attributes["management_addresses"])
882 +}
883 +
884 +func TestToTopologyData_KeepsDistinctActorsWhenMACDiffersDespiteSameSecondaryIdentity(t *testing.T) {
885 + result := Result{
886 + Devices: []Device{
887 + {
888 + ID: "device-a",
889 + Hostname: "shared-name",
890 + ChassisID: "00:11:22:33:44:55",
891 + Addresses: []netip.Addr{netip.MustParseAddr("10.20.30.40")},
892 + },
893 + {
894 + ID: "device-b",
895 + Hostname: "shared-name",
896 + ChassisID: "00:11:22:33:44:66",
897 + Addresses: []netip.Addr{netip.MustParseAddr("10.20.30.40")},
898 + },
899 + },
900 + }
901 +
902 + data := ToTopologyData(result, TopologyDataOptions{
903 + Source: "snmp",
904 + Layer: "2",
905 + View: "summary",
906 + })
907 +
908 + require.Len(t, data.Actors, 2)
909 + macs := make(map[string]struct{}, 2)
910 + for _, actor := range data.Actors {
911 + require.Equal(t, "device", actor.ActorType)
912 + require.NotEmpty(t, actor.Match.MacAddresses)
913 + macs[actor.Match.MacAddresses[0]] = struct{}{}
914 + }
915 + require.Contains(t, macs, "00:11:22:33:44:55")
916 + require.Contains(t, macs, "00:11:22:33:44:66")
917 +}
918 +
919 +func TestToTopologyData_MergesPairedAdjacenciesIntoBidirectionalLink(t *testing.T) {
920 + result := Result{
921 + Devices: []Device{
922 + {
923 + ID: "switch-a",
924 + Hostname: "switch-a",
925 + ChassisID: "aa:aa:aa:aa:aa:aa",
926 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
927 + },
928 + {
929 + ID: "switch-b",
930 + Hostname: "switch-b",
931 + ChassisID: "bb:bb:bb:bb:bb:bb",
932 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
933 + },
934 + },
935 + Interfaces: []Interface{
936 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
937 + {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
938 + },
939 + Adjacencies: []Adjacency{
940 + {
941 + Protocol: "lldp",
942 + SourceID: "switch-a",
943 + SourcePort: "Gi0/1",
944 + TargetID: "switch-b",
945 + TargetPort: "Gi0/2",
946 + Labels: map[string]string{
947 + adjacencyLabelPairID: "lldp:pair-a-b",
948 + adjacencyLabelPairPass: lldpMatchPassDefault,
949 + },
950 + },
951 + {
952 + Protocol: "lldp",
953 + SourceID: "switch-b",
954 + SourcePort: "Gi0/2",
955 + TargetID: "switch-a",
956 + TargetPort: "Gi0/1",
957 + Labels: map[string]string{
958 + adjacencyLabelPairID: "lldp:pair-a-b",
959 + adjacencyLabelPairPass: lldpMatchPassDefault,
960 + },
961 + },
962 + },
963 + }
964 +
965 + data := ToTopologyData(result, TopologyDataOptions{
966 + Source: "snmp",
967 + Layer: "2",
968 + View: "summary",
969 + })
970 +
971 + require.Len(t, data.Links, 1)
972 + link := data.Links[0]
973 + require.Equal(t, "lldp", link.Protocol)
974 + require.Equal(t, "bidirectional", link.Direction)
975 + require.Equal(t, "Gi0/1", link.Src.Attributes["if_name"])
976 + require.Equal(t, "Gi0/1", link.Src.Attributes["port_id"])
977 + require.Equal(t, "Gi0/2", link.Dst.Attributes["if_name"])
978 + require.Equal(t, "Gi0/2", link.Dst.Attributes["port_id"])
979 +
980 + require.NotNil(t, link.Metrics)
981 + require.Equal(t, "lldp:pair-a-b", link.Metrics[adjacencyLabelPairID])
982 + require.Equal(t, lldpMatchPassDefault, link.Metrics[adjacencyLabelPairPass])
983 + require.Equal(t, true, link.Metrics["pair_consistent"])
984 +
985 + require.Equal(t, 1, data.Stats["links_total"])
986 + require.Equal(t, 1, data.Stats["links_lldp"])
987 + require.Equal(t, 1, data.Stats["links_bidirectional"])
988 + require.Equal(t, 0, data.Stats["links_unidirectional"])
989 +}
990 +
991 +func TestToTopologyData_MergesPairedAdjacenciesPreservesRawAddressHints(t *testing.T) {
992 + result := Result{
993 + Devices: []Device{
994 + {
995 + ID: "switch-a",
996 + Hostname: "switch-a",
997 + ChassisID: "aa:aa:aa:aa:aa:aa",
998 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
999 + },
1000 + {
1001 + ID: "switch-b",
1002 + Hostname: "switch-b",
1003 + ChassisID: "bb:bb:bb:bb:bb:bb",
1004 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1005 + },
1006 + },
1007 + Adjacencies: []Adjacency{
1008 + {
1009 + Protocol: "cdp",
1010 + SourceID: "switch-a",
1011 + SourcePort: "Gi0/1",
1012 + TargetID: "switch-b",
1013 + TargetPort: "Gi0/2",
1014 + Labels: map[string]string{
1015 + adjacencyLabelPairID: "cdp:pair-a-b",
1016 + adjacencyLabelPairPass: cdpMatchPassDefault,
1017 + "remote_address_raw": "edge-sw3.mgmt.local",
1018 + },
1019 + },
1020 + {
1021 + Protocol: "cdp",
1022 + SourceID: "switch-b",
1023 + SourcePort: "Gi0/2",
1024 + TargetID: "switch-a",
1025 + TargetPort: "Gi0/1",
1026 + Labels: map[string]string{
1027 + adjacencyLabelPairID: "cdp:pair-a-b",
1028 + adjacencyLabelPairPass: cdpMatchPassDefault,
1029 + "remote_address_raw": "10.0.0.1",
1030 + },
1031 + },
1032 + },
1033 + }
1034 +
1035 + data := ToTopologyData(result, TopologyDataOptions{
1036 + Source: "snmp",
1037 + Layer: "2",
1038 + View: "summary",
1039 + })
1040 +
1041 + require.Len(t, data.Links, 1)
1042 + link := data.Links[0]
1043 + require.Equal(t, "cdp", link.Protocol)
1044 + require.Equal(t, "bidirectional", link.Direction)
1045 + require.Contains(t, link.Dst.Match.IPAddresses, "edge-sw3.mgmt.local")
1046 + require.Contains(t, link.Metrics, "src_remote_address_raw")
1047 + require.Contains(t, link.Metrics, "dst_remote_address_raw")
1048 +}
1049 +
1050 +func TestToTopologyData_MergesReversePairsWithoutDirectionalPairLabels(t *testing.T) {
1051 + result := Result{
1052 + Devices: []Device{
1053 + {
1054 + ID: "router-a",
1055 + Hostname: "MikroTik-router",
1056 + ChassisID: "aa:aa:aa:aa:aa:aa",
1057 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1058 + },
1059 + {
1060 + ID: "switch-b",
1061 + Hostname: "XS1930",
1062 + ChassisID: "bb:bb:bb:bb:bb:bb",
1063 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1064 + },
1065 + },
1066 + Interfaces: []Interface{
1067 + {DeviceID: "router-a", IfIndex: 3, IfName: "ether3", IfDescr: "ether3"},
1068 + {DeviceID: "switch-b", IfIndex: 8, IfName: "swp07", IfDescr: "swp07"},
1069 + },
1070 + Adjacencies: []Adjacency{
1071 + {
1072 + Protocol: "lldp",
1073 + SourceID: "router-a",
1074 + SourcePort: "ether3",
1075 + TargetID: "switch-b",
1076 + TargetPort: "",
1077 + Labels: map[string]string{
1078 + adjacencyLabelPairID: "lldp:pair-router-xs",
1079 + adjacencyLabelPairPass: lldpMatchPassPortDesc,
1080 + },
1081 + },
1082 + {
1083 + Protocol: "lldp",
1084 + SourceID: "switch-b",
1085 + SourcePort: "8",
1086 + TargetID: "router-a",
1087 + TargetPort: "ether3",
1088 + Labels: map[string]string{
1089 + adjacencyLabelPairID: "lldp:pair-router-xs",
1090 + adjacencyLabelPairPass: lldpMatchPassPortDesc,
1091 + },
1092 + },
1093 + },
1094 + }
1095 +
1096 + data := ToTopologyData(result, TopologyDataOptions{
1097 + Source: "snmp",
1098 + Layer: "2",
1099 + View: "summary",
1100 + })
1101 +
1102 + require.Equal(t, 1, data.Stats["links_total"])
1103 + require.Equal(t, 1, data.Stats["links_lldp"])
1104 + require.Equal(t, 1, data.Stats["links_bidirectional"])
1105 + require.Equal(t, 0, data.Stats["links_unidirectional"])
1106 + require.Len(t, data.Links, 1)
1107 +
1108 + link := data.Links[0]
1109 + require.Equal(t, "lldp", link.Protocol)
1110 + require.Equal(t, "bidirectional", link.Direction)
1111 + require.Equal(t, "MikroTik-router", topologyAttrString(link.Src.Attributes, "sys_name"))
1112 + require.Equal(t, "XS1930", topologyAttrString(link.Dst.Attributes, "sys_name"))
1113 + require.Equal(t, "ether3", topologyAttrString(link.Src.Attributes, "if_name"))
1114 + require.Equal(t, "swp07", topologyAttrString(link.Dst.Attributes, "if_name"))
1115 + require.Equal(t, "8", topologyAttrString(link.Dst.Attributes, "port_id"))
1116 + require.Equal(t, "swp07", topologyAttrString(link.Dst.Attributes, "port_name"))
1117 +}
1118 +
1119 +func TestToTopologyData_UnknownAdjacencyPortsRemainUnsetWithoutZeroFallback(t *testing.T) {
1120 + result := Result{
1121 + Devices: []Device{
1122 + {
1123 + ID: "router-a",
1124 + Hostname: "MikroTik-router",
1125 + ChassisID: "aa:aa:aa:aa:aa:aa",
1126 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1127 + },
1128 + {
1129 + ID: "switch-b",
1130 + Hostname: "XS1930",
1131 + ChassisID: "bb:bb:bb:bb:bb:bb",
1132 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1133 + },
1134 + },
1135 + Interfaces: []Interface{
1136 + {DeviceID: "router-a", IfIndex: 3, IfName: "ether3", IfDescr: "ether3"},
1137 + },
1138 + Adjacencies: []Adjacency{
1139 + {
1140 + Protocol: "lldp",
1141 + SourceID: "router-a",
1142 + SourcePort: "ether3",
1143 + TargetID: "switch-b",
1144 + TargetPort: "",
1145 + },
1146 + },
1147 + }
1148 +
1149 + data := ToTopologyData(result, TopologyDataOptions{
1150 + Source: "snmp",
1151 + Layer: "2",
1152 + View: "summary",
1153 + })
1154 +
1155 + require.Len(t, data.Links, 1)
1156 + link := data.Links[0]
1157 + require.Equal(t, "lldp", link.Protocol)
1158 + require.Equal(t, "unidirectional", link.Direction)
1159 + _, hasPortName := link.Dst.Attributes["port_name"]
1160 + require.False(t, hasPortName)
1161 + require.Equal(t, "", strings.TrimSpace(topologyMetricString(link.Metrics, "dst_port_name")))
1162 + require.Contains(t, topologyMetricString(link.Metrics, "display_name"), ":[unset]")
1163 +}
1164 +
1165 +func TestToTopologyData_DropsAmbiguousEndpointSegmentLinks(t *testing.T) {
1166 + result := Result{
1167 + Devices: []Device{
1168 + {
1169 + ID: "switch-a",
1170 + Hostname: "switch-a",
1171 + ChassisID: "aa:aa:aa:aa:aa:aa",
1172 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1173 + },
1174 + {
1175 + ID: "switch-b",
1176 + Hostname: "switch-b",
1177 + ChassisID: "bb:bb:bb:bb:bb:bb",
1178 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1179 + },
1180 + },
1181 + Interfaces: []Interface{
1182 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1183 + {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1184 + },
1185 + Attachments: []Attachment{
1186 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
1187 + {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
1188 + },
1189 + }
1190 +
1191 + data := ToTopologyData(result, TopologyDataOptions{
1192 + Source: "snmp",
1193 + Layer: "2",
1194 + View: "summary",
1195 + })
1196 +
1197 + bridgeLinks := 0
1198 + fdbLinks := 0
1199 + for _, link := range data.Links {
1200 + switch link.Protocol {
1201 + case "bridge":
1202 + bridgeLinks++
1203 + case "fdb":
1204 + fdbLinks++
1205 + }
1206 + }
1207 +
1208 + require.Equal(t, 0, bridgeLinks)
1209 + require.Equal(t, 0, fdbLinks)
1210 + require.Equal(t, 0, data.Stats["links_total"])
1211 + require.Equal(t, 0, data.Stats["links_fdb"])
1212 + require.Equal(t, 2, data.Stats["links_fdb_endpoint_candidates"])
1213 + require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
1214 + require.Equal(t, 2, data.Stats["links_fdb_endpoint_suppressed"])
1215 + require.Equal(t, 1, data.Stats["endpoints_ambiguous_segments"])
1216 + require.Equal(t, 2, data.Stats["segments_suppressed"])
1217 +}
1218 +
1219 +func TestToTopologyData_ProbableConnectivityConnectsAmbiguousEndpoint(t *testing.T) {
1220 + result := Result{
1221 + Devices: []Device{
1222 + {
1223 + ID: "switch-a",
1224 + Hostname: "switch-a",
1225 + ChassisID: "aa:aa:aa:aa:aa:aa",
1226 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1227 + },
1228 + {
1229 + ID: "switch-b",
1230 + Hostname: "switch-b",
1231 + ChassisID: "bb:bb:bb:bb:bb:bb",
1232 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1233 + },
1234 + },
1235 + Interfaces: []Interface{
1236 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1237 + {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1238 + },
1239 + Attachments: []Attachment{
1240 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
1241 + {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
1242 + },
1243 + }
1244 +
1245 + strictData := ToTopologyData(result, TopologyDataOptions{
1246 + Source: "snmp",
1247 + Layer: "2",
1248 + View: "summary",
1249 + })
1250 + require.Len(t, findFDBLinksByEndpointMAC(strictData.Links, "70:49:a2:65:72:cd"), 0)
1251 +
1252 + data := ToTopologyData(result, TopologyDataOptions{
1253 + Source: "snmp",
1254 + Layer: "2",
1255 + View: "summary",
1256 + ProbabilisticConnectivity: true,
1257 + })
1258 +
1259 + fdbLinks := findFDBLinksByEndpointMAC(data.Links, "70:49:a2:65:72:cd")
1260 + require.Len(t, fdbLinks, 1)
1261 + require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1262 + require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(topologyMetricString(fdbLinks[0].Metrics, "inference"))))
1263 + require.Equal(t, "probable_segment", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1264 + require.Equal(t, "low", topologyMetricString(fdbLinks[0].Metrics, "confidence"))
1265 +
1266 + require.Equal(t, 1, data.Stats["links_probable"])
1267 + require.Equal(t, 1, data.Stats["links_fdb_endpoint_emitted"])
1268 + require.Equal(t, 1, data.Stats["links_fdb_endpoint_suppressed"])
1269 +}
1270 +
1271 +func TestToTopologyData_ProbableConnectivityDoesNotReclassifyStrictSinglePortEndpoint(t *testing.T) {
1272 + result := Result{
1273 + Devices: []Device{
1274 + {
1275 + ID: "switch-a",
1276 + Hostname: "switch-a",
1277 + ChassisID: "aa:aa:aa:aa:aa:aa",
1278 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1279 + },
1280 + },
1281 + Interfaces: []Interface{
1282 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1283 + },
1284 + Attachments: []Attachment{
1285 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
1286 + },
1287 + }
1288 +
1289 + strictData := ToTopologyData(result, TopologyDataOptions{
1290 + Source: "snmp",
1291 + Layer: "2",
1292 + View: "summary",
1293 + })
1294 + strictFDBLinks := findFDBLinksByEndpointMAC(strictData.Links, "dd:dd:dd:dd:dd:dd")
1295 + require.Len(t, strictFDBLinks, 1)
1296 + require.Equal(t, "", strings.TrimSpace(strictFDBLinks[0].State))
1297 + require.Equal(t, "", strings.TrimSpace(topologyMetricString(strictFDBLinks[0].Metrics, "inference")))
1298 +
1299 + data := ToTopologyData(result, TopologyDataOptions{
1300 + Source: "snmp",
1301 + Layer: "2",
1302 + View: "summary",
1303 + ProbabilisticConnectivity: true,
1304 + })
1305 +
1306 + fdbLinks := findFDBLinksByEndpointMAC(data.Links, "dd:dd:dd:dd:dd:dd")
1307 + require.Len(t, fdbLinks, 1)
1308 + require.Equal(t, "", strings.TrimSpace(fdbLinks[0].State))
1309 + require.Equal(t, "", strings.TrimSpace(topologyMetricString(fdbLinks[0].Metrics, "inference")))
1310 + require.Equal(t, "direct", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1311 + require.Equal(t, 0, data.Stats["links_probable"])
1312 +}
1313 +
1314 +func TestToTopologyData_ProbableConnectivityConnectsUnlinkedLLDPEndpoint(t *testing.T) {
1315 + result := Result{
1316 + Devices: []Device{
1317 + {
1318 + ID: "switch-a",
1319 + Hostname: "switch-a",
1320 + ChassisID: "aa:aa:aa:aa:aa:aa",
1321 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1322 + },
1323 + {
1324 + ID: "switch-b",
1325 + Hostname: "switch-b",
1326 + ChassisID: "bb:bb:bb:bb:bb:bb",
1327 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1328 + },
1329 + },
1330 + Interfaces: []Interface{
1331 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1332 + {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1333 + },
1334 + Attachments: []Attachment{
1335 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:cf", Method: "lldp"},
1336 + {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:cf", Method: "lldp"},
1337 + },
1338 + }
1339 +
1340 + strictData := ToTopologyData(result, TopologyDataOptions{
1341 + Source: "snmp",
1342 + Layer: "2",
1343 + View: "summary",
1344 + })
1345 + require.Len(t, findFDBLinksByEndpointMAC(strictData.Links, "70:49:a2:65:72:cf"), 0)
1346 +
1347 + data := ToTopologyData(result, TopologyDataOptions{
1348 + Source: "snmp",
1349 + Layer: "2",
1350 + View: "summary",
1351 + ProbabilisticConnectivity: true,
1352 + })
1353 +
1354 + fdbLinks := findFDBLinksByEndpointMAC(data.Links, "70:49:a2:65:72:cf")
1355 + require.Len(t, fdbLinks, 1)
1356 + require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1357 + require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(topologyMetricString(fdbLinks[0].Metrics, "inference"))))
1358 + require.Equal(t, "probable_segment", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1359 +}
1360 +
1361 +func TestToTopologyData_ProbableConnectivityAvoidsExtraBridgePathForLLDPPeers(t *testing.T) {
1362 + result := Result{
1363 + Devices: []Device{
1364 + {
1365 + ID: "switch-a",
1366 + Hostname: "switch-a",
1367 + ChassisID: "aa:aa:aa:aa:aa:aa",
1368 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1369 + },
1370 + {
1371 + ID: "switch-b",
1372 + Hostname: "switch-b",
1373 + ChassisID: "bb:bb:bb:bb:bb:bb",
1374 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1375 + },
1376 + },
1377 + Interfaces: []Interface{
1378 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1379 + {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1380 + {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1381 + {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1382 + },
1383 + Adjacencies: []Adjacency{
1384 + {
1385 + Protocol: "lldp",
1386 + SourceID: "switch-a",
1387 + SourcePort: "Gi0/1",
1388 + TargetID: "switch-b",
1389 + TargetPort: "Gi0/1",
1390 + },
1391 + {
1392 + Protocol: "lldp",
1393 + SourceID: "switch-a",
1394 + SourcePort: "Gi0/2",
1395 + TargetID: "switch-b",
1396 + TargetPort: "Gi0/2",
1397 + },
1398 + },
1399 + Attachments: []Attachment{
1400 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:aa", Method: "lldp"},
1401 + {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:aa", Method: "lldp"},
1402 + {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:aa", Method: "lldp"},
1403 + {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:aa", Method: "lldp"},
1404 + },
1405 + }
1406 +
1407 + strictData := ToTopologyData(result, TopologyDataOptions{
1408 + Source: "snmp",
1409 + Layer: "2",
1410 + View: "summary",
1411 + })
1412 + require.Len(t, findFDBLinksByEndpointMAC(strictData.Links, "70:49:a2:65:72:aa"), 0)
1413 +
1414 + data := ToTopologyData(result, TopologyDataOptions{
1415 + Source: "snmp",
1416 + Layer: "2",
1417 + View: "summary",
1418 + ProbabilisticConnectivity: true,
1419 + })
1420 +
1421 + fdbLinks := findFDBLinksByEndpointMAC(data.Links, "70:49:a2:65:72:aa")
1422 + require.Len(t, fdbLinks, 1)
1423 + require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1424 +
1425 + bridgeLinksBySegment := make(map[string]map[string]struct{})
1426 + for _, link := range data.Links {
1427 + if !strings.EqualFold(strings.TrimSpace(link.Protocol), "bridge") {
1428 + continue
1429 + }
1430 + segmentActorID := strings.TrimSpace(link.DstActorID)
1431 + if segmentActorID == "" {
1432 + continue
1433 + }
1434 + devices := bridgeLinksBySegment[segmentActorID]
1435 + if devices == nil {
1436 + devices = make(map[string]struct{})
1437 + bridgeLinksBySegment[segmentActorID] = devices
1438 + }
1439 + devices[strings.TrimSpace(link.SrcActorID)] = struct{}{}
1440 + }
1441 + for _, devices := range bridgeLinksBySegment {
1442 + require.LessOrEqual(t, len(devices), 1)
1443 + }
1444 +}
1445 +
1446 +func TestToTopologyData_ProbableConnectivityConnectsZeroCandidateEndpointUsingReporterHints(t *testing.T) {
1447 + result := Result{
1448 + Devices: []Device{
1449 + {
1450 + ID: "switch-a",
1451 + Hostname: "switch-a",
1452 + ChassisID: "aa:aa:aa:aa:aa:aa",
1453 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1454 + },
1455 + },
1456 + Interfaces: []Interface{
1457 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1458 + },
1459 + Attachments: []Attachment{
1460 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
1461 + },
1462 + Enrichments: []Enrichment{
1463 + {
1464 + EndpointID: "ip:10.0.0.99",
1465 + IPs: []netip.Addr{netip.MustParseAddr("10.0.0.99")},
1466 + Labels: map[string]string{
1467 + "sources": "arp",
1468 + "device_ids": "switch-a",
1469 + "if_indexes": "1",
1470 + "if_names": "Gi0/1",
1471 + },
1472 + },
1473 + },
1474 + }
1475 +
1476 + strictData := ToTopologyData(result, TopologyDataOptions{
1477 + Source: "snmp",
1478 + Layer: "2",
1479 + View: "summary",
1480 + })
1481 + require.Len(t, findFDBLinksByEndpointIP(strictData.Links, "10.0.0.99"), 0)
1482 +
1483 + data := ToTopologyData(result, TopologyDataOptions{
1484 + Source: "snmp",
1485 + Layer: "2",
1486 + View: "summary",
1487 + ProbabilisticConnectivity: true,
1488 + })
1489 +
1490 + fdbLinks := findFDBLinksByEndpointIP(data.Links, "10.0.0.99")
1491 + require.Len(t, fdbLinks, 1)
1492 + require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1493 + require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(topologyMetricString(fdbLinks[0].Metrics, "inference"))))
1494 + require.Equal(t, "probable_segment", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1495 + require.Equal(t, "low", topologyMetricString(fdbLinks[0].Metrics, "confidence"))
1496 +
1497 + strictSignatures := topologyLinkSignatures(strictData.Links)
1498 + probableSignatures := topologyLinkSignatures(data.Links)
1499 + for signature := range strictSignatures {
1500 + _, ok := probableSignatures[signature]
1501 + require.Truef(t, ok, "strict link signature missing in probable output: %s", signature)
1502 + }
1503 +}
1504 +
1505 +func TestToTopologyData_ProbableConnectivityCreatesPortlessAttachmentForZeroCandidateEndpoint(t *testing.T) {
1506 + result := Result{
1507 + Devices: []Device{
1508 + {
1509 + ID: "switch-a",
1510 + Hostname: "switch-a",
1511 + ChassisID: "aa:aa:aa:aa:aa:aa",
1512 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1513 + },
1514 + },
1515 + Interfaces: []Interface{
1516 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1517 + },
1518 + Attachments: []Attachment{
1519 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
1520 + },
1521 + Enrichments: []Enrichment{
1522 + {
1523 + EndpointID: "ip:10.0.0.199",
1524 + IPs: []netip.Addr{netip.MustParseAddr("10.0.0.199")},
1525 + Labels: map[string]string{
1526 + "sources": "arp",
1527 + "device_ids": "switch-a",
1528 + "if_indexes": "999",
1529 + "if_names": "Gi0/999",
1530 + },
1531 + },
1532 + },
1533 + }
1534 +
1535 + data := ToTopologyData(result, TopologyDataOptions{
1536 + Source: "snmp",
1537 + Layer: "2",
1538 + View: "summary",
1539 + ProbabilisticConnectivity: true,
1540 + })
1541 +
1542 + fdbLinks := findFDBLinksByEndpointIP(data.Links, "10.0.0.199")
1543 + require.Len(t, fdbLinks, 1)
1544 + require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1545 + require.Equal(t, "probable_portless", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1546 +
1547 + segmentActor := findActorByMatch(data.Actors, fdbLinks[0].Src.Match)
1548 + require.NotNil(t, segmentActor)
1549 + require.Contains(t, topologyAttrString(segmentActor.Attributes, "segment_id"), "bridge-domain:probable:")
1550 + require.Equal(t, []string{"switch-a"}, segmentActor.Attributes["parent_devices"])
1551 +
1552 + bridgeCount := 0
1553 + for _, link := range data.Links {
1554 + if !strings.EqualFold(strings.TrimSpace(link.Protocol), "bridge") {
1555 + continue
1556 + }
1557 + if canonicalTopologyMatchKey(link.Dst.Match) != canonicalTopologyMatchKey(fdbLinks[0].Src.Match) {
1558 + continue
1559 + }
1560 + bridgeCount++
1561 + }
1562 + require.Equal(t, 1, bridgeCount)
1563 +}
1564 +
1565 +func TestToTopologyData_InferenceStrategy_STPParentDoesNotSuppressFDBEndpointOwnership(t *testing.T) {
1566 + result := Result{
1567 + Devices: []Device{
1568 + {
1569 + ID: "switch-a",
1570 + Hostname: "switch-a",
1571 + ChassisID: "aa:aa:aa:aa:aa:aa",
1572 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1573 + },
1574 + {
1575 + ID: "switch-b",
1576 + Hostname: "switch-b",
1577 + ChassisID: "bb:bb:bb:bb:bb:bb",
1578 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1579 + },
1580 + },
1581 + Interfaces: []Interface{
1582 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1583 + {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1584 + },
1585 + Adjacencies: []Adjacency{
1586 + {
1587 + Protocol: "stp",
1588 + SourceID: "switch-a",
1589 + SourcePort: "Gi0/1",
1590 + TargetID: "switch-b",
1591 + TargetPort: "Gi0/2",
1592 + },
1593 + },
1594 + Attachments: []Attachment{
1595 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:00:00:00:00:00:11", Method: "fdb"},
1596 + {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:00:00:00:00:00:22", Method: "fdb"},
1597 + },
1598 + }
1599 +
1600 + baseline := ToTopologyData(result, TopologyDataOptions{
1601 + Source: "snmp",
1602 + Layer: "2",
1603 + View: "summary",
1604 + })
1605 + require.Equal(t, topologyInferenceStrategyFDBMinimumKnowledge, baseline.Stats["inference_strategy"])
1606 + require.Greater(t, baseline.Stats["links_fdb_endpoint_emitted"].(int), 0)
1607 +
1608 + stpData := ToTopologyData(result, TopologyDataOptions{
1609 + Source: "snmp",
1610 + Layer: "2",
1611 + View: "summary",
1612 + InferenceStrategy: topologyInferenceStrategySTPParentTree,
1613 + })
1614 + require.Equal(t, topologyInferenceStrategySTPParentTree, stpData.Stats["inference_strategy"])
1615 + require.Greater(t, stpData.Stats["links_fdb_endpoint_emitted"].(int), 0)
1616 +}
1617 +
1618 +func TestToTopologyData_InferenceStrategy_CDPHybridPrefersCDPBridgeLinks(t *testing.T) {
1619 + result := Result{
1620 + Devices: []Device{
1621 + {ID: "sw-a", Hostname: "sw-a", ChassisID: "00:00:00:00:00:aa"},
1622 + {ID: "sw-b", Hostname: "sw-b", ChassisID: "00:00:00:00:00:bb"},
1623 + {ID: "sw-c", Hostname: "sw-c", ChassisID: "00:00:00:00:00:cc"},
1624 + },
1625 + Interfaces: []Interface{
1626 + {DeviceID: "sw-a", IfIndex: 1, IfName: "Gi0/1"},
1627 + {DeviceID: "sw-a", IfIndex: 2, IfName: "Gi0/2"},
1628 + {DeviceID: "sw-b", IfIndex: 1, IfName: "Gi0/1"},
1629 + {DeviceID: "sw-c", IfIndex: 1, IfName: "Gi0/1"},
1630 + },
1631 + Adjacencies: []Adjacency{
1632 + {
1633 + Protocol: "lldp",
1634 + SourceID: "sw-a",
1635 + SourcePort: "Gi0/1",
1636 + TargetID: "sw-b",
1637 + TargetPort: "Gi0/1",
1638 + },
1639 + {
1640 + Protocol: "cdp",
1641 + SourceID: "sw-a",
1642 + SourcePort: "Gi0/2",
1643 + TargetID: "sw-c",
1644 + TargetPort: "Gi0/1",
1645 + },
1646 + },
1647 + Attachments: []Attachment{
1648 + {DeviceID: "sw-a", IfIndex: 1, EndpointID: "mac:00:00:00:00:10:01", Method: "fdb"},
1649 + {DeviceID: "sw-a", IfIndex: 2, EndpointID: "mac:00:00:00:00:10:02", Method: "fdb"},
1650 + },
1651 + }
1652 +
1653 + baseline := ToTopologyData(result, TopologyDataOptions{
1654 + Source: "snmp",
1655 + Layer: "2",
1656 + View: "summary",
1657 + })
1658 + require.Equal(t, topologyInferenceStrategyFDBMinimumKnowledge, baseline.Stats["inference_strategy"])
1659 + require.Equal(t, 0, baseline.Stats["links_fdb_endpoint_emitted"])
1660 +
1661 + data := ToTopologyData(result, TopologyDataOptions{
1662 + Source: "snmp",
1663 + Layer: "2",
1664 + View: "summary",
1665 + InferenceStrategy: topologyInferenceStrategyCDPFDBHybrid,
1666 + })
1667 +
1668 + require.Equal(t, topologyInferenceStrategyCDPFDBHybrid, data.Stats["inference_strategy"])
1669 + require.Equal(t, 1, data.Stats["links_cdp"])
1670 + require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
1671 +}
1672 +
1673 +func TestPickProbableSegmentAnchorPortID_PrefersManagedPortWhenOwnerPointsToUnmanaged(t *testing.T) {
1674 + unmanagedPort := bridgePortRef{
1675 + deviceID: "ghost-switch",
1676 + ifIndex: 900,
1677 + ifName: "ghost0",
1678 + }
1679 + managedPort := bridgePortRef{
1680 + deviceID: "managed-switch",
1681 + ifIndex: 7,
1682 + ifName: "swp06",
1683 + }
1684 +
1685 + segment := newBridgeDomainSegment(unmanagedPort)
1686 + segment.addPort(managedPort)
1687 +
1688 + endpointID := "mac:50:2c:c6:a6:fc:35"
1689 + owner := fdbEndpointOwner{
1690 + portKey: bridgePortObservationKey(unmanagedPort),
1691 + portVLANKey: bridgePortObservationVLANKey(unmanagedPort),
1692 + port: unmanagedPort,
1693 + source: "single_port_mac",
1694 + }
1695 +
1696 + picked := pickProbableSegmentAnchorPortID(
1697 + segment,
1698 + map[string]struct{}{endpointID: {}},
1699 + map[string]fdbEndpointOwner{endpointID: owner},
1700 + map[string]struct{}{"managed-switch": {}},
1701 + )
1702 +
1703 + require.NotEmpty(t, picked)
1704 + require.Equal(t, bridgePortRefSortKey(managedPort), bridgePortRefSortKey(segment.ports[picked]))
1705 +}
1706 +
1707 +func TestSelectProbableEndpointReporterHint_PrefersManagedHintsOverUnmanagedOwner(t *testing.T) {
1708 + endpointLabels := map[string]string{
1709 + "learned_device_ids": "ghost-switch,managed-switch",
1710 + "learned_if_indexes": "7",
1711 + "learned_if_names": "swp06",
1712 + }
1713 + reporterHints := map[string][]bridgePortRef{
1714 + "ghost-switch": {
1715 + {deviceID: "ghost-switch", ifIndex: 900, ifName: "ghost0"},
1716 + },
1717 + "managed-switch": {
1718 + {deviceID: "managed-switch", ifIndex: 7, ifName: "swp06"},
1719 + },
1720 + }
1721 + owner := fdbEndpointOwner{
1722 + port: bridgePortRef{
1723 + deviceID: "ghost-switch",
1724 + ifIndex: 900,
1725 + ifName: "ghost0",
1726 + },
1727 + source: "single_port_mac",
1728 + }
1729 +
1730 + hint := selectProbableEndpointReporterHint(
1731 + endpointLabels,
1732 + reporterHints,
1733 + owner,
1734 + nil,
1735 + map[string]struct{}{"managed-switch": {}},
1736 + )
1737 +
1738 + require.Equal(t, "managed-switch", hint.deviceID)
1739 + require.Equal(t, 7, hint.ifIndex)
1740 + require.Equal(t, "swp06", hint.ifName)
1741 +}
1742 +
1743 +func TestProbableCandidateSegmentsFromReporterHints_PrefersManagedReporterSegments(t *testing.T) {
1744 + index := segmentReporterIndex{
1745 + byDevice: map[string]map[string]struct{}{
1746 + "ghost-switch": {"segment:ghost": {}},
1747 + "managed-switch": {"segment:managed": {}},
1748 + },
1749 + byDeviceIfIndex: map[string]map[string]struct{}{
1750 + "ghost-switch\x007": {"segment:ghost": {}},
1751 + "managed-switch\x007": {"segment:managed": {}},
1752 + },
1753 + byDeviceIfName: map[string]map[string]struct{}{
1754 + "ghost-switch\x00swp06": {"segment:ghost": {}},
1755 + "managed-switch\x00swp06": {"segment:managed": {}},
1756 + },
1757 + }
1758 +
1759 + segments := probableCandidateSegmentsFromReporterHints(
1760 + map[string]string{
1761 + "learned_device_ids": "ghost-switch,managed-switch",
1762 + "learned_if_indexes": "7",
1763 + "learned_if_names": "swp06",
1764 + },
1765 + nil,
1766 + index,
1767 + nil,
1768 + map[string]struct{}{"managed-switch": {}},
1769 + )
1770 +
1771 + require.Equal(t, []string{"segment:managed"}, segments)
1772 +}
1773 +
1774 +func TestEnsureManagedProbableReporterHint_UpgradesUnmanagedHint(t *testing.T) {
1775 + hint := probableEndpointReporterHint{
1776 + deviceID: "ghost-switch",
1777 + }
1778 + endpointLabels := map[string]string{
1779 + "learned_device_ids": "macAddress:18:fd:74:7e:c5:80",
1780 + "learned_if_indexes": "7",
1781 + "learned_if_names": "swp06",
1782 + }
1783 + aliasOwnerIDs := map[string]map[string]struct{}{
1784 + "mac:18:fd:74:7e:c5:80": {
1785 + "managed-switch": {},
1786 + },
1787 + }
1788 +
1789 + updated := ensureManagedProbableReporterHint(
1790 + hint,
1791 + endpointLabels,
1792 + nil,
1793 + aliasOwnerIDs,
1794 + map[string]struct{}{"managed-switch": {}},
1795 + []string{"managed-switch"},
1796 + )
1797 +
1798 + require.Equal(t, "managed-switch", updated.deviceID)
1799 + require.Equal(t, 7, updated.ifIndex)
1800 + require.Equal(t, "swp06", updated.ifName)
1801 +}
1802 +
1803 +func TestEnsureManagedProbableReporterHint_FallsBackToFirstManagedDevice(t *testing.T) {
1804 + updated := ensureManagedProbableReporterHint(
1805 + probableEndpointReporterHint{deviceID: "ghost-switch"},
1806 + nil,
1807 + nil,
1808 + nil,
1809 + map[string]struct{}{"managed-a": {}, "managed-b": {}},
1810 + []string{"managed-a", "managed-b"},
1811 + )
1812 +
1813 + require.Equal(t, "managed-a", updated.deviceID)
1814 + require.Equal(t, 0, updated.ifIndex)
1815 + require.Equal(t, "0", updated.ifName)
1816 +}
1817 +
1818 +func TestToTopologyData_ProbableConnectivityRecoversUnmanagedOverlapSuppression(t *testing.T) {
1819 + result := Result{
1820 + Devices: []Device{
1821 + {
1822 + ID: "switch-a",
1823 + Hostname: "switch-a",
1824 + ChassisID: "aa:aa:aa:aa:aa:aa",
1825 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1826 + },
1827 + },
1828 + Interfaces: []Interface{
1829 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1830 + {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1831 + },
1832 + Adjacencies: []Adjacency{
1833 + {
1834 + Protocol: "lldp",
1835 + SourceID: "switch-a",
1836 + SourcePort: "Gi0/1",
1837 + TargetID: "remote-peer",
1838 + TargetPort: "cc:cc:cc:cc:cc:cc",
1839 + },
1840 + },
1841 + Attachments: []Attachment{
1842 + {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:cc:cc:cc:cc:cc:cc", Method: "fdb"},
1843 + },
1844 + }
1845 +
1846 + strictData := ToTopologyData(result, TopologyDataOptions{
1847 + Source: "snmp",
1848 + Layer: "2",
1849 + View: "summary",
1850 + })
1851 + require.Len(t, findFDBLinksByEndpointMAC(strictData.Links, "cc:cc:cc:cc:cc:cc"), 0)
1852 +
1853 + data := ToTopologyData(result, TopologyDataOptions{
1854 + Source: "snmp",
1855 + Layer: "2",
1856 + View: "summary",
1857 + ProbabilisticConnectivity: true,
1858 + })
1859 +
1860 + fdbLinks := findFDBLinksByEndpointMAC(data.Links, "cc:cc:cc:cc:cc:cc")
1861 + require.Len(t, fdbLinks, 1)
1862 + require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1863 + require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(topologyMetricString(fdbLinks[0].Metrics, "inference"))))
1864 + require.Equal(t, "probable_direct", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1865 + require.Equal(t, "low", topologyMetricString(fdbLinks[0].Metrics, "confidence"))
1866 +}
1867 +
1868 +func TestToTopologyData_CollapseByIPPrunesSuppressedManagedOverlapEndpoint(t *testing.T) {
1869 + result := Result{
1870 + Devices: []Device{
1871 + {
1872 + ID: "switch-a",
1873 + Hostname: "switch-a",
1874 + ChassisID: "aa:aa:aa:aa:aa:aa",
1875 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1876 + },
1877 + {
1878 + ID: "nova",
1879 + Hostname: "nova",
1880 + ChassisID: "9c:6b:00:7b:98:c6",
1881 + Addresses: []netip.Addr{netip.MustParseAddr("172.22.0.1")},
1882 + Labels: map[string]string{"inferred": "true"},
1883 + },
1884 + },
1885 + Interfaces: []Interface{
1886 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1887 + {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1888 + },
1889 + Adjacencies: []Adjacency{
1890 + {
1891 + Protocol: "lldp",
1892 + SourceID: "switch-a",
1893 + SourcePort: "Gi0/1",
1894 + TargetID: "nova",
1895 + TargetPort: "9c:6b:00:7b:98:c7",
1896 + },
1897 + },
1898 + Attachments: []Attachment{
1899 + {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:9c:6b:00:7b:98:c7", Method: "fdb"},
1900 + },
1901 + Enrichments: []Enrichment{
1902 + {
1903 + EndpointID: "mac:9c:6b:00:7b:98:c7",
1904 + MAC: "9c:6b:00:7b:98:c7",
1905 + IPs: []netip.Addr{netip.MustParseAddr("10.20.4.22")},
1906 + Labels: map[string]string{
1907 + "sources": "arp",
1908 + },
1909 + },
1910 + },
1911 + }
1912 +
1913 + withoutCollapse := ToTopologyData(result, TopologyDataOptions{
1914 + Source: "snmp",
1915 + Layer: "2",
1916 + View: "summary",
1917 + })
1918 + require.NotNil(t, findActorByMAC(withoutCollapse.Actors, "9c:6b:00:7b:98:c7"))
1919 +
1920 + withCollapse := ToTopologyData(result, TopologyDataOptions{
1921 + Source: "snmp",
1922 + Layer: "2",
1923 + View: "summary",
1924 + CollapseActorsByIP: true,
1925 + })
1926 + require.NotNil(t, findActorByMAC(withCollapse.Actors, "9c:6b:00:7b:98:c6"))
1927 + require.Nil(t, findActorByMAC(withCollapse.Actors, "9c:6b:00:7b:98:c7"))
1928 + require.Equal(t, 1, withCollapse.Stats["actors_unlinked_suppressed"])
1929 +}
1930 +
1931 +func TestToTopologyData_ReplacesKnownDeviceEndpointWithManagedDeviceEdge(t *testing.T) {
1932 + result := Result{
1933 + Devices: []Device{
1934 + {
1935 + ID: "router-a",
1936 + Hostname: "router-a",
1937 + ChassisID: "aa:aa:aa:aa:aa:aa",
1938 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1939 + },
1940 + {
1941 + ID: "switch-b",
1942 + Hostname: "switch-b",
1943 + ChassisID: "bb:bb:bb:bb:bb:bb",
1944 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1945 + },
1946 + },
1947 + Interfaces: []Interface{
1948 + {DeviceID: "router-a", IfIndex: 1, IfName: "ether1"},
1949 + },
1950 + Attachments: []Attachment{
1951 + {DeviceID: "router-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
1952 + },
1953 + }
1954 +
1955 + data := ToTopologyData(result, TopologyDataOptions{
1956 + Source: "snmp",
1957 + Layer: "2",
1958 + View: "summary",
1959 + })
1960 +
1961 + fdbLinks := findFDBLinksByDstSysName(data.Links, "switch-b")
1962 + require.Len(t, fdbLinks, 1)
1963 + require.Equal(t, "managed_device_overlap", fdbLinks[0].Metrics["attachment_mode"])
1964 + require.Equal(t, 1, data.Stats["links_fdb_endpoint_emitted"])
1965 + require.Equal(t, 0, data.Stats["links_fdb_endpoint_suppressed"])
1966 +
1967 + for _, actor := range data.Actors {
1968 + if actor.ActorType != "endpoint" {
1969 + continue
1970 + }
1971 + for _, mac := range actor.Match.MacAddresses {
1972 + require.NotEqual(t, "bb:bb:bb:bb:bb:bb", mac)
1973 + }
1974 + }
1975 +}
1976 +
1977 +func TestToTopologyData_KnownDeviceOverlapUsesInterfaceMACAlias(t *testing.T) {
1978 + result := Result{
1979 + Devices: []Device{
1980 + {
1981 + ID: "router-a",
1982 + Hostname: "router-a",
1983 + ChassisID: "aa:aa:aa:aa:aa:80",
1984 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1985 + },
1986 + {
1987 + ID: "switch-b",
1988 + Hostname: "switch-b",
1989 + ChassisID: "bb:bb:bb:bb:bb:bb",
1990 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1991 + },
1992 + },
1993 + Interfaces: []Interface{
1994 + {DeviceID: "router-a", IfIndex: 1, IfName: "ether1", MAC: "aa:aa:aa:aa:aa:8c"},
1995 + {DeviceID: "switch-b", IfIndex: 1, IfName: "ether1"},
1996 + },
1997 + Attachments: []Attachment{
1998 + {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:8c", Method: "fdb"},
1999 + },
2000 + }
2001 +
2002 + data := ToTopologyData(result, TopologyDataOptions{
2003 + Source: "snmp",
2004 + Layer: "2",
2005 + View: "summary",
2006 + })
2007 +
2008 + fdbLinks := findFDBLinksByDstSysName(data.Links, "router-a")
2009 + require.Len(t, fdbLinks, 1)
2010 + require.Equal(t, "managed_device_overlap", fdbLinks[0].Metrics["attachment_mode"])
2011 + require.Equal(t, 1, data.Stats["links_fdb_endpoint_emitted"])
2012 + require.Equal(t, 0, data.Stats["links_fdb_endpoint_suppressed"])
2013 +}
2014 +
2015 +func TestToTopologyData_DeviceActorIncludesInterfaceMACAliases(t *testing.T) {
2016 + result := Result{
2017 + Devices: []Device{
2018 + {
2019 + ID: "switch-a",
2020 + Hostname: "switch-a",
2021 + ChassisID: "aa:aa:aa:aa:aa:01",
2022 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2023 + },
2024 + },
2025 + Interfaces: []Interface{
2026 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", MAC: "aa:aa:aa:aa:aa:11"},
2027 + {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2", MAC: "aa:aa:aa:aa:aa:12"},
2028 + {DeviceID: "switch-a", IfIndex: 3, IfName: "Gi0/3", MAC: "AA-AA-AA-AA-AA-12"},
2029 + },
2030 + }
2031 +
2032 + data := ToTopologyData(result, TopologyDataOptions{
2033 + Source: "snmp",
2034 + Layer: "2",
2035 + View: "summary",
2036 + })
2037 +
2038 + actor := findActorBySysName(data.Actors, "switch-a")
2039 + require.NotNil(t, actor)
2040 + require.ElementsMatch(
2041 + t,
2042 + []string{"aa:aa:aa:aa:aa:01", "aa:aa:aa:aa:aa:11", "aa:aa:aa:aa:aa:12"},
2043 + actor.Match.MacAddresses,
2044 + )
2045 +}
2046 +
2047 +func TestToTopologyData_KeepsUnlinkedEndpointsAndDevices(t *testing.T) {
2048 + result := Result{
2049 + Devices: []Device{
2050 + {
2051 + ID: "device-a",
2052 + Hostname: "device-a",
2053 + ChassisID: "aa:aa:aa:aa:aa:aa",
2054 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2055 + },
2056 + },
2057 + Enrichments: []Enrichment{
2058 + {
2059 + EndpointID: "ip:10.0.0.42",
2060 + IPs: []netip.Addr{netip.MustParseAddr("10.0.0.42")},
2061 + },
2062 + },
2063 + }
2064 +
2065 + data := ToTopologyData(result, TopologyDataOptions{
2066 + Source: "snmp",
2067 + Layer: "2",
2068 + View: "summary",
2069 + })
2070 +
2071 + require.Len(t, data.Actors, 2)
2072 + deviceCount := 0
2073 + endpointCount := 0
2074 + for _, actor := range data.Actors {
2075 + switch actor.ActorType {
2076 + case "device":
2077 + deviceCount++
2078 + case "endpoint":
2079 + endpointCount++
2080 + }
2081 + }
2082 + require.Equal(t, 1, deviceCount)
2083 + require.Equal(t, 1, endpointCount)
2084 + require.Equal(t, 0, data.Stats["links_total"])
2085 + require.Equal(t, 0, data.Stats["actors_unlinked_suppressed"])
2086 +}
2087 +
2088 +func TestToTopologyData_KeepsUnlinkedEndpointWhenIdentityOverlapsLinkedDevice(t *testing.T) {
2089 + result := Result{
2090 + Devices: []Device{
2091 + {
2092 + ID: "switch-a",
2093 + Hostname: "switch-a",
2094 + ChassisID: "aa:aa:aa:aa:aa:aa",
2095 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2096 + },
2097 + {
2098 + ID: "mega",
2099 + Hostname: "mega",
2100 + ChassisID: "bb:bb:bb:bb:bb:bb",
2101 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
2102 + },
2103 + },
2104 + Interfaces: []Interface{
2105 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1"},
2106 + {DeviceID: "mega", IfIndex: 1, IfName: "eth0"},
2107 + },
2108 + Adjacencies: []Adjacency{
2109 + {
2110 + Protocol: "lldp",
2111 + SourceID: "switch-a",
2112 + SourcePort: "Gi0/1",
2113 + TargetID: "mega",
2114 + TargetPort: "eth0",
2115 + },
2116 + },
2117 + Enrichments: []Enrichment{
2118 + {
2119 + EndpointID: "mac:cc:cc:cc:cc:cc:cc",
2120 + IPs: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
2121 + },
2122 + },
2123 + }
2124 +
2125 + data := ToTopologyData(result, TopologyDataOptions{
2126 + Source: "snmp",
2127 + Layer: "2",
2128 + View: "summary",
2129 + })
2130 +
2131 + require.NotNil(t, findActorByMAC(data.Actors, "cc:cc:cc:cc:cc:cc"))
2132 + require.Equal(t, 3, data.Stats["actors_total"])
2133 + require.Equal(t, 1, data.Stats["links_total"])
2134 + require.Equal(t, 0, data.Stats["actors_unlinked_suppressed"])
2135 +}
2136 +
2137 +func TestToTopologyData_DisplayNamesPreferDNSThenIPThenMAC(t *testing.T) {
2138 + result := Result{
2139 + Devices: []Device{
2140 + {
2141 + ID: "switch-a",
2142 + Hostname: "switch-a",
2143 + ChassisID: "aa:aa:aa:aa:aa:aa",
2144 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2145 + },
2146 + },
2147 + Interfaces: []Interface{
2148 + {DeviceID: "switch-a", IfIndex: 3, IfName: "Gi0/3", IfDescr: "Gi0/3"},
2149 + },
2150 + Attachments: []Attachment{
2151 + {DeviceID: "switch-a", IfIndex: 3, EndpointID: "ip:10.0.0.42", Method: "arp"},
2152 + {DeviceID: "switch-a", IfIndex: 3, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
2153 + },
2154 + }
2155 +
2156 + data := ToTopologyData(result, TopologyDataOptions{
2157 + Source: "snmp",
2158 + Layer: "2",
2159 + View: "summary",
2160 + ResolveDNSName: func(ip string) string {
2161 + switch ip {
2162 + case "10.0.0.1":
2163 + return "switch-a.example.net."
2164 + default:
2165 + return ""
2166 + }
2167 + },
2168 + })
2169 +
2170 + device := findActorBySysName(data.Actors, "switch-a")
2171 + require.NotNil(t, device)
2172 + require.Equal(t, "switch-a.example.net", device.Labels["display_name"])
2173 + require.Equal(t, "dns", device.Labels["display_source"])
2174 + require.Equal(t, "switch-a.example.net", device.Attributes["display_name"])
2175 +
2176 + ipEndpoint := findActorByIP(data.Actors, "10.0.0.42")
2177 + require.NotNil(t, ipEndpoint)
2178 + require.Equal(t, "10.0.0.42", ipEndpoint.Labels["display_name"])
2179 + require.Equal(t, "ip", ipEndpoint.Labels["display_source"])
2180 +
2181 + macEndpoint := findActorByMAC(data.Actors, "70:49:a2:65:72:cd")
2182 + require.NotNil(t, macEndpoint)
2183 + require.Equal(t, "70:49:a2:65:72:cd", macEndpoint.Labels["display_name"])
2184 + require.Equal(t, "mac", macEndpoint.Labels["display_source"])
2185 +
2186 + require.NotEmpty(t, data.Links)
2187 + for _, link := range data.Links {
2188 + require.NotNil(t, link.Src.Attributes)
2189 + require.NotNil(t, link.Dst.Attributes)
2190 + require.NotEmpty(t, link.Src.Attributes["display_name"])
2191 + require.NotEmpty(t, link.Dst.Attributes["display_name"])
2192 + }
2193 +}
2194 +
2195 +func TestTopologyDisplayNameFromMatch_PrefersSysNameBeforeIP(t *testing.T) {
2196 + display := topologyDisplayNameFromMatch(topology.Match{
2197 + SysName: "MikroTik-router",
2198 + IPAddresses: []string{"10.20.4.1"},
2199 + }, &topologyDisplayNameResolver{
2200 + lookup: func(string) string { return "" },
2201 + cache: map[string]string{},
2202 + })
2203 +
2204 + require.Equal(t, "MikroTik-router", display.name)
2205 + require.Equal(t, "sys_name", display.source)
2206 +}
2207 +
2208 +func TestTopologyDisplayNameFromMatch_PrefersHostnameBeforeIPWhenSysNameMissing(t *testing.T) {
2209 + display := topologyDisplayNameFromMatch(topology.Match{
2210 + Hostnames: []string{"nova"},
2211 + IPAddresses: []string{"10.20.4.22"},
2212 + }, &topologyDisplayNameResolver{
2213 + lookup: func(string) string { return "" },
2214 + cache: map[string]string{},
2215 + })
2216 +
2217 + require.Equal(t, "nova", display.name)
2218 + require.Equal(t, "hostname", display.source)
2219 +}
2220 +
2221 +func TestToTopologyData_SegmentDisplayNameUsesParentPortPattern(t *testing.T) {
2222 + result := Result{
2223 + Devices: []Device{
2224 + {
2225 + ID: "switch-a",
2226 + Hostname: "switch-a",
2227 + ChassisID: "aa:aa:aa:aa:aa:aa",
2228 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2229 + },
2230 + },
2231 + Interfaces: []Interface{
2232 + {DeviceID: "switch-a", IfIndex: 3, IfName: "Gi0/3", IfDescr: "Gi0/3"},
2233 + },
2234 + Attachments: []Attachment{
2235 + {DeviceID: "switch-a", IfIndex: 3, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
2236 + {DeviceID: "switch-a", IfIndex: 3, EndpointID: "mac:70:49:a2:65:72:ce", Method: "fdb"},
2237 + },
2238 + }
2239 +
2240 + data := ToTopologyData(result, TopologyDataOptions{
2241 + Source: "snmp",
2242 + Layer: "2",
2243 + View: "summary",
2244 + ResolveDNSName: func(ip string) string {
2245 + if ip == "10.0.0.1" {
2246 + return "switch-a.example.net."
2247 + }
2248 + return ""
2249 + },
2250 + })
2251 +
2252 + segment := findActorByType(data.Actors, "segment")
2253 + require.NotNil(t, segment)
2254 + require.Equal(t, "switch-a.example.net.gi0/3.segment", segment.Labels["display_name"])
2255 + require.Equal(t, "segment", segment.Labels["display_source"])
2256 + require.Equal(t, "switch-a.example.net.gi0/3.segment", segment.Attributes["display_name"])
2257 +}
2258 +
2259 +func TestToTopologyData_FDBOwnerInferencePrefersNonLLDPSide(t *testing.T) {
2260 + result := Result{
2261 + Devices: []Device{
2262 + {
2263 + ID: "switch-a",
2264 + Hostname: "switch-a",
2265 + ChassisID: "aa:aa:aa:aa:aa:aa",
2266 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2267 + },
2268 + {
2269 + ID: "switch-b",
2270 + Hostname: "switch-b",
2271 + ChassisID: "bb:bb:bb:bb:bb:bb",
2272 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
2273 + },
2274 + },
2275 + Interfaces: []Interface{
2276 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
2277 + {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1", MAC: "bb:bb:bb:bb:bb:bb"},
2278 + {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", MAC: "bb:bb:bb:bb:bb:bc"},
2279 + },
2280 + Adjacencies: []Adjacency{
2281 + {
2282 + Protocol: "lldp",
2283 + SourceID: "switch-a",
2284 + SourcePort: "Gi0/1",
2285 + TargetID: "switch-b",
2286 + TargetPort: "Gi0/1",
2287 + },
2288 + },
2289 + Attachments: []Attachment{
2290 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2291 + {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2292 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
2293 + {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
2294 + {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:ee:ee:ee:ee:ee:ee", Method: "fdb"},
2295 + },
2296 + }
2297 +
2298 + data := ToTopologyData(result, TopologyDataOptions{
2299 + Source: "snmp",
2300 + Layer: "2",
2301 + View: "summary",
2302 + })
2303 +
2304 + targetLinks := findFDBLinksByEndpointMAC(data.Links, "70:49:a2:65:72:cd")
2305 + require.Len(t, targetLinks, 1)
2306 +
2307 + segmentActor := findActorByMatch(data.Actors, targetLinks[0].Src.Match)
2308 + require.NotNil(t, segmentActor)
2309 + require.Equal(t, []string{"switch-b"}, segmentActor.Attributes["parent_devices"])
2310 + require.Equal(t, []string{"Gi0/2"}, segmentActor.Attributes["if_names"])
2311 +}
2312 +
2313 +func TestToTopologyData_FDBOwnerInferenceUsesSingleMACPortRule(t *testing.T) {
2314 + result := Result{
2315 + Devices: []Device{
2316 + {
2317 + ID: "switch-a",
2318 + Hostname: "switch-a",
2319 + },
2320 + {
2321 + ID: "switch-b",
2322 + Hostname: "switch-b",
2323 + },
2324 + },
2325 + Interfaces: []Interface{
2326 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1"},
2327 + {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2"},
2328 + {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1"},
2329 + },
2330 + Attachments: []Attachment{
2331 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2332 + {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2333 + {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2334 + {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2335 + },
2336 + }
2337 +
2338 + data := ToTopologyData(result, TopologyDataOptions{
2339 + Source: "snmp",
2340 + Layer: "2",
2341 + View: "summary",
2342 + })
2343 +
2344 + targetLinks := findFDBLinksByEndpointMAC(data.Links, "dd:dd:dd:dd:dd:dd")
2345 + require.Len(t, targetLinks, 1)
2346 +
2347 + srcActor := findActorByMatch(data.Actors, targetLinks[0].Src.Match)
2348 + require.NotNil(t, srcActor)
2349 + require.Equal(t, "device", srcActor.ActorType)
2350 + require.Equal(t, "switch-a", srcActor.Match.SysName)
2351 +
2352 + endpointActor := findActorByMAC(data.Actors, "dd:dd:dd:dd:dd:dd")
2353 + require.NotNil(t, endpointActor)
2354 + require.Equal(t, "endpoint", endpointActor.ActorType)
2355 + require.Equal(t, "single_port_mac", endpointActor.Attributes["attachment_source"])
2356 + require.Equal(t, "switch-a", endpointActor.Attributes["attached_device"])
2357 + require.Equal(t, "Gi0/1", endpointActor.Attributes["attached_port"])
2358 + require.Equal(t, "single_port_mac", endpointActor.Labels["attached_by"])
2359 +}
2360 +
2361 +func TestToTopologyData_FDBOwnerInferenceSuppressesManagedAliasSwitchFacingPorts(t *testing.T) {
2362 + result := Result{
2363 + Devices: []Device{
2364 + {
2365 + ID: "switch-a",
2366 + Hostname: "switch-a",
2367 + ChassisID: "aa:aa:aa:aa:aa:aa",
2368 + },
2369 + {
2370 + ID: "switch-b",
2371 + Hostname: "switch-b",
2372 + ChassisID: "bb:bb:bb:bb:bb:bb",
2373 + },
2374 + },
2375 + Interfaces: []Interface{
2376 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1"},
2377 + {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2"},
2378 + {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1"},
2379 + },
2380 + Attachments: []Attachment{
2381 + // Managed-device aliases learned on the same port mark it as switch-facing.
2382 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2383 + {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2384 +
2385 + // Candidate endpoint appears behind the managed-link port and must be suppressed.
2386 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2387 + {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2388 +
2389 + // Control endpoint on non-switch-facing port remains directly attachable.
2390 + {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:ee:ee:ee:ee:ee:ee", Method: "fdb"},
2391 + },
2392 + }
2393 +
2394 + data := ToTopologyData(result, TopologyDataOptions{
2395 + Source: "snmp",
2396 + Layer: "2",
2397 + View: "summary",
2398 + })
2399 +
2400 + ddLinks := findFDBLinksByEndpointMAC(data.Links, "dd:dd:dd:dd:dd:dd")
2401 + require.Len(t, ddLinks, 0)
2402 + ddActor := findActorByMAC(data.Actors, "dd:dd:dd:dd:dd:dd")
2403 + require.NotNil(t, ddActor)
2404 + _, hasAttachmentSource := ddActor.Attributes["attachment_source"]
2405 + require.False(t, hasAttachmentSource)
2406 + _, hasAttachedDevice := ddActor.Attributes["attached_device"]
2407 + require.False(t, hasAttachedDevice)
2408 +
2409 + eeLinks := findFDBLinksByEndpointMAC(data.Links, "ee:ee:ee:ee:ee:ee")
2410 + require.Len(t, eeLinks, 1)
2411 + eeSrc := findActorByMatch(data.Actors, eeLinks[0].Src.Match)
2412 + require.NotNil(t, eeSrc)
2413 + require.Equal(t, "device", eeSrc.ActorType)
2414 + require.Equal(t, "switch-a", eeSrc.Match.SysName)
2415 +}
2416 +
2417 +func TestToTopologyData_SuppressesFDBEndpointsOnLLDPPorts(t *testing.T) {
2418 + result := Result{
2419 + Devices: []Device{
2420 + {
2421 + ID: "switch-a",
2422 + Hostname: "switch-a",
2423 + ChassisID: "aa:aa:aa:aa:aa:aa",
2424 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2425 + },
2426 + {
2427 + ID: "host-b",
2428 + Hostname: "host-b",
2429 + ChassisID: "bb:bb:bb:bb:bb:bb",
2430 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
2431 + },
2432 + },
2433 + Interfaces: []Interface{
2434 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
2435 + {DeviceID: "host-b", IfIndex: 1, IfName: "eth0", MAC: "bb:bb:bb:bb:bb:bb"},
2436 + },
2437 + Adjacencies: []Adjacency{
2438 + {
2439 + Protocol: "lldp",
2440 + SourceID: "switch-a",
2441 + SourcePort: "Gi0/1",
2442 + TargetID: "host-b",
2443 + TargetPort: "eth0",
2444 + },
2445 + },
2446 + Attachments: []Attachment{
2447 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2448 + {DeviceID: "host-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2449 + },
2450 + }
2451 +
2452 + data := ToTopologyData(result, TopologyDataOptions{
2453 + Source: "snmp",
2454 + Layer: "2",
2455 + View: "summary",
2456 + })
2457 +
2458 + bridgeLinks := 0
2459 + fdbLinks := 0
2460 + segmentActors := 0
2461 + for _, actor := range data.Actors {
2462 + if actor.ActorType == "segment" {
2463 + segmentActors++
2464 + }
2465 + }
2466 + for _, link := range data.Links {
2467 + switch link.Protocol {
2468 + case "bridge":
2469 + bridgeLinks++
2470 + case "fdb":
2471 + fdbLinks++
2472 + }
2473 + }
2474 +
2475 + require.Equal(t, 0, segmentActors)
2476 + require.Equal(t, 0, bridgeLinks)
2477 + require.Equal(t, 0, fdbLinks)
2478 + require.Equal(t, 1, data.Stats["links_total"])
2479 + require.Equal(t, 0, data.Stats["links_fdb"])
2480 + require.Equal(t, 0, data.Stats["links_fdb_endpoint_candidates"])
2481 + require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
2482 + require.Equal(t, 0, data.Stats["links_fdb_endpoint_suppressed"])
2483 +}
2484 +
2485 +func TestToTopologyData_KeepsChassisPlaceholderDevicesAsDevices(t *testing.T) {
2486 + result := Result{
2487 + Devices: []Device{
2488 + {
2489 + ID: "switch-a",
2490 + Hostname: "switch-a",
2491 + ChassisID: "aa:aa:aa:aa:aa:aa",
2492 + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2493 + },
2494 + {
2495 + ID: "chassis-788cb595dfcc",
2496 + Hostname: "chassis-788cb595dfcc",
2497 + ChassisID: "78:8c:b5:95:df:cc",
2498 + },
2499 + },
2500 + Adjacencies: []Adjacency{
2501 + {
2502 + Protocol: "lldp",
2503 + SourceID: "switch-a",
2504 + SourcePort: "Gi0/1",
2505 + TargetID: "chassis-788cb595dfcc",
2506 + TargetPort: "eth0",
2507 + },
2508 + },
2509 + }
2510 +
2511 + data := ToTopologyData(result, TopologyDataOptions{
2512 + Source: "snmp",
2513 + Layer: "2",
2514 + View: "summary",
2515 + })
2516 +
2517 + placeholder := findActorBySysName(data.Actors, "chassis-788cb595dfcc")
2518 + require.NotNil(t, placeholder)
2519 + require.Equal(t, "device", placeholder.ActorType)
2520 +}
2521 +
2522 +func TestPruneSegmentArtifacts_SuppressesLLDPDuplicateSegmentPath(t *testing.T) {
2523 + actors := []topology.Actor{
2524 + {
2525 + ActorType: "device",
2526 + Match: topology.Match{IPAddresses: []string{"10.0.0.1"}, SysName: "switch-a"},
2527 + },
2528 + {
2529 + ActorType: "device",
2530 + Match: topology.Match{IPAddresses: []string{"10.0.0.2"}, SysName: "switch-b"},
2531 + },
2532 + {
2533 + ActorType: "segment",
2534 + Match: topology.Match{Hostnames: []string{"segment:dup"}},
2535 + },
2536 + }
2537 +
2538 + links := []topology.Link{
2539 + {
2540 + Protocol: "lldp",
2541 + Src: topology.LinkEndpoint{Match: topology.Match{IPAddresses: []string{"10.0.0.1"}}},
2542 + Dst: topology.LinkEndpoint{Match: topology.Match{IPAddresses: []string{"10.0.0.2"}}},
2543 + },
2544 + {
2545 + Protocol: "bridge",
2546 + Src: topology.LinkEndpoint{Match: topology.Match{IPAddresses: []string{"10.0.0.1"}}},
2547 + Dst: topology.LinkEndpoint{Match: topology.Match{Hostnames: []string{"segment:dup"}}},
2548 + },
2549 + {
2550 + Protocol: "bridge",
2551 + Src: topology.LinkEndpoint{Match: topology.Match{Hostnames: []string{"segment:dup"}}},
2552 + Dst: topology.LinkEndpoint{Match: topology.Match{IPAddresses: []string{"10.0.0.2"}}},
2553 + },
2554 + }
2555 +
2556 + filteredActors, filteredLinks, suppressed := pruneSegmentArtifacts(actors, links)
2557 + require.Equal(t, 1, suppressed)
2558 + require.Len(t, filteredActors, 2)
2559 + require.Len(t, filteredLinks, 1)
2560 + require.Equal(t, "lldp", filteredLinks[0].Protocol)
2561 +}
2562 +
2563 +func TestPruneSegmentArtifacts_SuppressesCDPDuplicateSegmentPath(t *testing.T) {
2564 + actors := []topology.Actor{
2565 + {
2566 + ActorType: "device",
2567 + Match: topology.Match{IPAddresses: []string{"10.0.1.1"}, SysName: "switch-a"},
2568 + },
2569 + {
2570 + ActorType: "device",
2571 + Match: topology.Match{IPAddresses: []string{"10.0.1.2"}, SysName: "switch-b"},
2572 + },
2573 + {
2574 + ActorType: "segment",
2575 + Match: topology.Match{Hostnames: []string{"segment:dup-cdp"}},
2576 + },
2577 + }
2578 +
2579 + links := []topology.Link{
2580 + {
2581 + Protocol: "cdp",
2582 + Src: topology.LinkEndpoint{Match: topology.Match{IPAddresses: []string{"10.0.1.1"}}},
2583 + Dst: topology.LinkEndpoint{Match: topology.Match{IPAddresses: []string{"10.0.1.2"}}},
2584 + },
2585 + {
2586 + Protocol: "bridge",
2587 + Src: topology.LinkEndpoint{Match: topology.Match{IPAddresses: []string{"10.0.1.1"}}},
2588 + Dst: topology.LinkEndpoint{Match: topology.Match{Hostnames: []string{"segment:dup-cdp"}}},
2589 + },
2590 + {
2591 + Protocol: "bridge",
2592 + Src: topology.LinkEndpoint{Match: topology.Match{Hostnames: []string{"segment:dup-cdp"}}},
2593 + Dst: topology.LinkEndpoint{Match: topology.Match{IPAddresses: []string{"10.0.1.2"}}},
2594 + },
2595 + }
2596 +
2597 + filteredActors, filteredLinks, suppressed := pruneSegmentArtifacts(actors, links)
2598 + require.Equal(t, 1, suppressed)
2599 + require.Len(t, filteredActors, 2)
2600 + require.Len(t, filteredLinks, 1)
2601 + require.Equal(t, "cdp", filteredLinks[0].Protocol)
2602 +}
2603 +
2604 +func TestPruneSegmentArtifacts_SuppressesSegmentsWithSingleNeighbor(t *testing.T) {
2605 + actors := []topology.Actor{
2606 + {
2607 + ActorType: "device",
2608 + Match: topology.Match{IPAddresses: []string{"10.0.0.1"}, SysName: "router-a"},
2609 + },
2610 + {
2611 + ActorType: "segment",
2612 + Match: topology.Match{Hostnames: []string{"segment:orphan"}},
2613 + },
2614 + }
2615 +
2616 + links := []topology.Link{
2617 + {
2618 + Protocol: "bridge",
2619 + Src: topology.LinkEndpoint{Match: topology.Match{IPAddresses: []string{"10.0.0.1"}}},
2620 + Dst: topology.LinkEndpoint{Match: topology.Match{Hostnames: []string{"segment:orphan"}}},
2621 + },
2622 + }
2623 +
2624 + filteredActors, filteredLinks, suppressed := pruneSegmentArtifacts(actors, links)
2625 + require.Equal(t, 1, suppressed)
2626 + require.Len(t, filteredActors, 1)
2627 + require.Len(t, filteredLinks, 0)
2628 +}
2629 +
2630 +func TestToTopologyData_DeterministicTransitRuleSuppressesFDBOnLLDPPortInExperimental(t *testing.T) {
2631 + result := Result{
2632 + Devices: []Device{
2633 + {
2634 + ID: "switch-a",
2635 + Hostname: "switch-a",
2636 + ChassisID: "aa:aa:aa:aa:aa:aa",
2637 + },
2638 + {
2639 + ID: "switch-b",
2640 + Hostname: "switch-b",
2641 + ChassisID: "bb:bb:bb:bb:bb:bb",
2642 + },
2643 + },
2644 + Interfaces: []Interface{
2645 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
2646 + {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
2647 + },
2648 + Adjacencies: []Adjacency{
2649 + {
2650 + Protocol: "lldp",
2651 + SourceID: "switch-a",
2652 + SourcePort: "Gi0/1",
2653 + TargetID: "switch-b",
2654 + TargetPort: "Gi0/2",
2655 + },
2656 + },
2657 + Attachments: []Attachment{
2658 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:00:00:00:00:00:11", Method: "fdb"},
2659 + },
2660 + }
2661 +
2662 + data := ToTopologyData(result, TopologyDataOptions{
2663 + Source: "snmp",
2664 + Layer: "2",
2665 + View: "summary",
2666 + InferenceStrategy: topologyInferenceStrategyFDBMinimumKnowledge,
2667 + })
2668 +
2669 + require.Equal(t, topologyInferenceStrategyFDBMinimumKnowledge, data.Stats["inference_strategy"])
2670 + require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
2671 + require.Nil(t, findActorByType(data.Actors, "segment"))
2672 +}
2673 +
2674 +func TestToTopologyData_DeterministicTransitRuleMatchesNumericLLDPPortToIfIndex(t *testing.T) {
2675 + result := Result{
2676 + Devices: []Device{
2677 + {
2678 + ID: "switch-a",
2679 + Hostname: "switch-a",
2680 + ChassisID: "aa:aa:aa:aa:aa:aa",
2681 + },
2682 + {
2683 + ID: "switch-b",
2684 + Hostname: "switch-b",
2685 + ChassisID: "bb:bb:bb:bb:bb:bb",
2686 + },
2687 + },
2688 + Interfaces: []Interface{
2689 + {DeviceID: "switch-a", IfIndex: 2, IfName: "GigabitEthernet2", IfDescr: "GigabitEthernet2"},
2690 + {DeviceID: "switch-b", IfIndex: 4, IfName: "ether4", IfDescr: "ether4"},
2691 + },
2692 + Adjacencies: []Adjacency{
2693 + {
2694 + Protocol: "lldp",
2695 + SourceID: "switch-a",
2696 + SourcePort: "2",
2697 + TargetID: "switch-b",
2698 + TargetPort: "ether4",
2699 + },
2700 + },
2701 + Attachments: []Attachment{
2702 + {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:00:00:00:00:00:11", Method: "fdb"},
2703 + },
2704 + }
2705 +
2706 + data := ToTopologyData(result, TopologyDataOptions{
2707 + Source: "snmp",
2708 + Layer: "2",
2709 + View: "summary",
2710 + InferenceStrategy: topologyInferenceStrategyFDBMinimumKnowledge,
2711 + })
2712 +
2713 + require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
2714 + require.Nil(t, findActorByType(data.Actors, "segment"))
2715 + lldpLink := findLinkByProtocol(data.Links, "lldp")
2716 + require.NotNil(t, lldpLink)
2717 + require.Equal(t, 2, lldpLink.Src.Attributes["if_index"])
2718 + require.Equal(t, "GigabitEthernet2", lldpLink.Src.Attributes["if_name"])
2719 + require.Equal(t, "2", lldpLink.Src.Attributes["port_id"])
2720 + require.Equal(t, 4, lldpLink.Dst.Attributes["if_index"])
2721 + require.Equal(t, "ether4", lldpLink.Dst.Attributes["if_name"])
2722 + require.Equal(t, "ether4", lldpLink.Dst.Attributes["port_id"])
2723 +}
2724 +
2725 +func TestToTopologyData_SwitchFacingPortDoesNotSuppressEndpointOwnership(t *testing.T) {
2726 + result := Result{
2727 + Devices: []Device{
2728 + {
2729 + ID: "switch-a",
2730 + Hostname: "switch-a",
2731 + ChassisID: "aa:aa:aa:aa:aa:aa",
2732 + },
2733 + {
2734 + ID: "switch-b",
2735 + Hostname: "switch-b",
2736 + ChassisID: "bb:bb:bb:bb:bb:bb",
2737 + },
2738 + },
2739 + Interfaces: []Interface{
2740 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
2741 + {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1", MAC: "bb:bb:bb:bb:bb:bb"},
2742 + },
2743 + Attachments: []Attachment{
2744 + // Reciprocal managed-alias observations make this a switch-facing bridge pair.
2745 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2746 + {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2747 + // Host endpoint learned on the same switch-facing port.
2748 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:00:00:00:00:00:11", Method: "fdb"},
2749 + },
2750 + }
2751 +
2752 + data := ToTopologyData(result, TopologyDataOptions{
2753 + Source: "snmp",
2754 + Layer: "2",
2755 + View: "summary",
2756 + InferenceStrategy: topologyInferenceStrategyFDBPairwise,
2757 + })
2758 +
2759 + actor := findActorBySysName(data.Actors, "switch-a")
2760 + require.NotNil(t, actor)
2761 +
2762 + statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
2763 + require.True(t, ok)
2764 + port1 := findInterfaceStatusByIndex(statuses, 1)
2765 + require.Equal(t, "switch_facing", port1["topology_role"])
2766 +
2767 + targetLinks := findFDBLinksByEndpointMAC(data.Links, "00:00:00:00:00:11")
2768 + require.Len(t, targetLinks, 1)
2769 + segmentActor := findActorByMatch(data.Actors, targetLinks[0].Src.Match)
2770 + require.NotNil(t, segmentActor)
2771 + parentDevices, ok := segmentActor.Attributes["parent_devices"].([]string)
2772 + require.True(t, ok)
2773 + require.Contains(t, parentDevices, "switch-a")
2774 + ifNames, ok := segmentActor.Attributes["if_names"].([]string)
2775 + require.True(t, ok)
2776 + require.Contains(t, ifNames, "Gi0/1")
2777 +}
2778 +
2779 +func TestSuppressInferredBridgeLinksOnDeterministicDiscovery(t *testing.T) {
2780 + deterministic := make(map[string]struct{})
2781 + addBridgePortObservationKeys(deterministic, bridgePortRef{
2782 + deviceID: "switch-a",
2783 + ifIndex: 1,
2784 + ifName: "Gi0/1",
2785 + })
2786 + discoveryPairs := map[string]struct{}{
2787 + topologyUndirectedPairKey("switch-a", "switch-b"): {},
2788 + }
2789 +
2790 + links := []bridgeBridgeLinkRecord{
2791 + {
2792 + designatedPort: bridgePortRef{deviceID: "switch-a", ifIndex: 1, ifName: "Gi0/1"},
2793 + port: bridgePortRef{deviceID: "switch-b", ifIndex: 1, ifName: "Gi0/1"},
2794 + method: "lldp",
2795 + },
2796 + {
2797 + designatedPort: bridgePortRef{deviceID: "switch-a", ifIndex: 2, ifName: "Gi0/2"},
2798 + port: bridgePortRef{deviceID: "switch-b", ifIndex: 2, ifName: "Gi0/2"},
2799 + method: "fdb_pairwise",
2800 + },
2801 + {
2802 + designatedPort: bridgePortRef{deviceID: "switch-a", ifIndex: 1, ifName: "Gi0/1"},
2803 + port: bridgePortRef{deviceID: "switch-c", ifIndex: 7, ifName: "Gi0/7"},
2804 + method: "stp",
2805 + },
2806 + {
2807 + designatedPort: bridgePortRef{deviceID: "switch-a", ifIndex: 3, ifName: "Gi0/3"},
2808 + port: bridgePortRef{deviceID: "switch-c", ifIndex: 3, ifName: "Gi0/3"},
2809 + method: "fdb_pairwise",
2810 + },
2811 + }
2812 +
2813 + filtered := suppressInferredBridgeLinksOnDeterministicDiscovery(links, deterministic, discoveryPairs)
2814 + require.Len(t, filtered, 2)
2815 + require.Equal(t, "lldp", filtered[0].method)
2816 + require.Equal(t, "fdb_pairwise", filtered[1].method)
2817 + require.Equal(t, "switch-c", filtered[1].port.deviceID)
2818 +}
2819 +
2820 +func TestToTopologyData_FDBOwnerInferenceUsesReporterMatrixRule(t *testing.T) {
2821 + result := Result{
2822 + Devices: []Device{
2823 + {
2824 + ID: "switch-a",
2825 + Hostname: "switch-a",
2826 + ChassisID: "aa:aa:aa:aa:aa:aa",
2827 + },
2828 + {
2829 + ID: "switch-b",
2830 + Hostname: "switch-b",
2831 + ChassisID: "bb:bb:bb:bb:bb:bb",
2832 + },
2833 + {
2834 + ID: "switch-c",
2835 + Hostname: "switch-c",
2836 + ChassisID: "cc:cc:cc:cc:cc:cc",
2837 + },
2838 + },
2839 + Interfaces: []Interface{
2840 + {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
2841 + {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1", MAC: "bb:bb:bb:bb:bb:bb"},
2842 + {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", MAC: "bb:bb:bb:bb:bb:bc"},
2843 + {DeviceID: "switch-c", IfIndex: 1, IfName: "Gi0/1", MAC: "cc:cc:cc:cc:cc:cc"},
2844 + {DeviceID: "switch-c", IfIndex: 2, IfName: "Gi0/2", MAC: "cc:cc:cc:cc:cc:cd"},
2845 + },
2846 + Attachments: []Attachment{
2847 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2848 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:cc:cc:cc:cc:cc:cc", Method: "fdb"},
2849 + {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2850 + {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2851 + {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:cc:cc:cc:cc:cc:cc", Method: "fdb"},
2852 + {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2853 + {DeviceID: "switch-c", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2854 + {DeviceID: "switch-c", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2855 + {DeviceID: "switch-c", IfIndex: 2, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2856 + {DeviceID: "switch-c", IfIndex: 2, EndpointID: "mac:ee:ee:ee:ee:ee:ee", Method: "fdb"},
2857 + },
2858 + }
2859 +
2860 + data := ToTopologyData(result, TopologyDataOptions{
2861 + Source: "snmp",
2862 + Layer: "2",
2863 + View: "summary",
2864 + })
2865 +
2866 + targetLinks := findFDBLinksByEndpointMAC(data.Links, "dd:dd:dd:dd:dd:dd")
2867 + require.Len(t, targetLinks, 1)
2868 +
2869 + segmentActor := findActorByMatch(data.Actors, targetLinks[0].Src.Match)
2870 + require.NotNil(t, segmentActor)
2871 + require.Equal(t, []string{"switch-c"}, segmentActor.Attributes["parent_devices"])
2872 + require.Equal(t, []string{"Gi0/2"}, segmentActor.Attributes["if_names"])
2873 +}
2874 +
2875 +func findInterfaceStatusByIndex(statuses []map[string]any, ifIndex int) map[string]any {
2876 + for _, status := range statuses {
2877 + value, ok := status["if_index"].(int)
2878 + if ok && value == ifIndex {
2879 + return status
2880 + }
2881 + }
2882 + return nil
2883 +}
2884 +
2885 +func findNeighborByProtocol(neighbors []map[string]any, protocol string) map[string]any {
2886 + for _, neighbor := range neighbors {
2887 + value, ok := neighbor["protocol"].(string)
2888 + if ok && strings.EqualFold(value, protocol) {
2889 + return neighbor
2890 + }
2891 + }
2892 + return nil
2893 +}
2894 +
2895 +func findFDBLinksByEndpointMAC(links []topology.Link, mac string) []topology.Link {
2896 + out := make([]topology.Link, 0)
2897 + for _, link := range links {
2898 + if link.Protocol != "fdb" {
2899 + continue
2900 + }
2901 + if slices.Contains(link.Dst.Match.MacAddresses, mac) {
2902 + out = append(out, link)
2903 + }
2904 + }
2905 + return out
2906 +}
2907 +
2908 +func findFDBLinksByEndpointIP(links []topology.Link, ip string) []topology.Link {
2909 + out := make([]topology.Link, 0)
2910 + for _, link := range links {
2911 + if link.Protocol != "fdb" {
2912 + continue
2913 + }
2914 + if slices.Contains(link.Dst.Match.IPAddresses, ip) {
2915 + out = append(out, link)
2916 + }
2917 + }
2918 + return out
2919 +}
2920 +
2921 +func topologyLinkSignatures(links []topology.Link) map[string]struct{} {
2922 + out := make(map[string]struct{}, len(links))
2923 + for _, link := range links {
2924 + srcKey := canonicalTopologyMatchKey(link.Src.Match)
2925 + dstKey := canonicalTopologyMatchKey(link.Dst.Match)
2926 + if srcKey == "" || dstKey == "" {
2927 + continue
2928 + }
2929 + key := strings.Join([]string{
2930 + strings.ToLower(strings.TrimSpace(link.Protocol)),
2931 + strings.ToLower(strings.TrimSpace(link.Direction)),
2932 + srcKey,
2933 + dstKey,
2934 + strings.ToLower(strings.TrimSpace(link.State)),
2935 + strings.ToLower(topologyMetricString(link.Metrics, "attachment_mode")),
2936 + }, keySep)
2937 + out[key] = struct{}{}
2938 + }
2939 + return out
2940 +}
2941 +
2942 +func findFDBLinksByDstSysName(links []topology.Link, sysName string) []topology.Link {
2943 + out := make([]topology.Link, 0)
2944 + for _, link := range links {
2945 + if link.Protocol != "fdb" {
2946 + continue
2947 + }
2948 + if link.Dst.Match.SysName != sysName {
2949 + continue
2950 + }
2951 + out = append(out, link)
2952 + }
2953 + return out
2954 +}
2955 +
2956 +func findActorByMatch(actors []topology.Actor, match topology.Match) *topology.Actor {
2957 + target := canonicalTopologyMatchKey(match)
2958 + if target == "" {
2959 + return nil
2960 + }
2961 + for i := range actors {
2962 + if canonicalTopologyMatchKey(actors[i].Match) == target {
2963 + return &actors[i]
2964 + }
2965 + }
2966 + return nil
2967 +}
2968 +
2969 +func findActorBySysName(actors []topology.Actor, sysName string) *topology.Actor {
2970 + for i := range actors {
2971 + if actors[i].Match.SysName == sysName {
2972 + return &actors[i]
2973 + }
2974 + }
2975 + return nil
2976 +}
2977 +
2978 +func findActorByMAC(actors []topology.Actor, mac string) *topology.Actor {
2979 + for i := range actors {
2980 + if slices.Contains(actors[i].Match.MacAddresses, mac) {
2981 + return &actors[i]
2982 + }
2983 + }
2984 + return nil
2985 +}
2986 +
2987 +func findActorByIP(actors []topology.Actor, ip string) *topology.Actor {
2988 + for i := range actors {
2989 + if slices.Contains(actors[i].Match.IPAddresses, ip) {
2990 + return &actors[i]
2991 + }
2992 + }
2993 + return nil
2994 +}
2995 +
2996 +func findActorByType(actors []topology.Actor, actorType string) *topology.Actor {
2997 + for i := range actors {
2998 + if actors[i].ActorType == actorType {
2999 + return &actors[i]
3000 + }
3001 + }
3002 + return nil
3003 +}
3004 +
3005 +func findLinkByProtocol(links []topology.Link, protocol string) *topology.Link {
3006 + for i := range links {
3007 + if links[i].Protocol == protocol {
3008 + return &links[i]
3009 + }
3010 + }
3011 + return nil
3012 +}
src/go/pkg/topology/engine/topology_updater.go new
+140
@@ -0,0 +1,140 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import "sync"
6 +
7 +// TopologyBuilderFunc builds the current topology snapshot.
8 +type TopologyBuilderFunc func() (NetworkRouterTopology, error)
9 +
10 +// TopologyUpdater ports non-routing runtime semantics from Enlinkd TopologyUpdater.
11 +type TopologyUpdater struct {
12 + mu sync.RWMutex
13 +
14 + topology NetworkRouterTopology
15 + builder TopologyBuilderFunc
16 + parseFn func() bool
17 + refreshFn func()
18 + hasRun bool
19 + forceRun bool
20 + runFailed bool
21 + lastFailed error
22 +}
23 +
24 +// NewTopologyUpdater builds an updater. parseFn and refreshFn are optional.
25 +func NewTopologyUpdater(builder TopologyBuilderFunc, parseFn func() bool, refreshFn func()) *TopologyUpdater {
26 + if builder == nil {
27 + return &TopologyUpdater{}
28 + }
29 + return &TopologyUpdater{
30 + builder: builder,
31 + parseFn: parseFn,
32 + refreshFn: refreshFn,
33 + topology: NetworkRouterTopology{},
34 + }
35 +}
36 +
37 +// RunSchedulable executes one discovery cycle.
38 +func (u *TopologyUpdater) RunSchedulable() {
39 + if u == nil || u.builder == nil {
40 + return
41 + }
42 +
43 + u.mu.Lock()
44 + hasRun := u.hasRun
45 + forceRun := u.forceRun
46 + if forceRun {
47 + u.forceRun = false
48 + }
49 + u.mu.Unlock()
50 +
51 + if !hasRun {
52 + topology, err := u.builder()
53 + u.mu.Lock()
54 + defer u.mu.Unlock()
55 + if err != nil {
56 + u.runFailed = true
57 + u.lastFailed = err
58 + return
59 + }
60 + u.topology = CloneNetworkRouterTopology(topology)
61 + u.hasRun = true
62 + u.runFailed = false
63 + u.lastFailed = nil
64 + return
65 + }
66 +
67 + parseUpdates := false
68 + if u.parseFn != nil {
69 + parseUpdates = u.parseFn()
70 + }
71 + if !parseUpdates && !forceRun {
72 + return
73 + }
74 + if u.refreshFn != nil {
75 + u.refreshFn()
76 + }
77 +
78 + topology, err := u.builder()
79 + u.mu.Lock()
80 + defer u.mu.Unlock()
81 + if err != nil {
82 + u.runFailed = true
83 + u.lastFailed = err
84 + return
85 + }
86 + u.topology = CloneNetworkRouterTopology(topology)
87 + u.runFailed = false
88 + u.lastFailed = nil
89 +}
90 +
91 +// GetTopology returns a non-blocking clone of the current topology.
92 +func (u *TopologyUpdater) GetTopology() NetworkRouterTopology {
93 + if u == nil {
94 + return NetworkRouterTopology{}
95 + }
96 + u.mu.RLock()
97 + topology := CloneNetworkRouterTopology(u.topology)
98 + u.mu.RUnlock()
99 + return topology
100 +}
101 +
102 +// ForceRun requests recomputation on the next RunSchedulable call.
103 +func (u *TopologyUpdater) ForceRun() {
104 + if u == nil {
105 + return
106 + }
107 + u.mu.Lock()
108 + u.forceRun = true
109 + u.mu.Unlock()
110 +}
111 +
112 +// HasRun reports if the first successful run completed.
113 +func (u *TopologyUpdater) HasRun() bool {
114 + if u == nil {
115 + return false
116 + }
117 + u.mu.RLock()
118 + defer u.mu.RUnlock()
119 + return u.hasRun
120 +}
121 +
122 +// LastError returns the last builder error, if any.
123 +func (u *TopologyUpdater) LastError() error {
124 + if u == nil {
125 + return nil
126 + }
127 + u.mu.RLock()
128 + defer u.mu.RUnlock()
129 + return u.lastFailed
130 +}
131 +
132 +// CloneNetworkRouterTopology deep-copies topology structures.
133 +func CloneNetworkRouterTopology(topology NetworkRouterTopology) NetworkRouterTopology {
134 + out := NetworkRouterTopology{
135 + Vertices: append([]NetworkRouterVertex(nil), topology.Vertices...),
136 + Edges: append([]NetworkRouterEdge(nil), topology.Edges...),
137 + DefaultVertex: topology.DefaultVertex,
138 + }
139 + return out
140 +}
src/go/pkg/topology/engine/topology_updater_test.go new
+203
@@ -0,0 +1,203 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "errors"
7 + "strconv"
8 + "sync/atomic"
9 + "testing"
10 + "time"
11 +
12 + "github.com/stretchr/testify/require"
13 +)
14 +
15 +// Port of TopologyUpdaterIT.verifyGetTopologyAccessWhileDiscoveryInProgressDoesNotBlock.
16 +func TestTopologyUpdater_GetTopologyDoesNotBlockDuringDiscovery(t *testing.T) {
17 + var buildCalls atomic.Int64
18 + updater := NewTopologyUpdater(func() (NetworkRouterTopology, error) {
19 + buildCalls.Add(1)
20 + time.Sleep(1 * time.Second)
21 + return NetworkRouterTopology{
22 + Vertices: []NetworkRouterVertex{
23 + {ID: "v1", Label: "Vertex 1"},
24 + {ID: "v2", Label: "Vertex 2"},
25 + },
26 + }, nil
27 + }, nil, nil)
28 +
29 + discoveryDone := make(chan struct{})
30 + go func() {
31 + updater.RunSchedulable()
32 + close(discoveryDone)
33 + }()
34 +
35 + type topoResult struct {
36 + vertices int
37 + edges int
38 + }
39 +
40 + getCurrent := func() <-chan topoResult {
41 + done := make(chan topoResult, 1)
42 + go func() {
43 + current := updater.GetTopology()
44 + done <- topoResult{
45 + vertices: len(current.Vertices),
46 + edges: len(current.Edges),
47 + }
48 + }()
49 + return done
50 + }
51 +
52 + select {
53 + case <-time.After(1 * time.Second):
54 + t.Fatalf("get topology timed out while discovery was running")
55 + case result := <-getCurrent():
56 + require.Equal(t, 0, result.vertices)
57 + require.Equal(t, 0, result.edges)
58 + }
59 +
60 + select {
61 + case <-time.After(3 * time.Second):
62 + t.Fatalf("discovery did not complete")
63 + case <-discoveryDone:
64 + }
65 +
66 + select {
67 + case <-time.After(1 * time.Second):
68 + t.Fatalf("get topology timed out after discovery")
69 + case result := <-getCurrent():
70 + require.Equal(t, 2, result.vertices)
71 + require.Equal(t, 0, result.edges)
72 + }
73 +
74 + require.Equal(t, int64(1), buildCalls.Load())
75 +}
76 +
77 +func TestTopologyUpdaterFirstRunFailureAndRecovery(t *testing.T) {
78 + var calls int
79 + boom := errors.New("boom")
80 +
81 + updater := NewTopologyUpdater(func() (NetworkRouterTopology, error) {
82 + calls++
83 + if calls == 1 {
84 + return NetworkRouterTopology{}, boom
85 + }
86 + return NetworkRouterTopology{
87 + Vertices: []NetworkRouterVertex{{ID: "node-1", Label: "node-1"}},
88 + DefaultVertex: "node-1",
89 + }, nil
90 + }, nil, nil)
91 +
92 + require.False(t, updater.HasRun())
93 + require.Nil(t, updater.LastError())
94 +
95 + updater.RunSchedulable()
96 +
97 + require.False(t, updater.HasRun())
98 + require.ErrorIs(t, updater.LastError(), boom)
99 + require.Empty(t, updater.GetTopology().Vertices)
100 +
101 + updater.RunSchedulable()
102 +
103 + require.True(t, updater.HasRun())
104 + require.NoError(t, updater.LastError())
105 + topology := updater.GetTopology()
106 + require.Equal(t, "node-1", topology.DefaultVertex)
107 + require.Len(t, topology.Vertices, 1)
108 +
109 + topology.Vertices[0].ID = "mutated"
110 + require.Equal(t, "node-1", updater.GetTopology().Vertices[0].ID)
111 +}
112 +
113 +func TestTopologyUpdaterForceRunAndParseUpdatesTriggerRefresh(t *testing.T) {
114 + var (
115 + builderCalls int
116 + parseCalls int
117 + refreshCalls int
118 + parseUpdates bool
119 + )
120 +
121 + updater := NewTopologyUpdater(
122 + func() (NetworkRouterTopology, error) {
123 + builderCalls++
124 + vertexID := strconv.Itoa(builderCalls)
125 + return NetworkRouterTopology{
126 + Vertices: []NetworkRouterVertex{{ID: vertexID, Label: "node-" + vertexID}},
127 + DefaultVertex: vertexID,
128 + }, nil
129 + },
130 + func() bool {
131 + parseCalls++
132 + return parseUpdates
133 + },
134 + func() {
135 + refreshCalls++
136 + },
137 + )
138 +
139 + updater.RunSchedulable()
140 + require.True(t, updater.HasRun())
141 + require.Equal(t, 1, builderCalls)
142 + require.Equal(t, 0, parseCalls)
143 + require.Equal(t, 0, refreshCalls)
144 + require.Equal(t, "1", updater.GetTopology().DefaultVertex)
145 +
146 + updater.RunSchedulable()
147 + require.Equal(t, 1, builderCalls)
148 + require.Equal(t, 1, parseCalls)
149 + require.Equal(t, 0, refreshCalls)
150 + require.Equal(t, "1", updater.GetTopology().DefaultVertex)
151 +
152 + updater.ForceRun()
153 + updater.RunSchedulable()
154 + require.Equal(t, 2, builderCalls)
155 + require.Equal(t, 2, parseCalls)
156 + require.Equal(t, 1, refreshCalls)
157 + require.Equal(t, "2", updater.GetTopology().DefaultVertex)
158 +
159 + parseUpdates = true
160 + updater.RunSchedulable()
161 + require.Equal(t, 3, builderCalls)
162 + require.Equal(t, 3, parseCalls)
163 + require.Equal(t, 2, refreshCalls)
164 + require.Equal(t, "3", updater.GetTopology().DefaultVertex)
165 + require.NoError(t, updater.LastError())
166 +}
167 +
168 +func TestTopologyUpdaterRefreshFailureKeepsLastSuccessfulTopology(t *testing.T) {
169 + var (
170 + builderCalls int
171 + parseUpdates bool
172 + )
173 + boom := errors.New("refresh failed")
174 +
175 + updater := NewTopologyUpdater(
176 + func() (NetworkRouterTopology, error) {
177 + builderCalls++
178 + if builderCalls == 2 {
179 + return NetworkRouterTopology{}, boom
180 + }
181 + vertexID := strconv.Itoa(builderCalls)
182 + return NetworkRouterTopology{
183 + Vertices: []NetworkRouterVertex{{ID: vertexID, Label: "node-" + vertexID}},
184 + DefaultVertex: vertexID,
185 + }, nil
186 + },
187 + func() bool {
188 + return parseUpdates
189 + },
190 + nil,
191 + )
192 +
193 + updater.RunSchedulable()
194 + require.True(t, updater.HasRun())
195 + require.Equal(t, "1", updater.GetTopology().DefaultVertex)
196 + require.NoError(t, updater.LastError())
197 +
198 + parseUpdates = true
199 + updater.RunSchedulable()
200 + require.True(t, updater.HasRun())
201 + require.ErrorIs(t, updater.LastError(), boom)
202 + require.Equal(t, "1", updater.GetTopology().DefaultVertex)
203 +}
src/go/pkg/topology/engine/types.go new
+219
@@ -0,0 +1,219 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package engine
4 +
5 +import (
6 + "net/netip"
7 + "time"
8 +)
9 +
10 +// Credential describes one SNMP authentication profile.
11 +type Credential struct {
12 + Version string
13 + Community string
14 + Username string
15 + AuthProtocol string
16 + AuthPassword string
17 + PrivProtocol string
18 + PrivPassword string
19 + ContextName string
20 + Port uint16
21 + Timeout time.Duration
22 + Retries int
23 +}
24 +
25 +// DiscoverOptions controls the discovery behavior.
26 +type DiscoverOptions struct {
27 + EnableLLDP bool
28 + EnableCDP bool
29 + EnableBridge bool
30 + EnableARP bool
31 + EnableSTP bool
32 + MaxDepth int
33 + Concurrency int
34 + CollectedAt time.Time
35 +}
36 +
37 +// CIDRRequest starts discovery from CIDR ranges.
38 +type CIDRRequest struct {
39 + CIDRs []netip.Prefix
40 + Credentials []Credential
41 + Options DiscoverOptions
42 +}
43 +
44 +// DeviceRequest starts discovery from known device addresses.
45 +type DeviceRequest struct {
46 + Devices []DeviceTarget
47 + Credentials []Credential
48 + Options DiscoverOptions
49 +}
50 +
51 +// DeviceTarget identifies one seed device.
52 +type DeviceTarget struct {
53 + Address netip.Addr
54 + Port uint16
55 + Hostname string
56 + Labels map[string]string
57 +}
58 +
59 +// Result is the discovery output from the engine.
60 +type Result struct {
61 + CollectedAt time.Time
62 + Devices []Device
63 + Interfaces []Interface
64 + Adjacencies []Adjacency
65 + Attachments []Attachment
66 + Enrichments []Enrichment
67 + Stats map[string]any
68 + SourceLabels map[string]string
69 +}
70 +
71 +// Device is a discovered network device.
72 +type Device struct {
73 + ID string
74 + Hostname string
75 + Addresses []netip.Addr
76 + SysObject string
77 + ChassisID string
78 + Labels map[string]string
79 +}
80 +
81 +// Interface is a discovered interface on a device.
82 +type Interface struct {
83 + DeviceID string
84 + IfIndex int
85 + IfName string
86 + IfDescr string
87 + MAC string
88 + Labels map[string]string
89 +}
90 +
91 +// Adjacency represents a direct device-to-device neighbor relation.
92 +type Adjacency struct {
93 + Protocol string
94 + SourceID string
95 + SourcePort string
96 + TargetID string
97 + TargetPort string
98 + Labels map[string]string
99 +}
100 +
101 +// Attachment ties an endpoint to a device interface.
102 +type Attachment struct {
103 + DeviceID string
104 + IfIndex int
105 + EndpointID string
106 + Method string
107 + Labels map[string]string
108 +}
109 +
110 +// Enrichment carries non-structural observations that can assist correlation.
111 +type Enrichment struct {
112 + EndpointID string
113 + IPs []netip.Addr
114 + MAC string
115 + Labels map[string]string
116 +}
117 +
118 +// L2Observation contains one device's normalized layer-2 SNMP observations.
119 +type L2Observation struct {
120 + DeviceID string
121 + // Inferred marks observations synthesized from neighbor advertisements
122 + // (for example LLDP/CDP remotes), not directly polled SNMP targets.
123 + Inferred bool
124 + Hostname string
125 + ManagementIP string
126 + SysObjectID string
127 + ChassisID string
128 + BaseBridgeAddress string
129 + Interfaces []ObservedInterface
130 + BridgePorts []BridgePortObservation
131 + STPPorts []STPPortObservation
132 + FDBEntries []FDBObservation
133 + ARPNDEntries []ARPNDObservation
134 + LLDPRemotes []LLDPRemoteObservation
135 + CDPRemotes []CDPRemoteObservation
136 +}
137 +
138 +// ObservedInterface describes one local interface seen on a device.
139 +type ObservedInterface struct {
140 + IfIndex int
141 + IfName string
142 + IfDescr string
143 + IfAlias string
144 + MAC string
145 + SpeedBps int64
146 + LastChange int64
147 + Duplex string
148 + InterfaceType string
149 + AdminStatus string
150 + OperStatus string
151 +}
152 +
153 +// LLDPRemoteObservation captures one remote LLDP neighbor advertised by a device.
154 +type LLDPRemoteObservation struct {
155 + LocalPortNum string
156 + RemoteIndex string
157 + LocalPortID string
158 + LocalPortIDSubtype string
159 + LocalPortDesc string
160 + ChassisID string
161 + SysName string
162 + PortID string
163 + PortIDSubtype string
164 + PortDesc string
165 + ManagementIP string
166 +}
167 +
168 +// CDPRemoteObservation captures one remote CDP neighbor advertised by a device.
169 +type CDPRemoteObservation struct {
170 + LocalIfIndex int
171 + LocalIfName string
172 + DeviceIndex string
173 + DeviceID string
174 + SysName string
175 + DevicePort string
176 + Address string
177 +}
178 +
179 +// BridgePortObservation maps one bridge base port to an interface index.
180 +type BridgePortObservation struct {
181 + BasePort string
182 + IfIndex int
183 +}
184 +
185 +// FDBObservation captures one forwarding database entry from a bridge table.
186 +type FDBObservation struct {
187 + MAC string
188 + BridgePort string
189 + IfIndex int
190 + Status string
191 + VLANID string
192 + VLANName string
193 +}
194 +
195 +// STPPortObservation captures one spanning-tree port row.
196 +type STPPortObservation struct {
197 + Port string
198 + IfIndex int
199 + IfName string
200 + VLANID string
201 + VLANName string
202 + State string
203 + Enable string
204 + PathCost string
205 + DesignatedRoot string
206 + DesignatedBridge string
207 + DesignatedPort string
208 +}
209 +
210 +// ARPNDObservation captures one ARP or ND neighbor-table observation.
211 +type ARPNDObservation struct {
212 + Protocol string
213 + IfIndex int
214 + IfName string
215 + IP string
216 + MAC string
217 + State string
218 + AddrType string
219 +}
src/go/pkg/topology/types.go new
+180
@@ -0,0 +1,180 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package topology
4 +
5 +import "time"
6 +
7 +type Match struct {
8 + ChassisIDs []string `json:"chassis_ids,omitempty"`
9 + MacAddresses []string `json:"mac_addresses,omitempty"`
10 + IPAddresses []string `json:"ip_addresses,omitempty"`
11 + Hostnames []string `json:"hostnames,omitempty"`
12 + DNSNames []string `json:"dns_names,omitempty"`
13 + SysObjectID string `json:"sys_object_id,omitempty"`
14 + SysName string `json:"sys_name,omitempty"`
15 + NetdataNodeID string `json:"netdata_node_id,omitempty"`
16 + NetdataMachineGUID string `json:"netdata_machine_guid,omitempty"`
17 + CloudInstanceID string `json:"cloud_instance_id,omitempty"`
18 + CloudAccountID string `json:"cloud_account_id,omitempty"`
19 + ContainerIDs []string `json:"container_ids,omitempty"`
20 + PodNames []string `json:"pod_names,omitempty"`
21 + NamespaceIDs []string `json:"namespace_ids,omitempty"`
22 +}
23 +
24 +type Actor struct {
25 + ActorID string `json:"actor_id,omitempty"`
26 + ActorType string `json:"actor_type"`
27 + Layer string `json:"layer"`
28 + Source string `json:"source"`
29 + Match Match `json:"match"`
30 + ParentMatch *Match `json:"parent_match,omitempty"`
31 + Attributes map[string]any `json:"attributes,omitempty"`
32 + Derived map[string]any `json:"derived,omitempty"`
33 + Labels map[string]string `json:"labels,omitempty"`
34 + Tables map[string][]map[string]any `json:"tables,omitempty"`
35 +}
36 +
37 +type LinkEndpoint struct {
38 + Match Match `json:"match"`
39 + Attributes map[string]any `json:"attributes,omitempty"`
40 +}
41 +
42 +type Link struct {
43 + Layer string `json:"layer"`
44 + Protocol string `json:"protocol"`
45 + LinkType string `json:"link_type"`
46 + Direction string `json:"direction,omitempty"`
47 + State string `json:"state,omitempty"`
48 + SrcActorID string `json:"src_actor_id,omitempty"`
49 + DstActorID string `json:"dst_actor_id,omitempty"`
50 + Src LinkEndpoint `json:"src"`
51 + Dst LinkEndpoint `json:"dst"`
52 + DiscoveredAt *time.Time `json:"discovered_at,omitempty"`
53 + LastSeen *time.Time `json:"last_seen,omitempty"`
54 + Metrics map[string]any `json:"metrics,omitempty"`
55 +}
56 +
57 +// Presentation defines how the UI should render this topology.
58 +// Sent in the info response, not in data responses.
59 +
60 +type PresentationSummaryField struct {
61 + Key string `json:"key"`
62 + Label string `json:"label"`
63 + Sources []string `json:"sources"`
64 +}
65 +
66 +type PresentationTableColumn struct {
67 + Key string `json:"key"`
68 + Label string `json:"label"`
69 + Type string `json:"type,omitempty"`
70 +}
71 +
72 +type PresentationTable struct {
73 + Label string `json:"label"`
74 + Source string `json:"source"`
75 + BulletSource bool `json:"bullet_source,omitempty"`
76 + Order int `json:"order,omitempty"`
77 + Columns []PresentationTableColumn `json:"columns"`
78 +}
79 +
80 +type PresentationModalTab struct {
81 + ID string `json:"id"`
82 + Label string `json:"label"`
83 + Type string `json:"type,omitempty"`
84 +}
85 +
86 +type PresentationActorType struct {
87 + Label string `json:"label"`
88 + ColorSlot string `json:"color_slot"`
89 + Opacity float64 `json:"opacity,omitempty"`
90 + Border bool `json:"border"`
91 + Role string `json:"role,omitempty"`
92 + SizeByLinks bool `json:"size_by_links,omitempty"`
93 + ShowPortBullets bool `json:"show_port_bullets,omitempty"`
94 + IconSVG string `json:"icon_svg,omitempty"`
95 + SummaryFields []PresentationSummaryField `json:"summary_fields"`
96 + Tables map[string]PresentationTable `json:"tables"`
97 + ModalTabs []PresentationModalTab `json:"modal_tabs"`
98 +}
99 +
100 +type PresentationLinkType struct {
101 + Label string `json:"label"`
102 + ColorSlot string `json:"color_slot"`
103 + Opacity float64 `json:"opacity,omitempty"`
104 + Width float64 `json:"width,omitempty"`
105 + Dash bool `json:"dash,omitempty"`
106 +}
107 +
108 +type PresentationPortType struct {
109 + Label string `json:"label"`
110 + ColorSlot string `json:"color_slot"`
111 + Opacity float64 `json:"opacity,omitempty"`
112 +}
113 +
114 +type PresentationPortField struct {
115 + Key string `json:"key"`
116 + Label string `json:"label"`
117 +}
118 +
119 +type PresentationLegendEntry struct {
120 + Type string `json:"type"`
121 + Label string `json:"label"`
122 +}
123 +
124 +type PresentationLegend struct {
125 + Actors []PresentationLegendEntry `json:"actors"`
126 + Links []PresentationLegendEntry `json:"links"`
127 + Ports []PresentationLegendEntry `json:"ports,omitempty"`
128 +}
129 +
130 +type Presentation struct {
131 + ActorTypes map[string]PresentationActorType `json:"actor_types"`
132 + LinkTypes map[string]PresentationLinkType `json:"link_types"`
133 + PortTypes map[string]PresentationPortType `json:"port_types,omitempty"`
134 + PortFields []PresentationPortField `json:"port_fields,omitempty"`
135 + Legend PresentationLegend `json:"legend"`
136 + ActorClickBehavior string `json:"actor_click_behavior"`
137 +}
138 +
139 +type FlowExporter struct {
140 + IP string `json:"ip,omitempty"`
141 + Name string `json:"name,omitempty"`
142 + SamplingRate int `json:"sampling_rate,omitempty"`
143 + FlowVersion string `json:"flow_version,omitempty"`
144 +}
145 +
146 +type Flow struct {
147 + Timestamp time.Time `json:"timestamp"`
148 + DurationSec int `json:"duration_sec,omitempty"`
149 + Exporter *FlowExporter `json:"exporter,omitempty"`
150 + Src LinkEndpoint `json:"src"`
151 + Dst LinkEndpoint `json:"dst"`
152 + Key map[string]any `json:"key,omitempty"`
153 + Metrics map[string]any `json:"metrics,omitempty"`
154 +}
155 +
156 +type LiveTopN struct {
157 + Enabled bool `json:"enabled,omitempty"`
158 + Limit int `json:"limit,omitempty"`
159 + SortBy string `json:"sort_by,omitempty"`
160 +}
161 +
162 +type IPPolicy struct {
163 + PublicAllowlist []string `json:"public_allowlist,omitempty"`
164 + LiveTopN *LiveTopN `json:"live_top_n,omitempty"`
165 +}
166 +
167 +type Data struct {
168 + SchemaVersion string `json:"schema_version"`
169 + Source string `json:"source,omitempty"`
170 + Layer string `json:"layer,omitempty"`
171 + AgentID string `json:"agent_id"`
172 + CollectedAt time.Time `json:"collected_at"`
173 + View string `json:"view,omitempty"`
174 + IPPolicy *IPPolicy `json:"ip_policy,omitempty"`
175 + Actors []Actor `json:"actors,omitempty"`
176 + Links []Link `json:"links,omitempty"`
177 + Flows []Flow `json:"flows,omitempty"`
178 + Stats map[string]any `json:"stats,omitempty"`
179 + Metrics map[string]any `json:"metrics,omitempty"`
180 +}
src/go/plugin/agent/discovery/file/watch.go
+14 -7
@@ -25,6 +25,7 @@ type (
25 watcher *fsnotify.Watcher
26 cache cache
27 refreshEvery time.Duration
28 + eventSettle time.Duration
29 }
30 cache map[string]time.Time
31 )
@@ -42,6 +43,7 @@ func NewWatcher(reg confgroup.Registry, paths []string) *Watcher {
43 watcher: nil,
44 cache: make(cache),
45 refreshEvery: time.Minute,
46 + eventSettle: 100 * time.Millisecond,
47 }
48 return d
49 }
@@ -86,13 +88,7 @@ func (w *Watcher) Run(ctx context.Context, in chan<- []*confgroup.Group) {
88 // vim "backupcopy=no" case, already collected after Rename event.
89 break
90 }
89 - if event.Has(fsnotify.Rename) {
90 - // It is common to modify files using vim.
91 - // When writing to a file a backup is made. "backupcopy" option tells how it's done.
92 - // Default is "no": rename the file and write a new one.
93 - // This is cheap attempt to not send empty group for the old file.
94 - time.Sleep(time.Millisecond * 100)
95 - }
91 + w.waitFileEventSettle(event)
92 w.refresh(ctx, in)
93 case err := <-w.watcher.Errors:
94 if err != nil {
@@ -205,6 +201,17 @@ func (w *Watcher) stop() {
201 _ = w.watcher.Close()
202 }
203
204 +func (w *Watcher) waitFileEventSettle(event fsnotify.Event) {
205 + if w.eventSettle <= 0 {
206 + return
207 + }
208 + if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Rename) {
209 + // Give editors and os.WriteFile() a chance to finish truncating/replacing the file
210 + // before we snapshot it, otherwise transient empty reads can be cached as real updates.
211 + time.Sleep(w.eventSettle)
212 + }
213 +}
214 +
215 func isChmodOnly(event fsnotify.Event) bool {
216 return event.Op^fsnotify.Chmod == 0
217 }
src/go/plugin/agent/discovery/file/watch_test.go
+64
@@ -11,6 +11,7 @@ import (
11 "github.com/netdata/netdata/go/plugins/plugin/framework/confgroup"
12
13 "github.com/stretchr/testify/assert"
14 + "github.com/stretchr/testify/require"
15 )
16
17 func TestWatcher_New(t *testing.T) {
@@ -180,6 +181,56 @@ func TestWatcher_Run(t *testing.T) {
181 return sim
182 },
183 },
184 + "add file while write is still settling": {
185 + createSim: func(tmp *tmpDir) discoverySim {
186 + reg := confgroup.Registry{
187 + "module": {},
188 + }
189 + cfg := sdConfig{
190 + {
191 + "name": "name",
192 + "module": "module",
193 + },
194 + }
195 + filename := tmp.join("module.conf")
196 + discovery := prepareDiscovery(t, Config{
197 + Registry: reg,
198 + Watch: []string{tmp.join("*.conf")},
199 + })
200 + setWatcherEventSettle(t, discovery, 50*time.Millisecond)
201 + expected := []*confgroup.Group{
202 + {
203 + Source: filename,
204 + Configs: []confgroup.Config{
205 + {
206 + "name": "name",
207 + "module": "module",
208 + "update_every": collectorapi.UpdateEvery,
209 + "autodetection_retry": collectorapi.AutoDetectionRetry,
210 + "priority": collectorapi.Priority,
211 + "__provider__": "file watcher",
212 + "__source_type__": confgroup.TypeStock,
213 + "__source__": fmt.Sprintf("discoverer=file_watcher,file=%s", filename),
214 + },
215 + },
216 + },
217 + }
218 +
219 + sim := discoverySim{
220 + discovery: discovery,
221 + afterRun: func() {
222 + tmp.writeString(filename, "")
223 + go func() {
224 + time.Sleep(10 * time.Millisecond)
225 + tmp.writeYAML(filename, cfg)
226 + }()
227 + time.Sleep(250 * time.Millisecond)
228 + },
229 + expectedGroups: expected,
230 + }
231 + return sim
232 + },
233 + },
234 "remove file": {
235 createSim: func(tmp *tmpDir) discoverySim {
236 reg := confgroup.Registry{
@@ -377,3 +428,16 @@ func TestWatcher_Run(t *testing.T) {
428 })
429 }
430 }
431 +
432 +func setWatcherEventSettle(t *testing.T, discovery *Discovery, delay time.Duration) {
433 + t.Helper()
434 + require.NotNil(t, discovery)
435 +
436 + for _, dd := range discovery.discoverers {
437 + w, ok := dd.(*Watcher)
438 + if !ok {
439 + continue
440 + }
441 + w.eventSettle = delay
442 + }
443 +}
src/go/plugin/agent/discovery/sd/pipeline/accumulator.go
+20 -1
@@ -62,7 +62,7 @@ func (a *accumulator) run(ctx context.Context, in chan []model.TargetGroup) {
62 } else {
63 a.Info("all discoverers exited")
64 }
65 - a.trySend(in)
65 + a.flushPending(ctx, in)
66 return
67 case <-tk.C:
68 select {
@@ -113,6 +113,25 @@ func (a *accumulator) trySend(in chan<- []model.TargetGroup) {
113 }
114 }
115
116 +func (a *accumulator) flushPending(ctx context.Context, in chan<- []model.TargetGroup) {
117 + a.mux.Lock()
118 + tggs := a.groupsList()
119 + a.mux.Unlock()
120 +
121 + if len(tggs) == 0 {
122 + return
123 + }
124 +
125 + select {
126 + case in <- tggs:
127 + a.mux.Lock()
128 + a.groupsReset()
129 + a.mux.Unlock()
130 + case <-ctx.Done():
131 + a.Warning("ctx done before flushing pending target groups")
132 + }
133 +}
134 +
135 func (a *accumulator) triggerSend() {
136 select {
137 case a.send <- struct{}{}:
src/go/plugin/agent/discovery/sd/pipeline/accumulator_test.go new
+88
@@ -0,0 +1,88 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package pipeline
4 +
5 +import (
6 + "context"
7 + "testing"
8 + "time"
9 +
10 + "github.com/netdata/netdata/go/plugins/plugin/agent/discovery/sd/model"
11 + "github.com/stretchr/testify/require"
12 +)
13 +
14 +func TestAccumulator_Run_FlushesPendingGroupsWhenDiscoverersExit(t *testing.T) {
15 + accum := newAccumulator()
16 + accum.sendEvery = time.Hour
17 + accum.discoverers = []model.Discoverer{
18 + newMockDiscoverer("", newMockTargetGroup("test", "mock1")),
19 + }
20 +
21 + ctx := t.Context()
22 +
23 + updates := make(chan []model.TargetGroup)
24 + done := make(chan struct{})
25 +
26 + go func() {
27 + defer close(done)
28 + accum.run(ctx, updates)
29 + }()
30 +
31 + time.Sleep(50 * time.Millisecond)
32 +
33 + select {
34 + case <-done:
35 + t.Fatal("accumulator exited before delivering pending groups")
36 + default:
37 + }
38 +
39 + var got []model.TargetGroup
40 + select {
41 + case got = <-updates:
42 + case <-time.After(time.Second):
43 + t.Fatal("timed out waiting for final accumulator flush")
44 + }
45 +
46 + require.Len(t, got, 1)
47 + require.Equal(t, "test", got[0].Source())
48 + require.Len(t, got[0].Targets(), 1)
49 +
50 + select {
51 + case <-done:
52 + case <-time.After(time.Second):
53 + t.Fatal("accumulator did not exit after flushing pending groups")
54 + }
55 +}
56 +
57 +func TestAccumulator_Run_ExitsOnCancelWhenFinalFlushBlocks(t *testing.T) {
58 + accum := newAccumulator()
59 + accum.sendEvery = time.Hour
60 + accum.discoverers = []model.Discoverer{
61 + newMockDiscoverer("", newMockTargetGroup("test", "mock1")),
62 + }
63 +
64 + ctx, cancel := context.WithCancel(context.Background())
65 + updates := make(chan []model.TargetGroup)
66 + done := make(chan struct{})
67 +
68 + go func() {
69 + defer close(done)
70 + accum.run(ctx, updates)
71 + }()
72 +
73 + time.Sleep(50 * time.Millisecond)
74 +
75 + select {
76 + case <-done:
77 + t.Fatal("accumulator exited before cancellation")
78 + default:
79 + }
80 +
81 + cancel()
82 +
83 + select {
84 + case <-done:
85 + case <-time.After(time.Second):
86 + t.Fatal("accumulator did not exit after cancellation")
87 + }
88 +}
src/go/plugin/agent/discovery/sd/pipeline/pipeline.go
-3
@@ -64,9 +64,6 @@ type (
64 // new
65 svr composer
66 }
67 - classificator interface {
68 - classify(model.Target) model.Tags
69 - }
67 composer interface {
68 compose(model.Target) []confgroup.Config
69 }
src/go/plugin/agent/jobmgr/funcctl/controller.go
+44 -16
@@ -115,10 +115,11 @@ func (c *Controller) Cleanup() {
115 if method.ID == "" {
116 continue
117 }
118 - funcName := fmt.Sprintf("%s:%s", name, method.ID)
119 - c.fnReg.Unregister(funcName)
120 - if c.api != nil {
121 - c.api.FunctionRemove(funcName)
118 + for _, funcName := range methodFunctionNames(name, method) {
119 + c.fnReg.Unregister(funcName)
120 + if c.api != nil {
121 + c.api.FunctionRemove(funcName)
122 + }
123 }
124 }
125 }
@@ -140,9 +141,6 @@ func (c *Controller) registerModuleMethodsOnFirstJobStart(moduleName string) {
141 continue
142 }
143
143 - funcName := fmt.Sprintf("%s:%s", moduleName, method.ID)
144 - c.fnReg.Register(funcName, c.makeMethodFuncHandler(moduleName, method.ID))
145 -
144 if c.api != nil {
145 help := method.Help
146 if help == "" {
@@ -155,21 +153,47 @@ func (c *Controller) registerModuleMethodsOnFirstJobStart(moduleName string) {
153 access = cloudAccess
154 }
155
158 - c.api.FunctionGlobal(netdataapi.FunctionGlobalOpts{
159 - Name: funcName,
160 - Timeout: 60,
161 - Help: help,
162 - Tags: "top",
163 - Access: access,
164 - Priority: 100,
165 - Version: 3,
166 - })
156 + for _, funcName := range methodFunctionNames(moduleName, method) {
157 + c.fnReg.Register(funcName, c.makeMethodFuncHandler(moduleName, method.ID))
158 + c.api.FunctionGlobal(netdataapi.FunctionGlobalOpts{
159 + Name: funcName,
160 + Timeout: 60,
161 + Help: help,
162 + Tags: "top",
163 + Access: access,
164 + Priority: 100,
165 + Version: 3,
166 + })
167 + }
168 + continue
169 + }
170 +
171 + for _, funcName := range methodFunctionNames(moduleName, method) {
172 + c.fnReg.Register(funcName, c.makeMethodFuncHandler(moduleName, method.ID))
173 }
174 }
175
176 c.staticMethodsSeen[moduleName] = struct{}{}
177 }
178
179 +func methodFunctionNames(moduleName string, method funcapi.MethodConfig) []string {
180 + funcName := fmt.Sprintf("%s:%s", moduleName, method.ID)
181 + funcNames := []string{funcName}
182 + seen := map[string]struct{}{funcName: {}}
183 +
184 + for _, alias := range method.Aliases {
185 + if alias == "" {
186 + continue
187 + }
188 + if _, ok := seen[alias]; ok {
189 + continue
190 + }
191 + seen[alias] = struct{}{}
192 + funcNames = append(funcNames, alias)
193 + }
194 + return funcNames
195 +}
196 +
197 func (c *Controller) registerJobMethods(job collectorapi.RuntimeJob, methods []funcapi.MethodConfig) {
198 planned := make(map[string]struct{}, len(methods))
199
@@ -200,6 +224,8 @@ func (c *Controller) registerJobMethods(job collectorapi.RuntimeJob, methods []f
224 continue
225 }
226
227 + // FIXME: job methods currently ignore method.Aliases and publish only the
228 + // canonical module:method name. Static/module methods use methodFunctionNames().
229 funcName := fmt.Sprintf("%s:%s", job.ModuleName(), method.ID)
230 c.fnReg.Register(funcName, c.makeJobMethodFuncHandler(job.ModuleName(), job.Name(), method.ID))
231
@@ -241,6 +267,8 @@ func (c *Controller) unregisterJobMethods(job collectorapi.RuntimeJob) {
267 continue
268 }
269
270 + // FIXME: keep this in sync with registerJobMethods() if job-method alias
271 + // support is added later; today only the canonical name is removed here.
272 funcName := fmt.Sprintf("%s:%s", job.ModuleName(), method.ID)
273 c.fnReg.Unregister(funcName)
274 if c.api != nil {
src/go/plugin/agent/jobmgr/funcctl/controller_test.go
+68 -4
@@ -282,8 +282,9 @@ func TestDispatchHelpers(t *testing.T) {
282
283 case "build params":
284 cases := map[string]struct{}{
285 - "build accepted params": {},
286 - "build required params uses select type": {},
285 + "build accepted params": {},
286 + "build required params uses select type": {},
287 + "build required params agent-wide omits __job": {},
288 }
289
290 for caseName := range cases {
@@ -297,7 +298,8 @@ func TestDispatchHelpers(t *testing.T) {
298 {ID: "extra"},
299 }
300
300 - assert.Equal(t, []string{"__job", "__sort", "db", "extra"}, buildAcceptedParams(methodParams))
301 + assert.Equal(t, []string{"__job", "__sort", "db", "extra"}, buildAcceptedParams(methodParams, true))
302 + assert.Equal(t, []string{"__sort", "db", "extra"}, buildAcceptedParams(methodParams, false))
303
304 case "build required params uses select type":
305 controller := New(Options{})
@@ -319,7 +321,7 @@ func TestDispatchHelpers(t *testing.T) {
321 {ID: "total_time", Name: "By Total Time", Default: true},
322 },
323 }}
322 - params := controller.buildRequiredParams("postgres", methodParams)
324 + params := controller.buildRequiredParams("postgres", methodParams, true)
325
326 assert.Len(t, params, 2)
327 for _, param := range params {
@@ -336,6 +338,30 @@ func TestDispatchHelpers(t *testing.T) {
338
339 assert.Equal(t, "__job", params[0]["id"])
340 assert.Equal(t, "__sort", params[1]["id"])
341 +
342 + case "build required params agent-wide omits __job":
343 + controller := New(Options{})
344 + controller.RegisterModules(collectorapi.Registry{
345 + "snmp": collectorapi.Creator{
346 + Methods: func() []funcapi.MethodConfig {
347 + return []funcapi.MethodConfig{{ID: "topology:snmp", AgentWide: true}}
348 + },
349 + },
350 + })
351 + controller.registry.addJob("snmp", "router", newTestRuntimeJob("snmp", "router", true))
352 +
353 + methodParams := []funcapi.ParamConfig{{
354 + ID: "topology_view",
355 + Name: "Topology View",
356 + Selection: funcapi.ParamSelect,
357 + Options: []funcapi.ParamOption{
358 + {ID: "l2", Name: "L2", Default: true},
359 + },
360 + }}
361 + params := controller.buildRequiredParams("snmp", methodParams, false)
362 +
363 + assert.Len(t, params, 1)
364 + assert.Equal(t, "topology_view", params[0]["id"])
365 }
366 })
367 }
@@ -344,10 +370,31 @@ func TestDispatchHelpers(t *testing.T) {
370 }
371 }
372
373 +func TestParseArgsParams(t *testing.T) {
374 + args := []string{
375 + "__job:snmp-a",
376 + "view=detailed",
377 + "labels:src_ip,dst_ip",
378 + "info",
379 + "invalid",
380 + "empty:",
381 + "=novalue",
382 + }
383 +
384 + got := parseArgsParams(args)
385 +
386 + assert.Equal(t, []string{"snmp-a"}, got["__job"])
387 + assert.Equal(t, []string{"detailed"}, got["view"])
388 + assert.Equal(t, []string{"src_ip", "dst_ip"}, got["labels"])
389 + assert.NotContains(t, got, "invalid")
390 + assert.NotContains(t, got, "empty")
391 +}
392 +
393 func TestControllerLifecycleHooks(t *testing.T) {
394 tests := map[string]struct{}{
395 "register modules does not register static methods yet": {},
396 "first job start registers static methods once": {},
397 + "topology methods register direct alias": {},
398 "job stop unregisters job methods": {},
399 "cleanup unregisters static methods": {},
400 "cleanup with api configured still unregisters static methods": {},
@@ -380,6 +427,23 @@ func TestControllerLifecycleHooks(t *testing.T) {
427
428 assert.Equal(t, []string{"mod:a"}, reg.registeredNames())
429
430 + case "topology methods register direct alias":
431 + controller.RegisterModules(collectorapi.Registry{
432 + "snmp": collectorapi.Creator{
433 + Methods: func() []funcapi.MethodConfig {
434 + return []funcapi.MethodConfig{{ID: "topology:snmp", Aliases: []string{"topology:snmp"}}}
435 + },
436 + },
437 + })
438 +
439 + controller.OnJobStart(newTestRuntimeJob("snmp", "edge-router", true))
440 +
441 + assert.ElementsMatch(t, []string{"snmp:topology:snmp", "topology:snmp"}, reg.registeredNames())
442 +
443 + controller.Cleanup()
444 +
445 + assert.ElementsMatch(t, []string{"snmp:topology:snmp", "topology:snmp"}, reg.unregisteredNames())
446 +
447 case "job stop unregisters job methods":
448 controller.RegisterModules(collectorapi.Registry{
449 "mod": collectorapi.Creator{
src/go/plugin/agent/jobmgr/funcctl/dispatch.go
+65 -32
@@ -116,27 +116,36 @@ func (c *Controller) makeMethodFuncHandler(moduleName, methodID string) func(fun
116
117 payload := parsePayload(fn.Payload)
118 argValues := parseArgsParams(fn.Args)
119 + includeJobParam := methodRequiresJobParam(methodCfg)
120
121 jobs := c.registry.getJobNames(moduleName)
122 if len(jobs) == 0 {
123 c.respondError(fn, 422, "no %s instances configured", moduleName)
124 return
125 }
125 - jobParam := buildJobParamConfig(jobs)
126 - jobValues := paramValues(argValues, payload, paramJob)
127 - if len(jobValues) > 1 {
128 - c.respondError(fn, 400, "parameter '%s' expects a single value", paramJob)
129 - return
130 - }
131 - resolvedJob := funcapi.ResolveParam(jobParam, jobValues)
132 - jobName := resolvedJob.GetOne()
133 - if len(jobValues) > 0 && jobValues[0] != jobName {
134 - c.respondError(fn, 404, "unknown job '%s', available: %v", jobValues[0], jobs)
135 - return
136 - }
137 - if jobName == "" {
138 - c.respondError(fn, 404, "no %s instances configured", moduleName)
139 - return
126 +
127 + // FIXME: AgentWide currently means "omit __job from the public API" rather
128 + // than "dispatch without a job"; we still route through the first running
129 + // job for the module.
130 + jobName := jobs[0]
131 + var resolvedJob funcapi.ResolvedParam
132 + if includeJobParam {
133 + jobParam := buildJobParamConfig(jobs)
134 + jobValues := paramValues(argValues, payload, paramJob)
135 + if len(jobValues) > 1 {
136 + c.respondError(fn, 400, "parameter '%s' expects a single value", paramJob)
137 + return
138 + }
139 + resolvedJob = funcapi.ResolveParam(jobParam, jobValues)
140 + jobName = resolvedJob.GetOne()
141 + if len(jobValues) > 0 && jobValues[0] != jobName {
142 + c.respondError(fn, 404, "unknown job '%s', available: %v", jobValues[0], jobs)
143 + return
144 + }
145 + if jobName == "" {
146 + c.respondError(fn, 404, "no %s instances configured", moduleName)
147 + return
148 + }
149 }
150
151 job, jobGen := c.registry.getJobWithGeneration(moduleName, jobName)
@@ -163,7 +172,7 @@ func (c *Controller) makeMethodFuncHandler(moduleName, methodID string) func(fun
172 resolvedParams[paramJob] = resolvedJob
173 },
174 respond: func(dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
166 - c.respondWithParams(fn, moduleName, dataResp, methodParams, updateEvery)
175 + c.respondWithParams(fn, moduleName, dataResp, methodParams, updateEvery, methodCfg.ResponseType, includeJobParam)
176 },
177 })
178 }
@@ -177,6 +186,7 @@ func (c *Controller) handleMethodFuncInfo(moduleName, methodID string, fn functi
186 }
187
188 methodParams := methodCfg.RequiredParams
189 + includeJobParam := methodRequiresJobParam(methodCfg)
190 help := methodCfg.Help
191 if help == "" {
192 help = fmt.Sprintf("%s %s data function", moduleName, methodID)
@@ -184,22 +194,30 @@ func (c *Controller) handleMethodFuncInfo(moduleName, methodID string, fn functi
194
195 updateEvery := max(methodCfg.UpdateEvery, 1)
196
187 - c.respondJSON(fn, map[string]any{
197 + resp := map[string]any{
198 "v": 3,
199 "update_every": updateEvery,
200 "status": 200,
191 - "type": "table",
201 + "type": resolveResponseType("", methodCfg.ResponseType),
202 "has_history": false,
203 "help": help,
194 - "accepted_params": buildAcceptedParams(methodParams),
195 - "required_params": c.buildRequiredParams(moduleName, methodParams),
196 - })
197 -}
204 + "accepted_params": buildAcceptedParams(methodParams, includeJobParam),
205 + "required_params": c.buildRequiredParams(moduleName, methodParams, includeJobParam),
206 + }
207 +
208 + if presentation := methodCfg.Presentation(); presentation != nil {
209 + resp["presentation"] = presentation
210 + }
211
199 -func (c *Controller) buildRequiredParams(moduleName string, methodParams []funcapi.ParamConfig) []map[string]any {
200 - jobs := c.registry.getJobNames(moduleName)
212 + c.respondJSON(fn, resp)
213 +}
214
202 - paramConfigs := []funcapi.ParamConfig{buildJobParamConfig(jobs)}
215 +func (c *Controller) buildRequiredParams(moduleName string, methodParams []funcapi.ParamConfig, includeJobParam bool) []map[string]any {
216 + paramConfigs := make([]funcapi.ParamConfig, 0, len(methodParams)+1)
217 + if includeJobParam {
218 + jobs := c.registry.getJobNames(moduleName)
219 + paramConfigs = append(paramConfigs, buildJobParamConfig(jobs))
220 + }
221 paramConfigs = append(paramConfigs, methodParams...)
222
223 required := make([]map[string]any, 0, len(paramConfigs))
@@ -276,6 +294,9 @@ func parseArgsParams(args []string) map[string][]string {
294 continue
295 }
296 parts := strings.SplitN(arg, ":", 2)
297 + if len(parts) != 2 {
298 + parts = strings.SplitN(arg, "=", 2)
299 + }
300 if len(parts) != 2 {
301 continue
302 }
@@ -384,8 +405,11 @@ func buildJobParamConfig(jobs []string) funcapi.ParamConfig {
405 }
406 }
407
387 -func buildAcceptedParams(methodParams []funcapi.ParamConfig) []string {
388 - accepted := []string{paramJob}
408 +func buildAcceptedParams(methodParams []funcapi.ParamConfig, includeJobParam bool) []string {
409 + accepted := make([]string, 0, len(methodParams)+1)
410 + if includeJobParam {
411 + accepted = append(accepted, paramJob)
412 + }
413 for _, param := range methodParams {
414 if !slices.Contains(accepted, param.ID) {
415 accepted = append(accepted, param.ID)
@@ -394,6 +418,10 @@ func buildAcceptedParams(methodParams []funcapi.ParamConfig) []string {
418 return accepted
419 }
420
421 +func methodRequiresJobParam(cfg *funcapi.MethodConfig) bool {
422 + return cfg == nil || !cfg.AgentWide
423 +}
424 +
425 func (c *Controller) makeJobMethodFuncHandler(moduleName, jobName, methodID string) func(functions.Function) {
426 return func(fn functions.Function) {
427 if slices.Contains(fn.Args, "info") {
@@ -431,7 +459,7 @@ func (c *Controller) makeJobMethodFuncHandler(moduleName, jobName, methodID stri
459 return c.resolveJobMethodParams(ctx, methodCfg, handler, methodID)
460 },
461 respond: func(dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
434 - c.respondJobMethodWithParams(fn, dataResp, methodParams, updateEvery)
462 + c.respondJobMethodWithParams(fn, dataResp, methodParams, updateEvery, methodCfg.ResponseType)
463 },
464 })
465 }
@@ -452,16 +480,22 @@ func (c *Controller) handleJobMethodFuncInfo(moduleName, jobName, methodID strin
480
481 updateEvery := max(methodCfg.UpdateEvery, 1)
482
455 - c.respondJSON(fn, map[string]any{
483 + resp := map[string]any{
484 "v": 3,
485 "update_every": updateEvery,
486 "status": 200,
459 - "type": "table",
487 + "type": resolveResponseType("", methodCfg.ResponseType),
488 "has_history": false,
489 "help": help,
490 "accepted_params": buildJobMethodAcceptedParams(methodParams),
491 "required_params": buildJobMethodRequiredParams(methodParams),
464 - })
492 + }
493 +
494 + if presentation := methodCfg.Presentation(); presentation != nil {
495 + resp["presentation"] = presentation
496 + }
497 +
498 + c.respondJSON(fn, resp)
499 }
500
501 func (c *Controller) resolveJobMethodParams(ctx context.Context, methodCfg *funcapi.MethodConfig, handler funcapi.MethodHandler, methodID string) ([]funcapi.ParamConfig, bool, error) {
@@ -477,7 +511,6 @@ func (c *Controller) resolveJobMethodParams(ctx context.Context, methodCfg *func
511
512 return funcapi.MergeParamConfigs(methodParams, jobParams), true, nil
513 }
480 -
514 func buildJobMethodAcceptedParams(methodParams []funcapi.ParamConfig) []string {
515 accepted := make([]string, 0, len(methodParams))
516 for _, param := range methodParams {
src/go/plugin/agent/jobmgr/funcctl/response.go
+20 -5
@@ -13,25 +13,29 @@ import (
13
14 type methodResponseWriter func(dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int)
15
16 -func (c *Controller) respondWithParams(fn functions.Function, moduleName string, dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
16 +func (c *Controller) respondWithParams(fn functions.Function, moduleName string, dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int, methodType string, includeJobParam bool) {
17 c.respondMethodDataWithParams(
18 fn,
19 dataResp,
20 methodParams,
21 updateEvery,
22 - buildAcceptedParams,
22 + methodType,
23 + func(params []funcapi.ParamConfig) []string {
24 + return buildAcceptedParams(params, includeJobParam)
25 + },
26 func(params []funcapi.ParamConfig) []map[string]any {
24 - return c.buildRequiredParams(moduleName, params)
27 + return c.buildRequiredParams(moduleName, params, includeJobParam)
28 },
29 )
30 }
31
29 -func (c *Controller) respondJobMethodWithParams(fn functions.Function, dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int) {
32 +func (c *Controller) respondJobMethodWithParams(fn functions.Function, dataResp *funcapi.FunctionResponse, methodParams []funcapi.ParamConfig, updateEvery int, methodType string) {
33 c.respondMethodDataWithParams(
34 fn,
35 dataResp,
36 methodParams,
37 updateEvery,
38 + methodType,
39 buildJobMethodAcceptedParams,
40 buildJobMethodRequiredParams,
41 )
@@ -42,6 +46,7 @@ func (c *Controller) respondMethodDataWithParams(
46 dataResp *funcapi.FunctionResponse,
47 methodParams []funcapi.ParamConfig,
48 updateEvery int,
49 + methodType string,
50 buildAccepted func([]funcapi.ParamConfig) []string,
51 buildRequired func([]funcapi.ParamConfig) []map[string]any,
52 ) {
@@ -63,7 +68,7 @@ func (c *Controller) respondMethodDataWithParams(
68 "v": 3,
69 "update_every": updateEvery,
70 "status": dataResp.Status,
66 - "type": "table",
71 + "type": resolveResponseType(dataResp.ResponseType, methodType),
72 "has_history": false,
73 "help": dataResp.Help,
74 "accepted_params": buildAccepted(paramsForResponse),
@@ -92,6 +97,16 @@ func (c *Controller) respondMethodDataWithParams(
97 c.respondJSON(fn, resp)
98 }
99
100 +func resolveResponseType(dataType, methodType string) string {
101 + if dataType != "" {
102 + return dataType
103 + }
104 + if methodType != "" {
105 + return methodType
106 + }
107 + return "table"
108 +}
109 +
110 func (c *Controller) respondError(fn functions.Function, status int, format string, args ...any) {
111 c.respondJSON(fn, map[string]any{
112 "status": status,
src/go/plugin/agent/jobmgr/funcctl/response_test.go new
+168
@@ -0,0 +1,168 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package funcctl
4 +
5 +import (
6 + "encoding/json"
7 + "testing"
8 +
9 + "github.com/stretchr/testify/assert"
10 + "github.com/stretchr/testify/require"
11 +
12 + "github.com/netdata/netdata/go/plugins/pkg/funcapi"
13 + "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
14 + "github.com/netdata/netdata/go/plugins/plugin/framework/functions"
15 +)
16 +
17 +func newTestControllerWithCapture(t *testing.T) (*Controller, *map[string]any) {
18 + t.Helper()
19 +
20 + var resp map[string]any
21 + controller := New(Options{
22 + JSONWriter: func(payload []byte, _ int) {
23 + require.NoError(t, json.Unmarshal(payload, &resp))
24 + },
25 + })
26 +
27 + return controller, &resp
28 +}
29 +
30 +func TestRespondWithParams_ResponseType(t *testing.T) {
31 + controller, resp := newTestControllerWithCapture(t)
32 +
33 + dataResp := &funcapi.FunctionResponse{
34 + Status: 200,
35 + ResponseType: "topology",
36 + }
37 +
38 + controller.respondWithParams(functions.Function{}, "snmp", dataResp, nil, 1, "", true)
39 +
40 + assert.Equal(t, "topology", (*resp)["type"])
41 +}
42 +
43 +func TestRespondWithParams_MethodTypeFallback(t *testing.T) {
44 + controller, resp := newTestControllerWithCapture(t)
45 +
46 + dataResp := &funcapi.FunctionResponse{
47 + Status: 200,
48 + }
49 +
50 + controller.respondWithParams(functions.Function{}, "snmp", dataResp, nil, 1, "topology", true)
51 +
52 + assert.Equal(t, "topology", (*resp)["type"])
53 +}
54 +
55 +func TestRespondWithParams_AgentWideOmitsJobParam(t *testing.T) {
56 + controller, resp := newTestControllerWithCapture(t)
57 +
58 + dataResp := &funcapi.FunctionResponse{
59 + Status: 200,
60 + RequiredParams: []funcapi.ParamConfig{{
61 + ID: "topology_view",
62 + Name: "Topology View",
63 + Selection: funcapi.ParamSelect,
64 + Options: []funcapi.ParamOption{
65 + {ID: "l2", Name: "L2", Default: true},
66 + },
67 + }},
68 + }
69 +
70 + controller.respondWithParams(functions.Function{}, "snmp", dataResp, nil, 1, "topology", false)
71 +
72 + accepted, ok := (*resp)["accepted_params"].([]any)
73 + assert.True(t, ok)
74 + assert.Equal(t, []any{"topology_view"}, accepted)
75 +
76 + required, ok := (*resp)["required_params"].([]any)
77 + assert.True(t, ok)
78 + assert.Len(t, required, 1)
79 + req0, ok := required[0].(map[string]any)
80 + assert.True(t, ok)
81 + assert.Equal(t, "topology_view", req0["id"])
82 +}
83 +
84 +func TestHandleMethodFuncInfo_UsesResponseType(t *testing.T) {
85 + controller, resp := newTestControllerWithCapture(t)
86 +
87 + controller.registry.registerModule("snmp", collectorapi.Creator{
88 + Methods: func() []funcapi.MethodConfig {
89 + return []funcapi.MethodConfig{{ID: "topology:snmp", ResponseType: "topology"}}
90 + },
91 + })
92 +
93 + controller.handleMethodFuncInfo("snmp", "topology:snmp", functions.Function{})
94 +
95 + assert.Equal(t, "topology", (*resp)["type"])
96 +}
97 +
98 +func TestHandleMethodFuncInfo_AgentWideOmitsJobParam(t *testing.T) {
99 + controller, resp := newTestControllerWithCapture(t)
100 +
101 + controller.registry.registerModule("snmp", collectorapi.Creator{
102 + Methods: func() []funcapi.MethodConfig {
103 + return []funcapi.MethodConfig{{
104 + ID: "topology:snmp",
105 + ResponseType: "topology",
106 + AgentWide: true,
107 + RequiredParams: []funcapi.ParamConfig{{
108 + ID: "topology_view",
109 + Name: "Topology View",
110 + Selection: funcapi.ParamSelect,
111 + Options: []funcapi.ParamOption{
112 + {ID: "l2", Name: "L2", Default: true},
113 + },
114 + }},
115 + }}
116 + },
117 + })
118 + controller.registry.addJob("snmp", "job-a", newTestRuntimeJob("snmp", "job-a", true))
119 +
120 + controller.handleMethodFuncInfo("snmp", "topology:snmp", functions.Function{})
121 +
122 + accepted, ok := (*resp)["accepted_params"].([]any)
123 + assert.True(t, ok)
124 + assert.Equal(t, []any{"topology_view"}, accepted)
125 +
126 + required, ok := (*resp)["required_params"].([]any)
127 + assert.True(t, ok)
128 + assert.Len(t, required, 1)
129 + req0, ok := required[0].(map[string]any)
130 + assert.True(t, ok)
131 + assert.Equal(t, "topology_view", req0["id"])
132 +}
133 +
134 +func TestHandleJobMethodFuncInfo_UsesResponseType(t *testing.T) {
135 + controller, resp := newTestControllerWithCapture(t)
136 +
137 + controller.registry.registerModule("netflow", collectorapi.Creator{})
138 + controller.registry.registerJobMethods("netflow", "job1", []funcapi.MethodConfig{
139 + {ID: "flows:netflow", ResponseType: "flows"},
140 + })
141 +
142 + controller.handleJobMethodFuncInfo("netflow", "job1", "flows:netflow", functions.Function{})
143 +
144 + assert.Equal(t, "flows", (*resp)["type"])
145 +}
146 +
147 +func TestHandleMethodFuncInfo_IncludesPresentation(t *testing.T) {
148 + controller, resp := newTestControllerWithCapture(t)
149 +
150 + controller.registry.registerModule("snmp", collectorapi.Creator{
151 + Methods: func() []funcapi.MethodConfig {
152 + return []funcapi.MethodConfig{
153 + funcapi.MethodConfig{
154 + ID: "topology:snmp",
155 + ResponseType: "topology",
156 + }.WithPresentation(map[string]any{
157 + "actor_click_behavior": "highlight_connections",
158 + }),
159 + }
160 + },
161 + })
162 +
163 + controller.handleMethodFuncInfo("snmp", "topology:snmp", functions.Function{})
164 +
165 + presentation, ok := (*resp)["presentation"].(map[string]any)
166 + require.True(t, ok)
167 + assert.Equal(t, "highlight_connections", presentation["actor_click_behavior"])
168 +}
src/go/plugin/go.d/collector/init.go
+1
@@ -107,6 +107,7 @@ import (
107 _ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/sensors"
108 _ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/smartctl"
109 _ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp"
110 + _ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp_topology"
111 _ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/spigotmc"
112 _ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/sql"
113 _ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/squid"
src/go/plugin/go.d/collector/snmp/collect.go
+2 -28
@@ -6,12 +6,9 @@ import (
6 "context"
7 "errors"
8 "fmt"
9 - "log/slog"
9 "maps"
11 - "path/filepath"
10 "slices"
11 "strconv"
14 - "strings"
12 "syscall"
13
14 "github.com/google/uuid"
@@ -19,7 +16,6 @@ import (
16 "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
17 "golang.org/x/sync/errgroup"
18
22 - "github.com/netdata/netdata/go/plugins/logger"
19 "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
20 "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector"
21 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/snmputils"
@@ -144,6 +140,8 @@ func (c *Collector) ensureInitialized() error {
140 c.addPingCharts()
141 }
142
143 + c.registerDeviceForTopology(si)
144 +
145 return nil
146 }
147
@@ -203,30 +201,6 @@ func (c *Collector) setupVnode(si *snmputils.SysInfo, deviceMeta map[string]ddsn
201 Labels: labels,
202 }
203 }
206 -
207 -func (c *Collector) setupProfiles(si *snmputils.SysInfo) []*ddsnmp.Profile {
208 - snmpProfiles := ddsnmp.FindProfiles(si.SysObjectID, si.Descr, c.ManualProfiles)
209 - var profInfo []string
210 -
211 - for _, prof := range snmpProfiles {
212 - if logger.Level.Enabled(slog.LevelDebug) {
213 - profInfo = append(profInfo, prof.SourceTree())
214 - } else {
215 - name := strings.TrimSuffix(filepath.Base(prof.SourceFile), filepath.Ext(prof.SourceFile))
216 - profInfo = append(profInfo, name)
217 - }
218 - }
219 -
220 - msg := fmt.Sprintf("device matched %d profile(s): %s (sysObjectID: '%s')", len(snmpProfiles), strings.Join(profInfo, ", "), si.SysObjectID)
221 - if len(snmpProfiles) == 0 {
222 - c.Warning(msg)
223 - } else {
224 - c.Info(msg)
225 - }
226 -
227 - return snmpProfiles
228 -}
229 -
204 func (c *Collector) initAndConnectSNMPClient() (gosnmp.Handler, error) {
205 snmpClient, err := c.initSNMPClient()
206 if err != nil {
src/go/plugin/go.d/collector/snmp/collector.go
+1
@@ -189,6 +189,7 @@ func (c *Collector) Cleanup(ctx context.Context) {
189 if c.funcRouter != nil {
190 c.funcRouter.Cleanup(ctx)
191 }
192 + ddsnmp.DeviceRegistry.Unregister(c.deviceRegistryKey())
193 if c.snmpClient != nil {
194 _ = c.snmpClient.Close()
195 }
src/go/plugin/go.d/collector/snmp/collector_test.go
+3
@@ -697,9 +697,12 @@ type mockDdSnmpCollector struct {
697 pms []*ddsnmp.ProfileMetrics
698 meta map[string]ddsnmp.MetaTag
699 err error
700 +
701 + collectCalls int
702 }
703
704 func (m *mockDdSnmpCollector) Collect() ([]*ddsnmp.ProfileMetrics, error) {
705 + m.collectCalls++
706 return m.pms, m.err
707 }
708
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/metrics.go
+2 -2
@@ -137,7 +137,7 @@ type SymbolConfig struct {
137
138 ChartMeta ChartMeta `yaml:"chart_meta,omitempty" json:"chart_meta"`
139
140 - Mapping MappingConfig `yaml:"mapping,omitempty" json:"mapping,omitempty"`
140 + Mapping MappingConfig `yaml:"mapping,omitempty" json:"mapping"`
141 Transform string `yaml:"transform,omitempty" json:"transform,omitempty"`
142 TransformCompiled *template.Template `yaml:"-" json:"-"`
143 }
@@ -184,7 +184,7 @@ type MetricTagConfig struct {
184 IndexTransform []MetricIndexTransform `yaml:"index_transform,omitempty" json:"index_transform,omitempty"`
185
186 MappingRef string `yaml:"mapping_ref,omitempty" json:"mapping_ref,omitempty"`
187 - Mapping MappingConfig `yaml:"mapping,omitempty" json:"mapping,omitempty"`
187 + Mapping MappingConfig `yaml:"mapping,omitempty" json:"mapping"`
188
189 // Regex
190 // Match/Tags are not exposed as json (UI) since ExtractValue can be used instead
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/validation.go
+41 -15
@@ -16,19 +16,32 @@ import (
16
17 var validMetadataResources = map[string]map[string]bool{
18 "device": {
19 - "name": true,
20 - "description": true,
21 - "sys_object_id": true,
22 - "location": true,
23 - "serial_number": true,
24 - "vendor": true,
25 - "version": true,
26 - "product_name": true,
27 - "model": true,
28 - "os_name": true,
29 - "os_version": true,
30 - "os_hostname": true,
31 - "type": true,
19 + "name": true,
20 + "description": true,
21 + "sys_object_id": true,
22 + "location": true,
23 + "serial_number": true,
24 + "vendor": true,
25 + "version": true,
26 + "software_version": true,
27 + "firmware_version": true,
28 + "hardware_version": true,
29 + "product_name": true,
30 + "model": true,
31 + "os_name": true,
32 + "os_version": true,
33 + "os_hostname": true,
34 + "category": true,
35 + "type": true,
36 + "lldp_loc_chassis_id": true,
37 + "lldp_loc_chassis_id_subtype": true,
38 + "lldp_loc_sys_name": true,
39 + "lldp_loc_sys_desc": true,
40 + "lldp_loc_sys_cap_supported": true,
41 + "lldp_loc_sys_cap_enabled": true,
42 + "bridge_base_address": true,
43 + "stp_designated_root": true,
44 + "vtp_version": true,
45 },
46 "interface": {
47 "name": true,
@@ -128,8 +141,7 @@ func validateEnrichMetadata(metadata MetadataConfig) error {
141 } else {
142 res := metadata[resName]
143 for fieldName := range res.Fields {
131 - _, isValidField := validMetadataResources[resName][fieldName]
132 - if !isValidField {
144 + if !isValidMetadataField(resName, fieldName) {
145 errs = append(errs, fmt.Errorf("invalid resource (%s) field: %s", resName, fieldName))
146 continue
147 }
@@ -179,6 +191,11 @@ func validateEnrichSysobjectIDMetadata(entries []SysobjectIDMetadataEntryConfig)
191
192 // Validate metadata fields
193 for fieldName, field := range entry.Metadata {
194 + if !isValidMetadataField(MetadataDeviceResource, fieldName) {
195 + errs = append(errs, fmt.Errorf("sysobjectid_metadata[%d]: invalid resource (%s) field: %s", i, MetadataDeviceResource, fieldName))
196 + continue
197 + }
198 +
199 // Validate the field must have either value or symbol(s)
200 if field.Value == "" && field.Symbol.OID == "" && len(field.Symbols) == 0 {
201 errs = append(errs, fmt.Errorf("sysobjectid_metadata[%d].%s: must have either value or symbol(s)", i, fieldName))
@@ -210,6 +227,15 @@ func validateEnrichSysobjectIDMetadata(entries []SysobjectIDMetadataEntryConfig)
227 return errors.Join(errs...)
228 }
229
230 +func isValidMetadataField(resourceName, fieldName string) bool {
231 + fields, ok := validMetadataResources[resourceName]
232 + if !ok {
233 + return false
234 + }
235 + _, ok = fields[fieldName]
236 + return ok
237 +}
238 +
239 func validateEnrichMetrics(metrics []MetricsConfig) error {
240 var errs []error
241
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/validation_test.go
+85
@@ -1245,6 +1245,34 @@ func Test_validateEnrichMetadata(t *testing.T) {
1245 },
1246 },
1247 },
1248 + "topology device metadata fields are accepted": {
1249 + wantError: false,
1250 + metadata: MetadataConfig{
1251 + "device": MetadataResourceConfig{
1252 + Fields: map[string]MetadataField{
1253 + "lldp_loc_sys_name": {
1254 + Symbol: SymbolConfig{
1255 + OID: "1.0.8802.1.1.2.1.3.3.0",
1256 + Name: "lldpLocSysName",
1257 + },
1258 + },
1259 + "bridge_base_address": {
1260 + Symbol: SymbolConfig{
1261 + OID: "1.3.6.1.2.1.17.1.1",
1262 + Name: "dot1dBaseBridgeAddress",
1263 + Format: "hex",
1264 + },
1265 + },
1266 + "vtp_version": {
1267 + Symbol: SymbolConfig{
1268 + OID: "1.3.6.1.4.1.9.9.46.1.1.1",
1269 + Name: "vtpVersion",
1270 + },
1271 + },
1272 + },
1273 + },
1274 + },
1275 + },
1276 "invalid resource": {
1277 wantError: true,
1278 metadata: MetadataConfig{
@@ -1329,3 +1357,60 @@ func Test_validateEnrichMetadata(t *testing.T) {
1357 })
1358 }
1359 }
1360 +
1361 +func Test_validateEnrichSysobjectIDMetadata(t *testing.T) {
1362 + tests := map[string]struct {
1363 + entries []SysobjectIDMetadataEntryConfig
1364 + wantError bool
1365 + }{
1366 + "accepts explicit version fields": {
1367 + entries: []SysobjectIDMetadataEntryConfig{
1368 + {
1369 + SysobjectID: "1.3.6.1.4.1.9.1.1",
1370 + Metadata: map[string]MetadataField{
1371 + "software_version": {
1372 + Value: "17.9.4",
1373 + },
1374 + "firmware_version": {
1375 + Symbol: SymbolConfig{
1376 + OID: "1.2.3",
1377 + Name: "firmwareVersion",
1378 + },
1379 + },
1380 + "hardware_version": {
1381 + Symbols: []SymbolConfig{
1382 + {
1383 + OID: "1.2.4",
1384 + Name: "hardwareVersion",
1385 + },
1386 + },
1387 + },
1388 + },
1389 + },
1390 + },
1391 + },
1392 + "rejects unknown field name": {
1393 + entries: []SysobjectIDMetadataEntryConfig{
1394 + {
1395 + SysobjectID: "1.3.6.1.4.1.9.1.1",
1396 + Metadata: map[string]MetadataField{
1397 + "custom_firmware_build": {
1398 + Value: "x1",
1399 + },
1400 + },
1401 + },
1402 + },
1403 + wantError: true,
1404 + },
1405 + }
1406 +
1407 + for name, tc := range tests {
1408 + t.Run(name, func(t *testing.T) {
1409 + if tc.wantError {
1410 + assert.Error(t, validateEnrichSysobjectIDMetadata(tc.entries))
1411 + } else {
1412 + assert.NoError(t, validateEnrichSysobjectIDMetadata(tc.entries))
1413 + }
1414 + })
1415 + }
1416 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table.go
+31
@@ -324,12 +324,43 @@ func (tc *tableCollector) buildTableNameMap(walkResults []tableWalkResult) map[s
324
325 // processTableResult processes a single table result
326 func (tc *tableCollector) processTableResult(result tableWalkResult, walkedData map[string]map[string]gosnmp.SnmpPDU, tableNameToOID map[string]string, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
327 + // Auxiliary table configs used only for cross-table tag lookups do not define
328 + // own symbols and should not trigger cache/fallback collection paths.
329 + // We still cache their presence so they are not re-walked on every cycle.
330 + if len(result.config.Symbols) == 0 {
331 + if result.pdus != nil {
332 + tc.tableCache.cacheData(result.config, nil, nil, nil)
333 + } else if tc.tableCache.isConfigCached(result.config) {
334 + stats.SNMP.TablesCached++
335 + }
336 + return nil, nil
337 + }
338 +
339 // Try cache first
340 if metrics := tc.tryCollectFromCache(result.config, stats); metrics != nil {
341 stats.SNMP.TablesCached++
342 return metrics, nil
343 }
344
345 + // Cache can become stale for dynamic tables. If we did not walk this table in
346 + // the pre-pass (because it looked cached), fall back to a direct walk now.
347 + if result.pdus == nil {
348 + if pdus, ok := walkedData[result.tableOID]; ok {
349 + result.pdus = pdus
350 + } else {
351 + pdus, err := tc.snmpWalk(result.tableOID, stats)
352 + if err != nil {
353 + stats.Errors.SNMP++
354 + return nil, fmt.Errorf("fallback walk failed for table OID '%s': %w", result.tableOID, err)
355 + }
356 + stats.SNMP.TablesWalked++
357 + walkedData[result.tableOID] = pdus
358 + if len(pdus) > 0 {
359 + result.pdus = pdus
360 + }
361 + }
362 + }
363 +
364 // Process walked data if available
365 if result.pdus != nil {
366 ctx := &tableProcessingContext{
src/go/plugin/go.d/collector/snmp/ddsnmp/device_registry.go new
+104
@@ -0,0 +1,104 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmp
4 +
5 +import "maps"
6 +
7 +import "sync"
8 +
9 +// DeviceConnectionInfo holds SNMP connection parameters for a device.
10 +// Registered by SNMP collector jobs, consumed by the topology collector.
11 +type DeviceConnectionInfo struct {
12 + Hostname string
13 + Port int
14 + SNMPVersion string
15 + Community string
16 + V3User string
17 + V3SecurityLevel string
18 + V3AuthProto string
19 + V3AuthKey string
20 + V3PrivProto string
21 + V3PrivKey string
22 + V3ContextName string
23 + MaxRepetitions uint32
24 + MaxOIDs int
25 + Timeout int
26 + Retries int
27 + SysObjectID string
28 + SysDescr string
29 + SysName string
30 + SysContact string
31 + SysLocation string
32 + Vendor string
33 + Model string
34 +
35 + DisableBulkWalk bool
36 +
37 + ManualProfiles []string
38 +
39 + VnodeGUID string
40 + VnodeLabels map[string]string
41 +}
42 +
43 +// DeviceRegistry is a global registry where SNMP jobs register their connection
44 +// info so the topology collector can discover which devices to poll.
45 +var DeviceRegistry = &deviceRegistry{
46 + devices: make(map[string]DeviceConnectionInfo),
47 +}
48 +
49 +type deviceRegistry struct {
50 + mu sync.RWMutex
51 + devices map[string]DeviceConnectionInfo
52 +}
53 +
54 +// Register adds or updates a device in the registry.
55 +// Reference types are deep-copied to prevent data races with the caller.
56 +func (r *deviceRegistry) Register(key string, info DeviceConnectionInfo) {
57 + dev := info
58 + if info.ManualProfiles != nil {
59 + dev.ManualProfiles = make([]string, len(info.ManualProfiles))
60 + copy(dev.ManualProfiles, info.ManualProfiles)
61 + }
62 + if info.VnodeLabels != nil {
63 + dev.VnodeLabels = make(map[string]string, len(info.VnodeLabels))
64 + maps.Copy(dev.VnodeLabels, info.VnodeLabels)
65 + }
66 + r.mu.Lock()
67 + r.devices[key] = dev
68 + r.mu.Unlock()
69 +}
70 +
71 +// Unregister removes a device from the registry.
72 +func (r *deviceRegistry) Unregister(key string) {
73 + r.mu.Lock()
74 + delete(r.devices, key)
75 + r.mu.Unlock()
76 +}
77 +
78 +// Devices returns a deep-copied snapshot of all registered devices.
79 +func (r *deviceRegistry) Devices() []DeviceConnectionInfo {
80 + r.mu.RLock()
81 + defer r.mu.RUnlock()
82 +
83 + devices := make([]DeviceConnectionInfo, 0, len(r.devices))
84 + for _, info := range r.devices {
85 + dev := info
86 + if info.ManualProfiles != nil {
87 + dev.ManualProfiles = make([]string, len(info.ManualProfiles))
88 + copy(dev.ManualProfiles, info.ManualProfiles)
89 + }
90 + if info.VnodeLabels != nil {
91 + dev.VnodeLabels = make(map[string]string, len(info.VnodeLabels))
92 + maps.Copy(dev.VnodeLabels, info.VnodeLabels)
93 + }
94 + devices = append(devices, dev)
95 + }
96 + return devices
97 +}
98 +
99 +// Len returns the number of registered devices.
100 +func (r *deviceRegistry) Len() int {
101 + r.mu.RLock()
102 + defer r.mu.RUnlock()
103 + return len(r.devices)
104 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/load.go
+40
@@ -73,6 +73,46 @@ func loadProfiles() {
73 })
74 }
75
76 +// LoadProfileByName loads a single profile by filename (with or without extension).
77 +// This supports loading abstract profiles (e.g., "_std-*.yaml") for programmatic use.
78 +func LoadProfileByName(name string) (*Profile, error) {
79 + paths := getProfilesDirs()
80 +
81 + candidates := []string{name}
82 + if !strings.HasSuffix(name, ".yaml") && !strings.HasSuffix(name, ".yml") {
83 + candidates = []string{name + ".yaml", name + ".yml"}
84 + }
85 +
86 + var lastErr error
87 + for _, cand := range candidates {
88 + path, err := paths.Find(cand)
89 + if err != nil {
90 + lastErr = err
91 + continue
92 + }
93 +
94 + profile, err := loadProfile(path, paths)
95 + if err != nil {
96 + return nil, err
97 + }
98 +
99 + if err := profile.validate(); err != nil {
100 + return nil, err
101 + }
102 + if err := CompileTransforms(profile); err != nil {
103 + return nil, err
104 + }
105 + profile.removeConstantMetrics()
106 +
107 + return profile, nil
108 + }
109 +
110 + if lastErr == nil {
111 + lastErr = fmt.Errorf("profile '%s' not found", name)
112 + }
113 + return nil, lastErr
114 +}
115 +
116 func loadProfilesFromDir(dirpath string, extendsPaths multipath.MultiPath) ([]*Profile, error) {
117 var profiles []*Profile
118
src/go/plugin/go.d/collector/snmp/ddsnmp/profile.go
+41
@@ -70,6 +70,17 @@ func FindProfiles(sysObjID, sysDescr string, manualProfiles []string) []*Profile
70 return finalize(selected)
71 }
72
73 +// FinalizeProfiles enriches and deduplicates metrics for a given profile list.
74 +// This mirrors the post-processing performed by FindProfiles.
75 +func FinalizeProfiles(profiles []*Profile) []*Profile {
76 + if len(profiles) == 0 {
77 + return nil
78 + }
79 + enrichProfiles(profiles)
80 + deduplicateMetricsAcrossProfiles(profiles)
81 + return profiles
82 +}
83 +
84 type (
85 Profile struct {
86 SourceFile string `yaml:"-"`
@@ -97,6 +108,36 @@ func (p *Profile) SourceTree() string {
108 return fmt.Sprintf("%s: %s", rootName, extensions)
109 }
110
111 +// HasExtension returns true if the profile extends the given profile name
112 +// (matches either full filename or filename without extension).
113 +func (p *Profile) HasExtension(name string) bool {
114 + if p == nil {
115 + return false
116 + }
117 + target := stripFileNameExt(name)
118 + for _, ext := range p.extensionHierarchy {
119 + if extensionHas(ext, target) {
120 + return true
121 + }
122 + }
123 + return false
124 +}
125 +
126 +func extensionHas(ext *extensionInfo, target string) bool {
127 + if ext == nil {
128 + return false
129 + }
130 + if stripFileNameExt(ext.name) == target || stripFileNameExt(ext.sourceFile) == target {
131 + return true
132 + }
133 + for _, child := range ext.extensions {
134 + if extensionHas(child, target) {
135 + return true
136 + }
137 + }
138 + return false
139 +}
140 +
141 func formatExtensions(extensions []*extensionInfo) string {
142 if len(extensions) == 0 {
143 return "[]"
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_filter.go new
+82
@@ -0,0 +1,82 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmp
4 +
5 +import (
6 + "strings"
7 +
8 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
9 +)
10 +
11 +// FilterVirtualMetricsBySources filters virtual metrics keeping only those
12 +// whose source metrics exist in the given metrics list. Used by both the
13 +// snmp and snmp_topology modules for profile filtering.
14 +func FilterVirtualMetricsBySources(vmetrics []ddprofiledefinition.VirtualMetricConfig, metrics []ddprofiledefinition.MetricsConfig) []ddprofiledefinition.VirtualMetricConfig {
15 + if len(vmetrics) == 0 {
16 + return nil
17 + }
18 +
19 + metricNames := make(map[string]struct{}, len(metrics)*2)
20 + for i := range metrics {
21 + addMetricNames(metricNames, &metrics[i])
22 + }
23 +
24 + filtered := vmetrics[:0]
25 + for _, vm := range vmetrics {
26 + clone := vm.Clone()
27 +
28 + switch {
29 + case len(clone.Alternatives) > 0:
30 + alts := clone.Alternatives[:0]
31 + for _, alt := range clone.Alternatives {
32 + if sourcesAvailable(alt.Sources, metricNames) {
33 + alts = append(alts, alt)
34 + }
35 + }
36 + if len(alts) == 0 {
37 + continue
38 + }
39 + clone.Sources = nil
40 + clone.Alternatives = alts
41 + case sourcesAvailable(clone.Sources, metricNames):
42 + // Keep as-is.
43 + default:
44 + continue
45 + }
46 +
47 + filtered = append(filtered, clone)
48 + }
49 +
50 + if len(filtered) == 0 {
51 + return nil
52 + }
53 + return filtered
54 +}
55 +
56 +func addMetricNames(names map[string]struct{}, metric *ddprofiledefinition.MetricsConfig) {
57 + if metric == nil {
58 + return
59 + }
60 +
61 + if name := strings.TrimSpace(FirstNonEmpty(metric.Symbol.Name, metric.Name)); name != "" {
62 + names[name] = struct{}{}
63 + }
64 + for i := range metric.Symbols {
65 + if name := strings.TrimSpace(metric.Symbols[i].Name); name != "" {
66 + names[name] = struct{}{}
67 + }
68 + }
69 +}
70 +
71 +func sourcesAvailable(sources []ddprofiledefinition.VirtualMetricSourceConfig, metricNames map[string]struct{}) bool {
72 + if len(sources) == 0 {
73 + return false
74 + }
75 +
76 + for _, source := range sources {
77 + if _, ok := metricNames[strings.TrimSpace(source.Metric)]; !ok {
78 + return false
79 + }
80 + }
81 + return true
82 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/topology_classify.go new
+190
@@ -0,0 +1,190 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmp
4 +
5 +import (
6 + "slices"
7 + "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
10 +)
11 +
12 +// Topology metric classification functions.
13 +// Used by both the snmp collector (to exclude topology metrics from collection)
14 +// and the snmp_topology collector (to include only topology metrics).
15 +
16 +// IsTopologyMetric returns true if the metric name is a known topology metric.
17 +func IsTopologyMetric(name string) bool {
18 + switch name {
19 + case "_topology_lldp_loc_port_entry", "_topology_lldp_loc_man_addr_entry",
20 + "_topology_lldp_rem_entry", "_topology_lldp_rem_man_addr_entry", "_topology_lldp_rem_man_addr_compat_entry",
21 + "_topology_cdp_cache_entry",
22 + "_topology_if_name_entry", "_topology_if_status_entry", "_topology_if_duplex_entry", "_topology_ip_if_index_entry",
23 + "_topology_bridge_port_if_index_entry", "_topology_fdb_entry", "_topology_qbridge_fdb_entry", "_topology_qbridge_vlan_entry",
24 + "_topology_stp_port_entry", "_topology_vtp_vlan_entry",
25 + "_topology_arp_entry", "_topology_arp_legacy_entry":
26 + return true
27 + default:
28 + return false
29 + }
30 +}
31 +
32 +// IsTopologySysUptimeMetric returns true if the metric name is a sysUptime variant
33 +// used by topology for freshness tracking.
34 +func IsTopologySysUptimeMetric(name string) bool {
35 + switch strings.ToLower(strings.TrimSpace(name)) {
36 + case "sysuptime", "systemuptime":
37 + return true
38 + default:
39 + return false
40 + }
41 +}
42 +
43 +// LooksLikeTopologyIdentifier returns true if the value looks like a topology-related
44 +// identifier based on prefix matching. Used for global metric tag classification.
45 +func LooksLikeTopologyIdentifier(value string) bool {
46 + value = strings.ToLower(strings.TrimSpace(value))
47 + switch {
48 + case value == "":
49 + return false
50 + case strings.HasPrefix(value, "_topology"),
51 + strings.HasPrefix(value, "lldp"),
52 + strings.HasPrefix(value, "cdp"),
53 + strings.HasPrefix(value, "topology"),
54 + strings.HasPrefix(value, "dot1d"),
55 + strings.HasPrefix(value, "dot1q"),
56 + strings.HasPrefix(value, "stp"),
57 + strings.HasPrefix(value, "vtp"),
58 + strings.HasPrefix(value, "fdb"),
59 + strings.HasPrefix(value, "bridge"),
60 + strings.HasPrefix(value, "arp"):
61 + return true
62 + default:
63 + return false
64 + }
65 +}
66 +
67 +// MetricConfigContainsTopologyData returns true if the MetricsConfig contains
68 +// topology-related metrics.
69 +func MetricConfigContainsTopologyData(metric *ddprofiledefinition.MetricsConfig) bool {
70 + if metric == nil {
71 + return false
72 + }
73 +
74 + if name := FirstNonEmpty(metric.Symbol.Name, metric.Name); IsTopologyMetric(name) || IsTopologySysUptimeMetric(name) {
75 + return true
76 + }
77 +
78 + for i := range metric.Symbols {
79 + name := metric.Symbols[i].Name
80 + if IsTopologyMetric(name) || IsTopologySysUptimeMetric(name) {
81 + return true
82 + }
83 + }
84 +
85 + return false
86 +}
87 +
88 +// MetricTagConfigContainsTopologyData returns true if the MetricTagConfig contains
89 +// topology-related data based on prefix matching.
90 +func MetricTagConfigContainsTopologyData(tag *ddprofiledefinition.MetricTagConfig) bool {
91 + if tag == nil {
92 + return false
93 + }
94 +
95 + values := []string{
96 + tag.Tag,
97 + tag.Table,
98 + tag.OID,
99 + tag.Symbol.Name,
100 + tag.Symbol.OID,
101 + tag.Column.Name,
102 + tag.Column.OID,
103 + }
104 + return slices.ContainsFunc(values, LooksLikeTopologyIdentifier)
105 +}
106 +
107 +func MetadataFieldContainsTopologyData(name string, field *ddprofiledefinition.MetadataField) bool {
108 + if field == nil {
109 + return false
110 + }
111 +
112 + if LooksLikeTopologyIdentifier(name) {
113 + return true
114 + }
115 + if LooksLikeTopologyIdentifier(field.Symbol.Name) || LooksLikeTopologyIdentifier(field.Symbol.OID) {
116 + return true
117 + }
118 + for i := range field.Symbols {
119 + if LooksLikeTopologyIdentifier(field.Symbols[i].Name) || LooksLikeTopologyIdentifier(field.Symbols[i].OID) {
120 + return true
121 + }
122 + }
123 +
124 + return false
125 +}
126 +
127 +func MetadataContainsTopologyData(cfg ddprofiledefinition.MetadataConfig) bool {
128 + for _, res := range cfg {
129 + for name, field := range res.Fields {
130 + if MetadataFieldContainsTopologyData(name, &field) {
131 + return true
132 + }
133 + }
134 + }
135 +
136 + return false
137 +}
138 +
139 +func SysobjectIDMetadataContainsTopologyData(entries []ddprofiledefinition.SysobjectIDMetadataEntryConfig) bool {
140 + for _, entry := range entries {
141 + for name, field := range entry.Metadata {
142 + if MetadataFieldContainsTopologyData(name, &field) {
143 + return true
144 + }
145 + }
146 + }
147 +
148 + return false
149 +}
150 +
151 +// ProfileContainsTopologyData returns true if the profile has any topology
152 +// metrics or topology-scoped metadata.
153 +func ProfileContainsTopologyData(prof *Profile) bool {
154 + if prof == nil || prof.Definition == nil {
155 + return false
156 + }
157 +
158 + for i := range prof.Definition.Metrics {
159 + if MetricConfigContainsTopologyData(&prof.Definition.Metrics[i]) {
160 + return true
161 + }
162 + }
163 +
164 + return MetadataContainsTopologyData(prof.Definition.Metadata) ||
165 + SysobjectIDMetadataContainsTopologyData(prof.Definition.SysobjectIDMetadata)
166 +}
167 +
168 +// ProfileHasCollectionData returns true if the profile definition has non-topology data
169 +// worth collecting (metrics, virtual metrics, tags, or metadata).
170 +func ProfileHasCollectionData(def *ddprofiledefinition.ProfileDefinition) bool {
171 + if def == nil {
172 + return false
173 + }
174 + return len(def.Metrics) > 0 ||
175 + len(def.VirtualMetrics) > 0 ||
176 + len(def.MetricTags) > 0 ||
177 + len(def.Metadata) > 0 ||
178 + len(def.SysobjectIDMetadata) > 0
179 +}
180 +
181 +// FirstNonEmpty returns the first non-empty trimmed string from the arguments.
182 +func FirstNonEmpty(values ...string) string {
183 + for _, value := range values {
184 + value = strings.TrimSpace(value)
185 + if value != "" {
186 + return value
187 + }
188 + }
189 + return ""
190 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/topology_classify_test.go new
+111
@@ -0,0 +1,111 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmp
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
9 + "github.com/stretchr/testify/assert"
10 +)
11 +
12 +func TestIsTopologyMetric(t *testing.T) {
13 + for _, name := range []string{
14 + "_topology_lldp_loc_port_entry", "_topology_lldp_loc_man_addr_entry", "_topology_lldp_rem_entry",
15 + "_topology_lldp_rem_man_addr_entry", "_topology_lldp_rem_man_addr_compat_entry",
16 + "_topology_cdp_cache_entry",
17 + "_topology_if_name_entry", "_topology_if_status_entry", "_topology_if_duplex_entry", "_topology_ip_if_index_entry",
18 + "_topology_bridge_port_if_index_entry", "_topology_fdb_entry", "_topology_qbridge_fdb_entry", "_topology_qbridge_vlan_entry",
19 + "_topology_stp_port_entry", "_topology_vtp_vlan_entry",
20 + "_topology_arp_entry", "_topology_arp_legacy_entry",
21 + } {
22 + assert.True(t, IsTopologyMetric(name), "expected topology: %s", name)
23 + }
24 +
25 + for _, name := range []string{
26 + "ifTraffic", "ifErrors", "sysUptime", "upsBatteryStatus", "", "cpu.usage",
27 + } {
28 + assert.False(t, IsTopologyMetric(name), "expected NOT topology: %s", name)
29 + }
30 +}
31 +
32 +func TestIsTopologySysUptimeMetric(t *testing.T) {
33 + for _, name := range []string{"sysUptime", "systemUptime", "SYSUPTIME", "SystemUptime", " sysUptime "} {
34 + assert.True(t, IsTopologySysUptimeMetric(name), "expected uptime: %s", name)
35 + }
36 +
37 + for _, name := range []string{"ifTraffic", "_topology_lldp_rem_entry", "", "uptime"} {
38 + assert.False(t, IsTopologySysUptimeMetric(name), "expected NOT uptime: %s", name)
39 + }
40 +}
41 +
42 +func TestLooksLikeTopologyIdentifier(t *testing.T) {
43 + for _, value := range []string{
44 + "lldpLocChassisId", "cdpDeviceId", "topology_if_name", "_topology_lldp_rem_entry",
45 + "dot1dBasePort", "dot1qVlanId", "stpPortState",
46 + "vtpVlanName", "fdbMac", "bridgeIfIndex", "arpIp",
47 + "LLDP_CAPS", "CDP_PORT",
48 + } {
49 + assert.True(t, LooksLikeTopologyIdentifier(value), "expected topology identifier: %s", value)
50 + }
51 +
52 + for _, value := range []string{
53 + "ifTraffic", "sysName", "cpu_usage", "", "snmp_host", "upsModel",
54 + } {
55 + assert.False(t, LooksLikeTopologyIdentifier(value), "expected NOT topology identifier: %s", value)
56 + }
57 +}
58 +
59 +func TestMetricConfigContainsTopologyData(t *testing.T) {
60 + assert.True(t, MetricConfigContainsTopologyData(&ddprofiledefinition.MetricsConfig{
61 + Symbol: ddprofiledefinition.SymbolConfig{Name: "_topology_lldp_loc_port_entry"},
62 + }))
63 +
64 + assert.True(t, MetricConfigContainsTopologyData(&ddprofiledefinition.MetricsConfig{
65 + Symbol: ddprofiledefinition.SymbolConfig{Name: "systemUptime"},
66 + }))
67 +
68 + assert.True(t, MetricConfigContainsTopologyData(&ddprofiledefinition.MetricsConfig{
69 + Symbols: []ddprofiledefinition.SymbolConfig{{Name: "_topology_fdb_entry"}},
70 + }))
71 +
72 + assert.False(t, MetricConfigContainsTopologyData(&ddprofiledefinition.MetricsConfig{
73 + Symbol: ddprofiledefinition.SymbolConfig{Name: "ifTraffic"},
74 + }))
75 +
76 + assert.False(t, MetricConfigContainsTopologyData(nil))
77 +}
78 +
79 +func TestProfileContainsTopologyData(t *testing.T) {
80 + assert.True(t, ProfileContainsTopologyData(&Profile{
81 + Definition: &ddprofiledefinition.ProfileDefinition{
82 + Metrics: []ddprofiledefinition.MetricsConfig{
83 + {Symbol: ddprofiledefinition.SymbolConfig{Name: "_topology_lldp_rem_entry"}},
84 + },
85 + },
86 + }))
87 +
88 + assert.True(t, ProfileContainsTopologyData(&Profile{
89 + Definition: &ddprofiledefinition.ProfileDefinition{
90 + Metadata: ddprofiledefinition.MetadataConfig{
91 + "device": {
92 + Fields: map[string]ddprofiledefinition.MetadataField{
93 + "lldp_loc_sys_name": {
94 + Symbol: ddprofiledefinition.SymbolConfig{Name: "lldpLocSysName"},
95 + },
96 + },
97 + },
98 + },
99 + },
100 + }))
101 +
102 + assert.False(t, ProfileContainsTopologyData(&Profile{
103 + Definition: &ddprofiledefinition.ProfileDefinition{
104 + Metrics: []ddprofiledefinition.MetricsConfig{
105 + {Symbol: ddprofiledefinition.SymbolConfig{Name: "ifTraffic"}},
106 + },
107 + },
108 + }))
109 +
110 + assert.False(t, ProfileContainsTopologyData(nil))
111 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/topology_provider.go new
+15
@@ -0,0 +1,15 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmp
4 +
5 +import "github.com/netdata/netdata/go/plugins/pkg/funcapi"
6 +
7 +// TopologyHandler is set by the snmp_topology module at init time.
8 +// The snmp module's function router delegates topology:snmp requests to it.
9 +// This avoids circular imports: snmp -> ddsnmp <- snmp_topology.
10 +var TopologyHandler funcapi.MethodHandler
11 +
12 +// TopologyMethodConfig is set by the snmp_topology module at init time.
13 +// The snmp module includes it in its method list so the function appears
14 +// as snmp:topology:snmp.
15 +var TopologyMethodConfig *funcapi.MethodConfig
src/go/plugin/go.d/collector/snmp/func_router.go
+3 -1
@@ -23,6 +23,7 @@ func newFuncRouter(cache *ifaceCache) *funcRouter {
23 handlers: make(map[string]funcapi.MethodHandler),
24 }
25 r.handlers[ifacesMethodID] = newFuncInterfaces(r)
26 + addTopologyFunctionHandler(r.handlers)
27 return r
28 }
29
@@ -50,9 +51,10 @@ func (r *funcRouter) Cleanup(ctx context.Context) {
51 }
52
53 func snmpMethods() []funcapi.MethodConfig {
53 - return []funcapi.MethodConfig{
54 + methods := []funcapi.MethodConfig{
55 ifacesMethodConfig(),
56 }
57 + return appendTopologyMethodConfig(methods)
58 }
59
60 func snmpFunctionHandler(job collectorapi.RuntimeJob) funcapi.MethodHandler {
src/go/plugin/go.d/collector/snmp/init.go
+11 -2
@@ -27,6 +27,17 @@ func (c *Collector) validateConfig() error {
27 }
28
29 func (c *Collector) initSNMPClient() (gosnmp.Handler, error) {
30 + client, err := c.newConfiguredSNMPClient()
31 + if err != nil {
32 + return nil, err
33 + }
34 +
35 + c.Info(snmputils.SnmpClientConnInfo(client))
36 +
37 + return client, nil
38 +}
39 +
40 +func (c *Collector) newConfiguredSNMPClient() (gosnmp.Handler, error) {
41 client := c.newSnmpClient()
42
43 client.SetTarget(c.Hostname)
@@ -68,8 +79,6 @@ func (c *Collector) initSNMPClient() (gosnmp.Handler, error) {
79 return nil, fmt.Errorf("invalid SNMP version: %s", c.Options.Version)
80 }
81
71 - c.Info(snmputils.SnmpClientConnInfo(client))
72 -
82 return client, nil
83 }
84
src/go/plugin/go.d/collector/snmp/integrations/snmp_devices.md
+4 -4
@@ -142,7 +142,10 @@ Before configuring the collector:
142
143 #### Options
144
145 -The following options can be defined globally: update_every, autodetection_retry.
145 +The following options can be defined globally: `update_every`, `autodetection_retry`.
146 +
147 +There is no module-wide `topology:` block in `snmp.conf`.
148 +SNMP topology discovery is handled by the separate `snmp_topology` collector using devices registered by SNMP jobs.
149
150
151 <details open><summary>Config options</summary>
@@ -558,6 +561,3 @@ Table metrics are usually the slowest and often determine the total collection t
561 1. Do logs show “skipping data collection”?
562 2. Does *Internal → Stats* show collection time > `update_every`?
563 3. Increase `update_every` until skips disappear.
561 -
562 -
563 -
src/go/plugin/go.d/collector/snmp/metadata.yaml
+132
@@ -646,6 +646,138 @@ modules:
646 Exposes interface names, operational status, and traffic counters only:<br/>• No packet payloads or authentication credentials are exposed<br/>• No device configuration details are exposed
647 availability: |
648 Available when:<br/>• The collector has completed at least one data collection cycle<br/>• Interface data is cached from the last successful SNMP collection<br/>• Returns HTTP 503 if cache is not ready yet
649 +
650 + - id: topology
651 + name: Network Topology
652 + description: |
653 + Provides the agent-wide SNMP topology view built from all currently running topology-enabled SNMP jobs.
654 +
655 + This function reads cached LLDP/CDP data collected by the independent topology refresh loop and returns a topology schema (devices, links, and stats). No additional SNMP requests are triggered when calling this function.
656 +
657 + Use cases:
658 + - Discover Layer 2 neighbors and link mapping
659 + - Validate cabling and port connections
660 + - Identify adjacent devices that are discovered but not monitored
661 + parameters:
662 + - id: nodes_identity
663 + name: Nodes Identity
664 + description: Choose actor identity strategy. `ip` collapses nodes by management IP and removes non-IP inferred actors. `mac` keeps MAC-oriented identities.
665 + type: select
666 + required: true
667 + default: ip
668 + options:
669 + - id: ip
670 + name: IP
671 + default: true
672 + - id: mac
673 + name: MAC
674 + - id: map_type
675 + name: Map
676 + description: Select the topology map mode. Defaults to the managed-device LLDP/CDP view. Other modes progressively include inferred devices and lower-confidence links.
677 + type: select
678 + required: true
679 + default: lldp_cdp_managed
680 + options:
681 + - id: lldp_cdp_managed
682 + name: LLDP/CDP/Managed Devices Map
683 + default: true
684 + - id: high_confidence_inferred
685 + name: High Confidence Inferred Map
686 + - id: all_devices_low_confidence
687 + name: All Devices (Low Confidence)
688 + - id: inference_strategy
689 + name: Infer Strategy
690 + description: Select the inference algorithm used for FDB/STP/CDP correlation.
691 + type: select
692 + required: true
693 + default: fdb_minimum_knowledge
694 + options:
695 + - id: fdb_minimum_knowledge
696 + name: FDB Minimum-Knowledge (Baseline)
697 + default: true
698 + - id: stp_parent_tree
699 + name: STP Parent Tree
700 + - id: fdb_pairwise_minimum_knowledge
701 + name: FDB Pairwise Minimum-Knowledge
702 + - id: stp_fdb_correlated
703 + name: STP + FDB Correlated
704 + - id: cdp_fdb_hybrid
705 + name: CDP + FDB Hybrid
706 + - id: managed_snmp_device_focus
707 + name: Focus On
708 + description: Limit depth filtering to selected managed SNMP roots. The static default is `all_devices`; additional `ip:<address>` options are supplied dynamically from the current managed SNMP jobs.
709 + type: multiselect
710 + required: true
711 + default: all_devices
712 + options:
713 + - id: all_devices
714 + name: All Devices
715 + default: true
716 + - id: depth
717 + name: Focus Depth
718 + description: Limit topology expansion hops from the focus roots. `all` disables depth filtering.
719 + type: select
720 + required: true
721 + default: all
722 + options:
723 + - id: all
724 + name: All
725 + default: true
726 + - id: "0"
727 + name: "0"
728 + - id: "1"
729 + name: "1"
730 + - id: "2"
731 + name: "2"
732 + - id: "3"
733 + name: "3"
734 + - id: "4"
735 + name: "4"
736 + - id: "5"
737 + name: "5"
738 + - id: "6"
739 + name: "6"
740 + - id: "7"
741 + name: "7"
742 + - id: "8"
743 + name: "8"
744 + - id: "9"
745 + name: "9"
746 + - id: "10"
747 + name: "10"
748 + returns:
749 + description: Agent-wide topology data in a JSON schema suitable for cross-agent aggregation.
750 + columns:
751 + - name: schema_version
752 + type: integer
753 + unit: ""
754 + description: Topology schema version.
755 + - name: agent_id
756 + type: string
757 + unit: ""
758 + description: Netdata Agent or vnode identifier that collected the data.
759 + - name: collected_at
760 + type: string
761 + unit: ""
762 + description: Collection timestamp in RFC 3339 format.
763 + - name: devices
764 + type: array
765 + unit: ""
766 + description: List of devices (local and discovered).
767 + - name: links
768 + type: array
769 + unit: ""
770 + description: List of discovered links (LLDP/CDP).
771 + - name: stats
772 + type: object
773 + unit: ""
774 + description: Summary stats (device/link counts).
775 + performance: |
776 + Uses cached SNMP data only, no additional SNMP requests are triggered:<br/>• Responses are instantaneous from memory cache<br/>• Large devices with many discovered neighbors may return many rows
777 + security: |
778 + Exposes discovered device identifiers, interface/port identifiers, and management addresses only:<br/>• No packet payloads or authentication credentials are exposed<br/>• No device configuration details are exposed
779 + availability: |
780 + Available when:<br/>• The collector has completed at least one successful topology refresh cycle<br/>• LLDP/CDP topology data is present in cache from the last successful topology refresh<br/>• Returns HTTP 503 if topology cache is not ready yet
781 metrics:
782 folding:
783 title: Metrics
src/go/plugin/go.d/collector/snmp/profile-format.md
+45
@@ -1243,6 +1243,7 @@ They work the same in **both** places:
1243
1244 | Transformation | Purpose | Example Input → Output |
1245 |----------------------------------|-------------------------------------------------|------------------------------------------------------------------|
1246 +| `format` | Convert the raw SNMP value before tag parsing. | `0x18fd74331a9c → "18fd74331a9c"` |
1247 | `mapping` | Replace numeric/string codes with names. | `1 → "ethernet"`, `161 → "lag"` |
1248 | `extract_value` | Extract a substring via regex (first group). | `"RouterOS CCR2004-16G-2S+" → "CCR2004-16G-2S+"` |
1249 | `match_pattern` + `match_value` | Replace the value using regex groups or static. | `"Palo Alto Networks VM-Series firewall" → "VM-Series firewall"` |
@@ -1289,6 +1290,50 @@ They work the same in **both** places:
1290 port: $4
1291 ```
1292
1293 +- `format`
1294 + ```yaml
1295 + symbol:
1296 + OID: 1.3.6.1.2.1.17.1.1
1297 + name: dot1dBaseBridgeAddress
1298 + format: hex
1299 + ```
1300 +
1301 +### Format
1302 +
1303 +Use `format` when the raw SNMP value must be converted before tag or metadata processing.
1304 +
1305 +**The collector**:
1306 +
1307 +- Applies `format` when converting the raw SNMP value to a string.
1308 +- Then applies the configured tag or metadata transformations to that formatted string.
1309 +- Supports the same `format` values accepted by `symbol` definitions elsewhere in the profile.
1310 +
1311 +**Where it can be used**:
1312 +
1313 +- `metadata.device.fields.<field>.symbol`
1314 +- `metadata.device.fields.<field>.symbols[]`
1315 +- `metric_tags[].symbol`
1316 +
1317 +**Currently used by this profile set**:
1318 +
1319 +- `format: hex` for octet-string values such as:
1320 + - MAC addresses
1321 + - binary management-address values
1322 + - capability bitmaps
1323 +
1324 +**Example**:
1325 +
1326 +```yaml
1327 +metadata:
1328 + device:
1329 + fields:
1330 + bridge_base_address:
1331 + symbol:
1332 + OID: 1.3.6.1.2.1.17.1.1
1333 + name: dot1dBaseBridgeAddress
1334 + format: hex
1335 +```
1336 +
1337 ### Mapping
1338
1339 Use `mapping` to replace raw tag values with **human-readable text labels**.
src/go/plugin/go.d/collector/snmp/profile_sets.go new
+41
@@ -0,0 +1,41 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "fmt"
7 + "log/slog"
8 + "path/filepath"
9 + "strings"
10 +
11 + "github.com/netdata/netdata/go/plugins/logger"
12 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
13 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/snmputils"
14 +)
15 +
16 +func (c *Collector) setupProfiles(si *snmputils.SysInfo) []*ddsnmp.Profile {
17 + matchedProfiles := ddsnmp.FindProfiles(si.SysObjectID, si.Descr, c.ManualProfiles)
18 + c.logMatchedProfiles(matchedProfiles, si.SysObjectID)
19 +
20 + return selectCollectionProfiles(matchedProfiles)
21 +}
22 +
23 +func (c *Collector) logMatchedProfiles(profiles []*ddsnmp.Profile, sysObjectID string) {
24 + var profInfo []string
25 +
26 + for _, prof := range profiles {
27 + if logger.Level.Enabled(slog.LevelDebug) {
28 + profInfo = append(profInfo, prof.SourceTree())
29 + } else {
30 + name := strings.TrimSuffix(filepath.Base(prof.SourceFile), filepath.Ext(prof.SourceFile))
31 + profInfo = append(profInfo, name)
32 + }
33 + }
34 +
35 + msg := fmt.Sprintf("device matched %d profile(s): %s (sysObjectID: '%s')", len(profiles), strings.Join(profInfo, ", "), sysObjectID)
36 + if len(profiles) == 0 {
37 + c.Warning(msg)
38 + } else {
39 + c.Info(msg)
40 + }
41 +}
src/go/plugin/go.d/collector/snmp/profile_sets_test.go new
+101
@@ -0,0 +1,101 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/assert"
9 + "github.com/stretchr/testify/require"
10 +
11 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
12 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
13 +)
14 +
15 +func TestSelectCollectionProfiles_RemovesTopologyPollWork(t *testing.T) {
16 + profiles := []*ddsnmp.Profile{newMixedTopologyProfile()}
17 +
18 + selected := selectCollectionProfiles(profiles)
19 + require.Len(t, selected, 1)
20 +
21 + prof := selected[0]
22 + require.NotNil(t, prof.Definition)
23 + require.Len(t, prof.Definition.Metrics, 1)
24 + assert.Equal(t, "upsBatteryStatus", prof.Definition.Metrics[0].Symbol.Name)
25 + require.Len(t, prof.Definition.VirtualMetrics, 1)
26 + assert.Equal(t, "upsBatteryStatusTotal", prof.Definition.VirtualMetrics[0].Name)
27 + require.Len(t, prof.Definition.MetricTags, 1)
28 + assert.Equal(t, "ups_model", prof.Definition.MetricTags[0].Tag)
29 + require.Len(t, prof.Definition.Metadata, 1)
30 + assert.NotContains(t, prof.Definition.Metadata["device"].Fields, "lldp_loc_sys_name")
31 + require.Len(t, prof.Definition.SysobjectIDMetadata, 1)
32 +}
33 +
34 +func newMixedTopologyProfile() *ddsnmp.Profile {
35 + return &ddsnmp.Profile{
36 + Definition: &ddprofiledefinition.ProfileDefinition{
37 + Metadata: ddprofiledefinition.MetadataConfig{
38 + "device": {
39 + Fields: map[string]ddprofiledefinition.MetadataField{
40 + "lldp_loc_sys_name": {
41 + Symbol: ddprofiledefinition.SymbolConfig{OID: "1.0.8802.1.1.2.1.3.3.0", Name: "lldpLocSysName"},
42 + },
43 + "model": {
44 + Symbol: ddprofiledefinition.SymbolConfig{OID: "1.2.3.4.5", Name: "deviceModel"},
45 + },
46 + },
47 + },
48 + },
49 + SysobjectIDMetadata: []ddprofiledefinition.SysobjectIDMetadataEntryConfig{
50 + {
51 + SysobjectID: ".1.3.6.1.4.1.1",
52 + Metadata: map[string]ddprofiledefinition.MetadataField{
53 + "vendor": {Value: "test"},
54 + },
55 + },
56 + },
57 + MetricTags: []ddprofiledefinition.MetricTagConfig{
58 + {
59 + Tag: "lldp_loc_chassis_id",
60 + Symbol: ddprofiledefinition.SymbolConfigCompat{
61 + Name: "lldpLocChassisId",
62 + },
63 + },
64 + {
65 + Tag: "ups_model",
66 + Symbol: ddprofiledefinition.SymbolConfigCompat{
67 + Name: "upsModel",
68 + },
69 + },
70 + },
71 + Metrics: []ddprofiledefinition.MetricsConfig{
72 + {
73 + Symbol: ddprofiledefinition.SymbolConfig{
74 + OID: "1.0.8802.1.1.2.1.3.7.1.2",
75 + Name: "_topology_lldp_loc_port_entry",
76 + },
77 + },
78 + {
79 + Symbol: ddprofiledefinition.SymbolConfig{
80 + OID: "1.3.6.1.2.1.33.1.2.1.0",
81 + Name: "upsBatteryStatus",
82 + },
83 + },
84 + },
85 + VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
86 + {
87 + Name: "lldpLocalPortRows",
88 + Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
89 + {Metric: "_topology_lldp_loc_port_entry", Table: "lldpLocPortTable"},
90 + },
91 + },
92 + {
93 + Name: "upsBatteryStatusTotal",
94 + Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
95 + {Metric: "upsBatteryStatus", Table: "upsBatteryTable"},
96 + },
97 + },
98 + },
99 + },
100 + }
101 +}
src/go/plugin/go.d/collector/snmp/topology_device_registry.go new
+74
@@ -0,0 +1,74 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "fmt"
7 + "maps"
8 +
9 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
10 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/snmputils"
11 +)
12 +
13 +func firstVendor(values ...string) string {
14 + for _, v := range values {
15 + if v != "" {
16 + return v
17 + }
18 + }
19 + return ""
20 +}
21 +
22 +func (c *Collector) vnodeGUID() string {
23 + if c.vnode != nil {
24 + return c.vnode.GUID
25 + }
26 + return ""
27 +}
28 +
29 +func (c *Collector) vnodeLabels() map[string]string {
30 + if c.vnode != nil && len(c.vnode.Labels) > 0 {
31 + cp := make(map[string]string, len(c.vnode.Labels))
32 + maps.Copy(cp, c.vnode.Labels)
33 + return cp
34 + }
35 + return nil
36 +}
37 +
38 +func (c *Collector) deviceRegistryKey() string {
39 + return fmt.Sprintf("%p:%s:%d", c, c.Hostname, c.Options.Port)
40 +}
41 +
42 +// registerDeviceForTopology exposes the already-configured SNMP job to the
43 +// snmp_topology collector without duplicating job configuration.
44 +func (c *Collector) registerDeviceForTopology(si *snmputils.SysInfo) {
45 + ddsnmp.DeviceRegistry.Register(c.deviceRegistryKey(), ddsnmp.DeviceConnectionInfo{
46 + Hostname: c.Hostname,
47 + Port: c.Options.Port,
48 + SNMPVersion: c.Options.Version,
49 + Community: c.Community,
50 + V3User: c.User.Name,
51 + V3SecurityLevel: c.User.SecurityLevel,
52 + V3AuthProto: c.User.AuthProto,
53 + V3AuthKey: c.User.AuthKey,
54 + V3PrivProto: c.User.PrivProto,
55 + V3PrivKey: c.User.PrivKey,
56 + V3ContextName: c.User.ContextName,
57 + MaxRepetitions: c.adjMaxRepetitions,
58 + MaxOIDs: c.Options.MaxOIDs,
59 + Timeout: c.Options.Timeout,
60 + Retries: c.Options.Retries,
61 + SysObjectID: si.SysObjectID,
62 + SysDescr: si.Descr,
63 + SysName: si.Name,
64 + SysContact: si.Contact,
65 + SysLocation: si.Location,
66 + Vendor: firstVendor(si.Vendor, si.Organization),
67 + Model: si.Model,
68 +
69 + DisableBulkWalk: c.disableBulkWalk,
70 + ManualProfiles: c.ManualProfiles,
71 + VnodeGUID: c.vnodeGUID(),
72 + VnodeLabels: c.vnodeLabels(),
73 + })
74 +}
src/go/plugin/go.d/collector/snmp/topology_func_router.go new
+22
@@ -0,0 +1,22 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "github.com/netdata/netdata/go/plugins/pkg/funcapi"
7 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
8 +)
9 +
10 +func addTopologyFunctionHandler(handlers map[string]funcapi.MethodHandler) {
11 + if ddsnmp.TopologyHandler == nil || ddsnmp.TopologyMethodConfig == nil {
12 + return
13 + }
14 + handlers[ddsnmp.TopologyMethodConfig.ID] = ddsnmp.TopologyHandler
15 +}
16 +
17 +func appendTopologyMethodConfig(methods []funcapi.MethodConfig) []funcapi.MethodConfig {
18 + if ddsnmp.TopologyMethodConfig == nil {
19 + return methods
20 + }
21 + return append(methods, *ddsnmp.TopologyMethodConfig)
22 +}
src/go/plugin/go.d/collector/snmp/topology_profile_filter.go new
+146
@@ -0,0 +1,146 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
7 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
8 +)
9 +
10 +// selectCollectionProfiles filters out topology metrics from profiles,
11 +// keeping only metrics intended for regular SNMP data collection.
12 +func selectCollectionProfiles(profiles []*ddsnmp.Profile) []*ddsnmp.Profile {
13 + if len(profiles) == 0 {
14 + return nil
15 + }
16 +
17 + selected := make([]*ddsnmp.Profile, 0, len(profiles))
18 + for _, prof := range profiles {
19 + if prof == nil || prof.Definition == nil {
20 + continue
21 + }
22 +
23 + stripTopologyFromProfile(prof)
24 +
25 + if !ddsnmp.ProfileHasCollectionData(prof.Definition) {
26 + continue
27 + }
28 +
29 + selected = append(selected, prof)
30 + }
31 +
32 + if len(selected) == 0 {
33 + return nil
34 + }
35 + return selected
36 +}
37 +
38 +// stripTopologyFromProfile removes topology metrics and tags from a profile,
39 +// leaving only data intended for regular SNMP collection.
40 +func stripTopologyFromProfile(prof *ddsnmp.Profile) {
41 + def := prof.Definition
42 + hadTopologyData := ddsnmp.ProfileContainsTopologyData(prof)
43 +
44 + def.Metrics = stripTopologyMetrics(def.Metrics)
45 + def.VirtualMetrics = ddsnmp.FilterVirtualMetricsBySources(def.VirtualMetrics, def.Metrics)
46 + def.Metadata = stripTopologyMetadata(def.Metadata)
47 + def.SysobjectIDMetadata = stripTopologySysobjectIDMetadata(def.SysobjectIDMetadata)
48 + if hadTopologyData {
49 + def.MetricTags = stripTopologyMetricTags(def.MetricTags)
50 + }
51 +}
52 +
53 +func stripTopologyMetrics(metrics []ddprofiledefinition.MetricsConfig) []ddprofiledefinition.MetricsConfig {
54 + if len(metrics) == 0 {
55 + return nil
56 + }
57 +
58 + filtered := metrics[:0]
59 + for _, metric := range metrics {
60 + if !ddsnmp.MetricConfigContainsTopologyData(&metric) {
61 + filtered = append(filtered, metric)
62 + }
63 + }
64 +
65 + if len(filtered) == 0 {
66 + return nil
67 + }
68 + return filtered
69 +}
70 +
71 +func stripTopologyMetricTags(tags []ddprofiledefinition.MetricTagConfig) []ddprofiledefinition.MetricTagConfig {
72 + if len(tags) == 0 {
73 + return nil
74 + }
75 +
76 + filtered := tags[:0]
77 + for _, tag := range tags {
78 + if !ddsnmp.MetricTagConfigContainsTopologyData(&tag) {
79 + filtered = append(filtered, tag)
80 + }
81 + }
82 +
83 + if len(filtered) == 0 {
84 + return nil
85 + }
86 + return filtered
87 +}
88 +
89 +func stripTopologyMetadata(meta ddprofiledefinition.MetadataConfig) ddprofiledefinition.MetadataConfig {
90 + if len(meta) == 0 {
91 + return nil
92 + }
93 +
94 + filtered := make(ddprofiledefinition.MetadataConfig)
95 + for resName, res := range meta {
96 + fields := make(map[string]ddprofiledefinition.MetadataField)
97 + for name, field := range res.Fields {
98 + if !ddsnmp.MetadataFieldContainsTopologyData(name, &field) {
99 + fields[name] = field
100 + }
101 + }
102 +
103 + idTags := stripTopologyMetricTags(res.IDTags)
104 + if len(fields) == 0 && len(idTags) == 0 {
105 + continue
106 + }
107 +
108 + filtered[resName] = ddprofiledefinition.MetadataResourceConfig{
109 + Fields: fields,
110 + IDTags: idTags,
111 + }
112 + }
113 +
114 + if len(filtered) == 0 {
115 + return nil
116 + }
117 + return filtered
118 +}
119 +
120 +func stripTopologySysobjectIDMetadata(entries []ddprofiledefinition.SysobjectIDMetadataEntryConfig) []ddprofiledefinition.SysobjectIDMetadataEntryConfig {
121 + if len(entries) == 0 {
122 + return nil
123 + }
124 +
125 + filtered := make([]ddprofiledefinition.SysobjectIDMetadataEntryConfig, 0, len(entries))
126 + for _, entry := range entries {
127 + fields := make(map[string]ddprofiledefinition.MetadataField)
128 + for name, field := range entry.Metadata {
129 + if !ddsnmp.MetadataFieldContainsTopologyData(name, &field) {
130 + fields[name] = field
131 + }
132 + }
133 + if len(fields) == 0 {
134 + continue
135 + }
136 + filtered = append(filtered, ddprofiledefinition.SysobjectIDMetadataEntryConfig{
137 + SysobjectID: entry.SysobjectID,
138 + Metadata: fields,
139 + })
140 + }
141 +
142 + if len(filtered) == 0 {
143 + return nil
144 + }
145 + return filtered
146 +}
src/go/plugin/go.d/collector/snmp_topology/charts.go new
+45
@@ -0,0 +1,45 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
6 +
7 +var (
8 + topologyDevicesChart = collectorapi.Chart{
9 + ID: "topology_devices",
10 + Title: "Topology devices",
11 + Units: "devices",
12 + Fam: "Topology",
13 + Ctx: "snmp_topology.devices",
14 + Priority: 39200,
15 + Dims: collectorapi.Dims{
16 + {ID: "snmp_topology_devices_total", Name: "total"},
17 + {ID: "snmp_topology_devices_discovered", Name: "discovered"},
18 + },
19 + }
20 + topologyLinksChart = collectorapi.Chart{
21 + ID: "topology_links",
22 + Title: "Topology links",
23 + Units: "links",
24 + Fam: "Topology",
25 + Ctx: "snmp_topology.links",
26 + Priority: 39201,
27 + Dims: collectorapi.Dims{
28 + {ID: "snmp_topology_links_total", Name: "total"},
29 + {ID: "snmp_topology_links_lldp", Name: "lldp"},
30 + {ID: "snmp_topology_links_cdp", Name: "cdp"},
31 + {ID: "snmp_topology_links_stp", Name: "stp"},
32 + },
33 + }
34 + topologyCharts = collectorapi.Charts{
35 + topologyDevicesChart.Copy(),
36 + topologyLinksChart.Copy(),
37 + }
38 +)
39 +
40 +func (c *Collector) addTopologyCharts() {
41 + charts := topologyCharts.Copy()
42 + if err := c.Charts().Add(*charts...); err != nil {
43 + c.Warningf("failed to add topology charts: %v", err)
44 + }
45 +}
src/go/plugin/go.d/collector/snmp_topology/collector.go new
+345
@@ -0,0 +1,345 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "context"
7 + _ "embed"
8 + "fmt"
9 + "time"
10 +
11 + "github.com/gosnmp/gosnmp"
12 +
13 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
14 +
15 + "github.com/netdata/netdata/go/plugins/pkg/funcapi"
16 + "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
17 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
18 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector"
19 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/snmputils"
20 +)
21 +
22 +//go:embed "config_schema.json"
23 +var configSchema string
24 +
25 +func init() {
26 + collectorapi.Register("snmp_topology", collectorapi.Creator{
27 + JobConfigSchema: configSchema,
28 + Defaults: collectorapi.Defaults{
29 + UpdateEvery: 60,
30 + },
31 + Create: func() collectorapi.CollectorV1 { return New() },
32 + Config: func() any { return &Config{} },
33 + })
34 +
35 + // Register the topology function handler and method config so the snmp module
36 + // can serve topology:snmp requests under the snmp:topology:snmp function name.
37 + ddsnmp.TopologyHandler = &funcTopology{}
38 + cfg := topologyMethodConfig()
39 + ddsnmp.TopologyMethodConfig = &cfg
40 +}
41 +
42 +func New() *Collector {
43 + return &Collector{
44 + deviceCaches: make(map[string]*topologyCache),
45 + deviceLastCollected: make(map[string]time.Time),
46 + newSnmpClient: gosnmp.NewHandler,
47 + newDdSnmpColl: func(cfg ddsnmpcollector.Config) ddCollector {
48 + return ddsnmpcollector.New(cfg)
49 + },
50 + }
51 +}
52 +
53 +type (
54 + Collector struct {
55 + collectorapi.Base `yaml:",inline"`
56 + Config `yaml:",inline"`
57 +
58 + charts *collectorapi.Charts
59 + deviceCaches map[string]*topologyCache // one cache per SNMP device
60 + deviceLastCollected map[string]time.Time // last collection time per device
61 + topologyCache *topologyCache // current device cache (set during refreshDeviceTopology)
62 + topologyChartsAdded bool
63 +
64 + newSnmpClient func() gosnmp.Handler
65 + newDdSnmpColl func(ddsnmpcollector.Config) ddCollector
66 + }
67 + ddCollector interface {
68 + Collect() ([]*ddsnmp.ProfileMetrics, error)
69 + }
70 +)
71 +
72 +func (c *Collector) Configuration() any {
73 + return c.Config
74 +}
75 +
76 +func (c *Collector) Init(context.Context) error {
77 + return nil
78 +}
79 +
80 +func (c *Collector) Check(context.Context) error {
81 + return nil
82 +}
83 +
84 +func (c *Collector) Charts() *collectorapi.Charts {
85 + if c.charts == nil {
86 + c.charts = &collectorapi.Charts{}
87 + }
88 + return c.charts
89 +}
90 +
91 +func (c *Collector) Collect(context.Context) map[string]int64 {
92 + if devices := ddsnmp.DeviceRegistry.Devices(); len(devices) > 0 {
93 + refreshEvery := c.refreshEvery()
94 + now := time.Now()
95 + seen := make(map[string]bool, len(devices))
96 +
97 + for _, dev := range devices {
98 + key := fmt.Sprintf("%s:%d", dev.Hostname, dev.Port)
99 + seen[key] = true
100 +
101 + lastCollected, exists := c.deviceLastCollected[key]
102 + isNew := !exists
103 + isStale := exists && now.Sub(lastCollected) >= refreshEvery
104 +
105 + if isNew || isStale {
106 + c.refreshDeviceTopology(key, dev)
107 + c.deviceLastCollected[key] = now
108 + }
109 + }
110 +
111 + c.pruneStaleDeviceCaches(seen)
112 + }
113 +
114 + mx := make(map[string]int64)
115 + c.collectTopologyMetrics(mx)
116 + return mx
117 +}
118 +
119 +const defaultRefreshEvery = 30 * time.Minute
120 +
121 +func (c *Collector) refreshEvery() time.Duration {
122 + if d := c.RefreshEvery.Duration(); d > 0 {
123 + return d
124 + }
125 + return defaultRefreshEvery
126 +}
127 +
128 +func (c *Collector) Cleanup(context.Context) {
129 + for key, cache := range c.deviceCaches {
130 + snmpTopologyRegistry.unregister(cache)
131 + delete(c.deviceCaches, key)
132 + }
133 +}
134 +
135 +// refreshDeviceTopology collects topology data for a single device into its own cache.
136 +func (c *Collector) refreshDeviceTopology(key string, dev ddsnmp.DeviceConnectionInfo) {
137 + cache := c.getOrCreateDeviceCache(key, dev)
138 +
139 + snmpClient, err := newSNMPClientFromDeviceInfo(c.newSnmpClient, dev)
140 + if err != nil {
141 + c.Warningf("device '%s': failed to create SNMP client: %v", dev.Hostname, err)
142 + return
143 + }
144 + if dev.MaxRepetitions != 0 {
145 + snmpClient.SetMaxRepetitions(dev.MaxRepetitions)
146 + }
147 + if err := snmpClient.Connect(); err != nil {
148 + c.Warningf("device '%s': failed to connect: %v", dev.Hostname, err)
149 + return
150 + }
151 + defer func() { _ = snmpClient.Close() }()
152 +
153 + profiles := c.findTopologyProfiles(dev)
154 + if len(profiles) == 0 {
155 + return
156 + }
157 +
158 + coll := c.newDdSnmpColl(ddsnmpcollector.Config{
159 + SnmpClient: snmpClient,
160 + Profiles: profiles,
161 + Log: c.Logger,
162 + SysObjectID: dev.SysObjectID,
163 + DisableBulkWalk: dev.DisableBulkWalk,
164 + })
165 +
166 + pms, err := coll.Collect()
167 + if err != nil {
168 + c.Warningf("device '%s': topology collection failed: %v", dev.Hostname, err)
169 + return
170 + }
171 +
172 + // Point c.topologyCache at this device's cache so the ingestion methods work.
173 + c.topologyCache = cache
174 +
175 + c.updateTopologyProfileTags(pms)
176 + c.ingestTopologyProfileMetrics(pms)
177 + c.collectTopologyVTPVLANContexts(dev)
178 + c.finalizeTopologyCache()
179 +
180 + c.topologyCache = nil
181 +}
182 +
183 +func (c *Collector) getOrCreateDeviceCache(key string, dev ddsnmp.DeviceConnectionInfo) *topologyCache {
184 + cache, ok := c.deviceCaches[key]
185 + if !ok {
186 + cache = newTopologyCache()
187 + c.deviceCaches[key] = cache
188 + snmpTopologyRegistry.register(cache)
189 + }
190 +
191 + // Reset cache for fresh collection cycle.
192 + cache.mu.Lock()
193 + cache.updateTime = time.Now()
194 + cache.lastUpdate = time.Time{}
195 + cache.staleAfter = c.refreshEvery() + time.Duration(c.UpdateEvery*2)*time.Second
196 + cache.agentID = dev.Hostname
197 + cache.localDevice = buildLocalTopologyDevice(dev)
198 + cache.lldpLocPorts = make(map[string]*lldpLocPort)
199 + cache.lldpRemotes = make(map[string]*lldpRemote)
200 + cache.cdpRemotes = make(map[string]*cdpRemote)
201 + cache.ifNamesByIndex = make(map[string]string)
202 + cache.ifStatusByIndex = make(map[string]ifStatus)
203 + cache.ifIndexByIP = make(map[string]string)
204 + cache.ifNetmaskByIP = make(map[string]string)
205 + cache.bridgePortToIf = make(map[string]string)
206 + cache.fdbEntries = make(map[string]*fdbEntry)
207 + cache.fdbIDToVlanID = make(map[string]string)
208 + cache.vlanIDToName = make(map[string]string)
209 + cache.vtpVersion = ""
210 + cache.stpBaseBridgeAddress = ""
211 + cache.stpDesignatedRoot = ""
212 + cache.stpPorts = make(map[string]*stpPortEntry)
213 + cache.arpEntries = make(map[string]*arpEntry)
214 + cache.mu.Unlock()
215 +
216 + return cache
217 +}
218 +
219 +func (c *Collector) pruneStaleDeviceCaches(seen map[string]bool) {
220 + for key, cache := range c.deviceCaches {
221 + if !seen[key] {
222 + snmpTopologyRegistry.unregister(cache)
223 + delete(c.deviceCaches, key)
224 + delete(c.deviceLastCollected, key)
225 + }
226 + }
227 +}
228 +
229 +func (c *Collector) findTopologyProfiles(dev ddsnmp.DeviceConnectionInfo) []*ddsnmp.Profile {
230 + return selectTopologyRefreshProfiles(ddsnmp.FindProfiles(dev.SysObjectID, dev.SysDescr, dev.ManualProfiles))
231 +}
232 +
233 +func (c *Collector) ingestTopologyProfileMetrics(pms []*ddsnmp.ProfileMetrics) {
234 + for _, pm := range pms {
235 + c.ingestTopologyMetricSet(pm.HiddenMetrics)
236 + c.ingestTopologyMetricSet(pm.Metrics)
237 + }
238 +}
239 +
240 +func (c *Collector) ingestTopologyMetricSet(metrics []ddsnmp.Metric) {
241 + for _, metric := range metrics {
242 + switch {
243 + case ddsnmp.IsTopologyMetric(metric.Name):
244 + c.updateTopologyCacheEntry(metric)
245 + case ddsnmp.IsTopologySysUptimeMetric(metric.Name):
246 + c.updateTopologyScalarMetric(metric)
247 + }
248 + }
249 +}
250 +
251 +// collectTopologyMetrics reads the aggregated topology from the global registry.
252 +func (c *Collector) collectTopologyMetrics(mx map[string]int64) {
253 + if !c.topologyChartsAdded {
254 + c.addTopologyCharts()
255 + c.topologyChartsAdded = true
256 + }
257 +
258 + data, ok := snmpTopologyRegistry.snapshot()
259 + if !ok {
260 + mx["snmp_topology_devices_total"] = 0
261 + mx["snmp_topology_devices_discovered"] = 0
262 + mx["snmp_topology_links_total"] = 0
263 + mx["snmp_topology_links_lldp"] = 0
264 + mx["snmp_topology_links_cdp"] = 0
265 + mx["snmp_topology_links_stp"] = 0
266 + return
267 + }
268 +
269 + totalDevices := 0
270 + for _, actor := range data.Actors {
271 + if topologyengine.IsDeviceActorType(actor.ActorType) {
272 + totalDevices++
273 + }
274 + }
275 +
276 + var lldpLinks, cdpLinks, stpLinks int64
277 + for _, link := range data.Links {
278 + switch link.Protocol {
279 + case "lldp":
280 + lldpLinks++
281 + case "cdp":
282 + cdpLinks++
283 + case "stp":
284 + stpLinks++
285 + }
286 + }
287 +
288 + mx["snmp_topology_devices_total"] = int64(totalDevices)
289 + mx["snmp_topology_devices_discovered"] = int64(maxInt(totalDevices-1, 0))
290 + mx["snmp_topology_links_total"] = int64(len(data.Links))
291 + mx["snmp_topology_links_lldp"] = lldpLinks
292 + mx["snmp_topology_links_cdp"] = cdpLinks
293 + mx["snmp_topology_links_stp"] = stpLinks
294 +}
295 +
296 +func newSNMPClientFromDeviceInfo(newClient func() gosnmp.Handler, dev ddsnmp.DeviceConnectionInfo) (gosnmp.Handler, error) {
297 + client := newClient()
298 +
299 + client.SetTarget(dev.Hostname)
300 + client.SetPort(uint16(dev.Port))
301 + client.SetRetries(dev.Retries)
302 + client.SetTimeout(time.Duration(dev.Timeout) * time.Second)
303 + client.SetMaxOids(dev.MaxOIDs)
304 + client.SetMaxRepetitions(uint32(dev.MaxRepetitions))
305 +
306 + ver := snmputils.ParseSNMPVersion(dev.SNMPVersion)
307 +
308 + switch ver {
309 + case gosnmp.Version1:
310 + client.SetCommunity(dev.Community)
311 + client.SetVersion(gosnmp.Version1)
312 + case gosnmp.Version2c:
313 + client.SetCommunity(dev.Community)
314 + client.SetVersion(gosnmp.Version2c)
315 + case gosnmp.Version3:
316 + if dev.V3User == "" {
317 + return nil, fmt.Errorf("username is required for SNMPv3")
318 + }
319 + client.SetVersion(gosnmp.Version3)
320 + client.SetSecurityModel(gosnmp.UserSecurityModel)
321 + client.SetMsgFlags(snmputils.ParseSNMPv3SecurityLevel(dev.V3SecurityLevel))
322 + client.SetSecurityParameters(&gosnmp.UsmSecurityParameters{
323 + UserName: dev.V3User,
324 + AuthenticationProtocol: snmputils.ParseSNMPv3AuthProtocol(dev.V3AuthProto),
325 + AuthenticationPassphrase: dev.V3AuthKey,
326 + PrivacyProtocol: snmputils.ParseSNMPv3PrivProtocol(dev.V3PrivProto),
327 + PrivacyPassphrase: dev.V3PrivKey,
328 + })
329 + client.SetContextName(dev.V3ContextName)
330 + default:
331 + return nil, fmt.Errorf("invalid SNMP version: %s", dev.SNMPVersion)
332 + }
333 +
334 + return client, nil
335 +}
336 +
337 +func topologyMethods() []funcapi.MethodConfig {
338 + return []funcapi.MethodConfig{
339 + topologyMethodConfig(),
340 + }
341 +}
342 +
343 +func topologyFunctionHandler(job collectorapi.RuntimeJob) funcapi.MethodHandler {
344 + return &funcTopology{}
345 +}
src/go/plugin/go.d/collector/snmp_topology/config.go new
+12
@@ -0,0 +1,12 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "github.com/netdata/netdata/go/plugins/pkg/confopt"
6 +
7 +// Config for the snmp_topology module.
8 +// This module has a single global job — device list comes from the SNMP device registry.
9 +type Config struct {
10 + UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"`
11 + RefreshEvery confopt.LongDuration `yaml:"refresh_every,omitempty" json:"refresh_every,omitempty"`
12 +}
src/go/plugin/go.d/collector/snmp_topology/config_schema.json new
+21
@@ -0,0 +1,21 @@
1 +{
2 + "jsonSchema": {
3 + "type": "object",
4 + "properties": {
5 + "update_every": {
6 + "title": "Check interval",
7 + "description": "How often to check for new or stale devices, in seconds.",
8 + "type": "integer",
9 + "minimum": 10,
10 + "default": 60
11 + },
12 + "refresh_every": {
13 + "title": "Refresh interval",
14 + "description": "How often to refresh topology data for each device.",
15 + "type": "string",
16 + "pattern": "^[0-9]+(\\.[0-9]+)?(ms|s|m|h|d|w|mo|y)?$",
17 + "default": "30m"
18 + }
19 + }
20 + }
21 +}
src/go/plugin/go.d/collector/snmp_topology/func_topology.go new
+38
@@ -0,0 +1,38 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "github.com/netdata/netdata/go/plugins/pkg/funcapi"
6 +
7 +// Compile-time interface check.
8 +var _ funcapi.MethodHandler = (*funcTopology)(nil)
9 +
10 +type funcTopology struct{}
11 +
12 +const topologyMethodID = "topology:snmp"
13 +
14 +const (
15 + topologyParamNodesIdentity = "nodes_identity"
16 + topologyParamMapType = "map_type"
17 + topologyParamInferenceStrategy = "inference_strategy"
18 + topologyParamManagedDeviceFocus = "managed_snmp_device_focus"
19 + topologyParamDepth = "depth"
20 +
21 + topologyNodesIdentityIP = "ip"
22 + topologyNodesIdentityMAC = "mac"
23 +
24 + topologyMapTypeLLDPCDPManaged = "lldp_cdp_managed"
25 + topologyMapTypeHighConfidenceInferred = "high_confidence_inferred"
26 + topologyMapTypeAllDevicesLowConfidence = "all_devices_low_confidence"
27 + topologyInferenceStrategyFDBMinimumKnowledge = "fdb_minimum_knowledge"
28 + topologyInferenceStrategySTPParentTree = "stp_parent_tree"
29 + topologyInferenceStrategyFDBPairwise = "fdb_pairwise_minimum_knowledge"
30 + topologyInferenceStrategySTPFDBCorrelated = "stp_fdb_correlated"
31 + topologyInferenceStrategyCDPFDBHybrid = "cdp_fdb_hybrid"
32 + topologyManagedFocusAllDevices = "all_devices"
33 + topologyManagedFocusIPPrefix = "ip:"
34 + topologyDepthAll = "all"
35 + topologyDepthMin = 0
36 + topologyDepthMax = 10
37 + topologyDepthAllInternal = -1
38 +)
src/go/plugin/go.d/collector/snmp_topology/func_topology_depth.go new
+35
@@ -0,0 +1,35 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 +)
9 +
10 +func normalizeTopologyDepth(v string) int {
11 + value := strings.ToLower(strings.TrimSpace(v))
12 + if value == "" || value == topologyDepthAll {
13 + return topologyDepthAllInternal
14 + }
15 + depth, err := strconv.Atoi(value)
16 + if err != nil {
17 + return topologyDepthAllInternal
18 + }
19 + if depth < topologyDepthMin {
20 + return topologyDepthMin
21 + }
22 + if depth > topologyDepthMax {
23 + return topologyDepthMax
24 + }
25 + return depth
26 +}
27 +
28 +func isTopologyMapTypeProbable(v string) bool {
29 + switch strings.ToLower(strings.TrimSpace(v)) {
30 + case "", topologyMapTypeAllDevicesLowConfidence:
31 + return true
32 + default:
33 + return false
34 + }
35 +}
src/go/plugin/go.d/collector/snmp_topology/func_topology_handler.go new
+96
@@ -0,0 +1,96 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "context"
7 + "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/funcapi"
10 +)
11 +
12 +func (f *funcTopology) MethodParams(_ context.Context, method string) ([]funcapi.ParamConfig, error) {
13 + if method != topologyMethodID {
14 + return nil, nil
15 + }
16 +
17 + return []funcapi.ParamConfig{
18 + topologyNodesIdentityParamConfig(),
19 + topologyMapTypeParamConfig(),
20 + topologyInferenceStrategyParamConfig(),
21 + topologyManagedFocusParamConfig(topologyManagedFocusParamOptions()),
22 + topologyDepthParamConfig(),
23 + }, nil
24 +}
25 +
26 +func (f *funcTopology) Cleanup(_ context.Context) {}
27 +
28 +func (f *funcTopology) Handle(_ context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
29 + if method != topologyMethodID {
30 + return funcapi.NotFoundResponse(method)
31 + }
32 +
33 + if snmpTopologyRegistry == nil {
34 + return funcapi.UnavailableResponse("topology data not available yet, please retry after topology refresh")
35 + }
36 +
37 + options := resolveTopologyQueryOptions(params)
38 + options.ResolveDNSName = resolveTopologyReverseDNSNameCached // never block on network I/O
39 + data, ok := snmpTopologyRegistry.snapshotWithOptions(options)
40 + if !ok {
41 + return funcapi.UnavailableResponse("topology data not available yet, please retry after topology refresh")
42 + }
43 +
44 + return &funcapi.FunctionResponse{
45 + Status: 200,
46 + Help: "SNMP topology and neighbor discovery data",
47 + ResponseType: "topology",
48 + Data: data,
49 + }
50 +}
51 +
52 +func topologyManagedFocusParamOptions() []funcapi.ParamOption {
53 + if snmpTopologyRegistry == nil {
54 + return nil
55 + }
56 +
57 + options := make([]funcapi.ParamOption, 0)
58 + for _, target := range snmpTopologyRegistry.managedDeviceFocusTargets() {
59 + if strings.TrimSpace(target.Value) == "" {
60 + continue
61 + }
62 + options = append(options, funcapi.ParamOption{
63 + ID: target.Value,
64 + Name: target.Name,
65 + })
66 + }
67 + return options
68 +}
69 +
70 +func resolveTopologyQueryOptions(params funcapi.ResolvedParams) topologyQueryOptions {
71 + options := topologyQueryOptions{
72 + CollapseActorsByIP: true,
73 + EliminateNonIPInferred: true,
74 + MapType: topologyMapTypeLLDPCDPManaged,
75 + InferenceStrategy: topologyInferenceStrategyFDBMinimumKnowledge,
76 + ManagedDeviceFocus: topologyManagedFocusAllDevices,
77 + Depth: topologyDepthAllInternal,
78 + }
79 +
80 + if identity := normalizeTopologyNodesIdentity(params.GetOne(topologyParamNodesIdentity)); identity == topologyNodesIdentityMAC {
81 + options.CollapseActorsByIP = false
82 + options.EliminateNonIPInferred = false
83 + }
84 + if mapType := normalizeTopologyMapType(params.GetOne(topologyParamMapType)); mapType != "" {
85 + options.MapType = mapType
86 + }
87 + if strategy := normalizeTopologyInferenceStrategy(params.GetOne(topologyParamInferenceStrategy)); strategy != "" {
88 + options.InferenceStrategy = strategy
89 + }
90 + if focuses := normalizeTopologyManagedFocuses(params.Get(topologyParamManagedDeviceFocus)); len(focuses) > 0 {
91 + options.ManagedDeviceFocus = formatTopologyManagedFocuses(focuses)
92 + }
93 + options.Depth = normalizeTopologyDepth(params.GetOne(topologyParamDepth))
94 +
95 + return options
96 +}
src/go/plugin/go.d/collector/snmp_topology/func_topology_managed_focus.go new
+101
@@ -0,0 +1,101 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func normalizeTopologyManagedFocus(v string) string {
11 + value := strings.TrimSpace(v)
12 + if value == "" {
13 + return topologyManagedFocusAllDevices
14 + }
15 + return normalizeTopologyManagedFocusValue(value)
16 +}
17 +
18 +func normalizeTopologyManagedFocusValue(v string) string {
19 + value := strings.TrimSpace(v)
20 + switch strings.ToLower(value) {
21 + case topologyManagedFocusAllDevices:
22 + return topologyManagedFocusAllDevices
23 + }
24 + if len(value) > len(topologyManagedFocusIPPrefix) &&
25 + strings.EqualFold(value[:len(topologyManagedFocusIPPrefix)], topologyManagedFocusIPPrefix) {
26 + ip := normalizeIPAddress(strings.TrimSpace(value[len(topologyManagedFocusIPPrefix):]))
27 + if ip == "" {
28 + return ""
29 + }
30 + return topologyManagedFocusIPPrefix + ip
31 + }
32 + return ""
33 +}
34 +
35 +func normalizeTopologyManagedFocuses(values []string) []string {
36 + expanded := splitTopologyManagedFocusValues(values)
37 + if len(expanded) == 0 {
38 + return []string{topologyManagedFocusAllDevices}
39 + }
40 +
41 + seen := make(map[string]struct{}, len(expanded))
42 + out := make([]string, 0, len(expanded))
43 + for _, raw := range expanded {
44 + normalized := normalizeTopologyManagedFocusValue(raw)
45 + if normalized == "" {
46 + continue
47 + }
48 + if normalized == topologyManagedFocusAllDevices {
49 + return []string{topologyManagedFocusAllDevices}
50 + }
51 + if _, ok := seen[normalized]; ok {
52 + continue
53 + }
54 + seen[normalized] = struct{}{}
55 + out = append(out, normalized)
56 + }
57 +
58 + if len(out) == 0 {
59 + return []string{topologyManagedFocusAllDevices}
60 + }
61 + sort.Strings(out)
62 + return out
63 +}
64 +
65 +func splitTopologyManagedFocusValues(values []string) []string {
66 + if len(values) == 0 {
67 + return nil
68 + }
69 +
70 + out := make([]string, 0, len(values))
71 + for _, raw := range values {
72 + for token := range strings.SplitSeq(raw, ",") {
73 + token = strings.TrimSpace(token)
74 + if token == "" {
75 + continue
76 + }
77 + out = append(out, token)
78 + }
79 + }
80 + return out
81 +}
82 +
83 +func parseTopologyManagedFocuses(value string) []string {
84 + if strings.TrimSpace(value) == "" {
85 + return []string{topologyManagedFocusAllDevices}
86 + }
87 + return normalizeTopologyManagedFocuses(strings.Split(value, ","))
88 +}
89 +
90 +func formatTopologyManagedFocuses(values []string) string {
91 + normalized := normalizeTopologyManagedFocuses(values)
92 + if len(normalized) == 0 {
93 + return topologyManagedFocusAllDevices
94 + }
95 + return strings.Join(normalized, ",")
96 +}
97 +
98 +func isTopologyManagedFocusAllDevices(value string) bool {
99 + normalized := parseTopologyManagedFocuses(value)
100 + return len(normalized) == 1 && normalized[0] == topologyManagedFocusAllDevices
101 +}
src/go/plugin/go.d/collector/snmp_topology/func_topology_options.go new
+46
@@ -0,0 +1,46 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +func normalizeTopologyNodesIdentity(v string) string {
8 + switch strings.ToLower(strings.TrimSpace(v)) {
9 + case "", topologyNodesIdentityIP:
10 + return topologyNodesIdentityIP
11 + case topologyNodesIdentityMAC:
12 + return topologyNodesIdentityMAC
13 + default:
14 + return ""
15 + }
16 +}
17 +
18 +func normalizeTopologyMapType(v string) string {
19 + switch strings.ToLower(strings.TrimSpace(v)) {
20 + case "", topologyMapTypeLLDPCDPManaged:
21 + return topologyMapTypeLLDPCDPManaged
22 + case topologyMapTypeHighConfidenceInferred:
23 + return topologyMapTypeHighConfidenceInferred
24 + case topologyMapTypeAllDevicesLowConfidence:
25 + return topologyMapTypeAllDevicesLowConfidence
26 + default:
27 + return ""
28 + }
29 +}
30 +
31 +func normalizeTopologyInferenceStrategy(v string) string {
32 + switch strings.ToLower(strings.TrimSpace(v)) {
33 + case "", topologyInferenceStrategyFDBMinimumKnowledge:
34 + return topologyInferenceStrategyFDBMinimumKnowledge
35 + case topologyInferenceStrategySTPParentTree:
36 + return topologyInferenceStrategySTPParentTree
37 + case topologyInferenceStrategyFDBPairwise:
38 + return topologyInferenceStrategyFDBPairwise
39 + case topologyInferenceStrategySTPFDBCorrelated:
40 + return topologyInferenceStrategySTPFDBCorrelated
41 + case topologyInferenceStrategyCDPFDBHybrid:
42 + return topologyInferenceStrategyCDPFDBHybrid
43 + default:
44 + return ""
45 + }
46 +}
src/go/plugin/go.d/collector/snmp_topology/func_topology_presentation.go new
+51
@@ -0,0 +1,51 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "github.com/netdata/netdata/go/plugins/pkg/funcapi"
7 + "github.com/netdata/netdata/go/plugins/pkg/topology"
8 +)
9 +
10 +func snmpTopologyPresentation() *topology.Presentation {
11 + deviceSummaryFields := topologyDeviceSummaryFields()
12 + deviceTables := topologyDeviceTables()
13 + linkOnlyTables := topologyLinkOnlyTables(deviceTables["links"])
14 + infoOnlyTabs := topologyInfoOnlyTabs()
15 +
16 + return &topology.Presentation{
17 + ActorTypes: topologyPresentationActorTypes(
18 + deviceSummaryFields,
19 + deviceTables,
20 + linkOnlyTables,
21 + infoOnlyTabs,
22 + topologySegmentSummaryFields(),
23 + topologyEndpointSummaryFields(),
24 + ),
25 + LinkTypes: topologyPresentationLinkTypes(),
26 + PortFields: topologyPresentationPortFields(),
27 + PortTypes: topologyPresentationPortTypes(),
28 + Legend: topologyPresentationLegend(),
29 + ActorClickBehavior: "highlight_connections",
30 + }
31 +}
32 +
33 +func topologyMethodConfig() funcapi.MethodConfig {
34 + return funcapi.MethodConfig{
35 + ID: topologyMethodID,
36 + Aliases: []string{topologyMethodID},
37 + Name: "Topology (SNMP)",
38 + UpdateEvery: 10,
39 + Help: "SNMP Layer-2 topology and neighbor discovery data",
40 + RequireCloud: true,
41 + ResponseType: "topology",
42 + AgentWide: true,
43 + RequiredParams: []funcapi.ParamConfig{
44 + topologyNodesIdentityParamConfig(),
45 + topologyMapTypeParamConfig(),
46 + topologyInferenceStrategyParamConfig(),
47 + topologyManagedFocusParamConfig(nil),
48 + topologyDepthParamConfig(),
49 + },
50 + }.WithPresentation(snmpTopologyPresentation())
51 +}
src/go/plugin/go.d/collector/snmp_topology/func_topology_presentation_params.go new
+117
@@ -0,0 +1,117 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strconv"
7 +
8 + "github.com/netdata/netdata/go/plugins/pkg/funcapi"
9 +)
10 +
11 +func topologyNodesIdentityParamConfig() funcapi.ParamConfig {
12 + return funcapi.ParamConfig{
13 + ID: topologyParamNodesIdentity,
14 + Name: "Nodes Identity",
15 + Help: "Choose actor identity strategy: ip (collapse by IP, remove non-IP inferred) or mac",
16 + Selection: funcapi.ParamSelect,
17 + Options: []funcapi.ParamOption{
18 + {ID: topologyNodesIdentityIP, Name: "IP", Default: true},
19 + {ID: topologyNodesIdentityMAC, Name: "MAC"},
20 + },
21 + }
22 +}
23 +
24 +func topologyMapTypeParamConfig() funcapi.ParamConfig {
25 + return funcapi.ParamConfig{
26 + ID: topologyParamMapType,
27 + Name: "Map",
28 + Help: "Choose topology map mode",
29 + Selection: funcapi.ParamSelect,
30 + Options: []funcapi.ParamOption{
31 + {
32 + ID: topologyMapTypeLLDPCDPManaged,
33 + Name: "LLDP/CDP/Managed Devices Map",
34 + Default: true,
35 + },
36 + {ID: topologyMapTypeHighConfidenceInferred, Name: "High Confidence Inferred Map"},
37 + {
38 + ID: topologyMapTypeAllDevicesLowConfidence,
39 + Name: "All Devices (Low Confidence)",
40 + },
41 + },
42 + }
43 +}
44 +
45 +func topologyInferenceStrategyParamConfig() funcapi.ParamConfig {
46 + return funcapi.ParamConfig{
47 + ID: topologyParamInferenceStrategy,
48 + Name: "Infer Strategy",
49 + Help: "Choose the topology inference strategy",
50 + Selection: funcapi.ParamSelect,
51 + Options: []funcapi.ParamOption{
52 + {
53 + ID: topologyInferenceStrategyFDBMinimumKnowledge,
54 + Name: "FDB Minimum-Knowledge (Baseline)",
55 + Default: true,
56 + },
57 + {
58 + ID: topologyInferenceStrategySTPParentTree,
59 + Name: "STP Parent Tree",
60 + },
61 + {
62 + ID: topologyInferenceStrategyFDBPairwise,
63 + Name: "FDB Pairwise Minimum-Knowledge",
64 + },
65 + {
66 + ID: topologyInferenceStrategySTPFDBCorrelated,
67 + Name: "STP + FDB Correlated",
68 + },
69 + {
70 + ID: topologyInferenceStrategyCDPFDBHybrid,
71 + Name: "CDP + FDB Hybrid",
72 + },
73 + },
74 + }
75 +}
76 +
77 +func topologyManagedFocusParamConfig(extraOptions []funcapi.ParamOption) funcapi.ParamConfig {
78 + options := make([]funcapi.ParamOption, 0, 1+len(extraOptions))
79 + options = append(options, funcapi.ParamOption{
80 + ID: topologyManagedFocusAllDevices,
81 + Name: "All Devices",
82 + Default: true,
83 + })
84 + options = append(options, extraOptions...)
85 +
86 + return funcapi.ParamConfig{
87 + ID: topologyParamManagedDeviceFocus,
88 + Name: "Focus On",
89 + Help: "Choose focus root set for depth filtering",
90 + Selection: funcapi.ParamMultiSelect,
91 + Options: options,
92 + }
93 +}
94 +
95 +func topologyDepthParamConfig() funcapi.ParamConfig {
96 + options := make([]funcapi.ParamOption, 0, 1+(topologyDepthMax-topologyDepthMin+1))
97 + options = append(options, funcapi.ParamOption{
98 + ID: topologyDepthAll,
99 + Name: "All",
100 + Default: true,
101 + })
102 + for depth := topologyDepthMin; depth <= topologyDepthMax; depth++ {
103 + value := strconv.Itoa(depth)
104 + options = append(options, funcapi.ParamOption{
105 + ID: value,
106 + Name: value,
107 + })
108 + }
109 +
110 + return funcapi.ParamConfig{
111 + ID: topologyParamDepth,
112 + Name: "Focus Depth",
113 + Help: "Limit topology expansion hops from focus roots",
114 + Selection: funcapi.ParamSelect,
115 + Options: options,
116 + }
117 +}
src/go/plugin/go.d/collector/snmp_topology/func_topology_presentation_schema.go new
+96
@@ -0,0 +1,96 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "github.com/netdata/netdata/go/plugins/pkg/topology"
6 +
7 +func topologyDeviceSummaryFields() []topology.PresentationSummaryField {
8 + return []topology.PresentationSummaryField{
9 + {Key: "actor_type", Label: "Type", Sources: []string{"actor_type"}},
10 + {Key: "vendor", Label: "Vendor", Sources: []string{"attributes.vendor", "attributes.vendor_derived"}},
11 + {Key: "model", Label: "Model", Sources: []string{"attributes.model"}},
12 + {Key: "sys_descr", Label: "Description", Sources: []string{"attributes.sys_descr", "match.sys_name"}},
13 + {Key: "sys_location", Label: "Location", Sources: []string{"attributes.sys_location"}},
14 + {Key: "sys_contact", Label: "Contact", Sources: []string{"attributes.sys_contact"}},
15 + {Key: "protocols", Label: "Protocols", Sources: []string{"attributes.protocols", "attributes.learned_sources"}},
16 + {Key: "capabilities", Label: "Capabilities", Sources: []string{"attributes.capabilities"}},
17 + {Key: "ports_total", Label: "Ports", Sources: []string{"attributes.ports_total"}},
18 + {Key: "vlan_count", Label: "VLANs", Sources: []string{"attributes.vlan_count"}},
19 + {Key: "fdb_total_macs", Label: "FDB MACs", Sources: []string{"attributes.fdb_total_macs"}},
20 + {Key: "lldp_neighbor_count", Label: "LLDP Neighbors", Sources: []string{"attributes.lldp_neighbor_count"}},
21 + {Key: "cdp_neighbor_count", Label: "CDP Neighbors", Sources: []string{"attributes.cdp_neighbor_count"}},
22 + {Key: "chart_id_prefix", Label: "Chart Prefix", Sources: []string{"attributes.chart_id_prefix"}},
23 + {Key: "netdata_host_id", Label: "Netdata Host", Sources: []string{"attributes.netdata_host_id"}},
24 + {Key: "source", Label: "Source", Sources: []string{"source"}},
25 + {Key: "layer", Label: "Layer", Sources: []string{"layer"}},
26 + }
27 +}
28 +
29 +func topologyDeviceTables() map[string]topology.PresentationTable {
30 + return map[string]topology.PresentationTable{
31 + "ports": {
32 + Label: "Ports",
33 + Source: "data",
34 + BulletSource: true,
35 + Order: 1,
36 + Columns: []topology.PresentationTableColumn{
37 + {Key: "name", Label: "Port"},
38 + {Key: "oper_status", Label: "Status", Type: "badge"},
39 + {Key: "admin_status", Label: "Admin"},
40 + {Key: "port_type", Label: "Type", Type: "badge"},
41 + {Key: "link_mode", Label: "Mode", Type: "badge"},
42 + {Key: "topology_role", Label: "Role", Type: "badge"},
43 + {Key: "stp_state", Label: "STP", Type: "badge"},
44 + {Key: "vlan_ids", Label: "VLANs", Type: "count"},
45 + {Key: "fdb_mac_count", Label: "FDB", Type: "number"},
46 + {Key: "link_count", Label: "Links", Type: "number"},
47 + {Key: "neighbor_count", Label: "Neighbors", Type: "number"},
48 + },
49 + },
50 + "links": {
51 + Label: "Links",
52 + Source: "links",
53 + Order: 2,
54 + Columns: []topology.PresentationTableColumn{
55 + {Key: "localPort", Label: "Local Port"},
56 + {Key: "remoteLabel", Label: "Remote Actor", Type: "actor_link"},
57 + {Key: "remotePort", Label: "Remote Port"},
58 + {Key: "protocol", Label: "Protocol"},
59 + {Key: "direction", Label: "Direction"},
60 + },
61 + },
62 + }
63 +}
64 +
65 +func topologySegmentSummaryFields() []topology.PresentationSummaryField {
66 + return []topology.PresentationSummaryField{
67 + {Key: "actor_type", Label: "Type", Sources: []string{"actor_type"}},
68 + {Key: "learned_sources", Label: "Discovered By", Sources: []string{"attributes.learned_sources"}},
69 + {Key: "ports_total", Label: "Ports", Sources: []string{"attributes.ports_total"}},
70 + {Key: "endpoints_total", Label: "Endpoints", Sources: []string{"attributes.endpoints_total"}},
71 + {Key: "source", Label: "Source", Sources: []string{"source"}},
72 + {Key: "layer", Label: "Layer", Sources: []string{"layer"}},
73 + }
74 +}
75 +
76 +func topologyEndpointSummaryFields() []topology.PresentationSummaryField {
77 + return []topology.PresentationSummaryField{
78 + {Key: "actor_type", Label: "Type", Sources: []string{"actor_type"}},
79 + {Key: "vendor", Label: "Vendor", Sources: []string{"attributes.vendor", "attributes.vendor_derived"}},
80 + {Key: "learned_sources", Label: "Discovered By", Sources: []string{"attributes.learned_sources"}},
81 + {Key: "source", Label: "Source", Sources: []string{"source"}},
82 + {Key: "layer", Label: "Layer", Sources: []string{"layer"}},
83 + }
84 +}
85 +
86 +func topologyInfoOnlyTabs() []topology.PresentationModalTab {
87 + return []topology.PresentationModalTab{
88 + {ID: "info", Label: "Info"},
89 + }
90 +}
91 +
92 +func topologyLinkOnlyTables(linkTable topology.PresentationTable) map[string]topology.PresentationTable {
93 + return map[string]topology.PresentationTable{
94 + "links": linkTable,
95 + }
96 +}
src/go/plugin/go.d/collector/snmp_topology/func_topology_presentation_test.go new
+208
@@ -0,0 +1,208 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/netdata/netdata/go/plugins/pkg/topology"
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestSNMPTopologyPresentationMatchesExpectedContract(t *testing.T) {
13 + require.Equal(t, expectedSNMPTopologyPresentation(), snmpTopologyPresentation())
14 +}
15 +
16 +func expectedSNMPTopologyPresentation() *topology.Presentation {
17 + deviceSummaryFields := []topology.PresentationSummaryField{
18 + {Key: "actor_type", Label: "Type", Sources: []string{"actor_type"}},
19 + {Key: "vendor", Label: "Vendor", Sources: []string{"attributes.vendor", "attributes.vendor_derived"}},
20 + {Key: "model", Label: "Model", Sources: []string{"attributes.model"}},
21 + {Key: "sys_descr", Label: "Description", Sources: []string{"attributes.sys_descr", "match.sys_name"}},
22 + {Key: "sys_location", Label: "Location", Sources: []string{"attributes.sys_location"}},
23 + {Key: "sys_contact", Label: "Contact", Sources: []string{"attributes.sys_contact"}},
24 + {Key: "protocols", Label: "Protocols", Sources: []string{"attributes.protocols", "attributes.learned_sources"}},
25 + {Key: "capabilities", Label: "Capabilities", Sources: []string{"attributes.capabilities"}},
26 + {Key: "ports_total", Label: "Ports", Sources: []string{"attributes.ports_total"}},
27 + {Key: "vlan_count", Label: "VLANs", Sources: []string{"attributes.vlan_count"}},
28 + {Key: "fdb_total_macs", Label: "FDB MACs", Sources: []string{"attributes.fdb_total_macs"}},
29 + {Key: "lldp_neighbor_count", Label: "LLDP Neighbors", Sources: []string{"attributes.lldp_neighbor_count"}},
30 + {Key: "cdp_neighbor_count", Label: "CDP Neighbors", Sources: []string{"attributes.cdp_neighbor_count"}},
31 + {Key: "chart_id_prefix", Label: "Chart Prefix", Sources: []string{"attributes.chart_id_prefix"}},
32 + {Key: "netdata_host_id", Label: "Netdata Host", Sources: []string{"attributes.netdata_host_id"}},
33 + {Key: "source", Label: "Source", Sources: []string{"source"}},
34 + {Key: "layer", Label: "Layer", Sources: []string{"layer"}},
35 + }
36 +
37 + deviceLinkTable := topology.PresentationTable{
38 + Label: "Links",
39 + Source: "links",
40 + Order: 2,
41 + Columns: []topology.PresentationTableColumn{
42 + {Key: "localPort", Label: "Local Port"},
43 + {Key: "remoteLabel", Label: "Remote Actor", Type: "actor_link"},
44 + {Key: "remotePort", Label: "Remote Port"},
45 + {Key: "protocol", Label: "Protocol"},
46 + {Key: "direction", Label: "Direction"},
47 + },
48 + }
49 +
50 + deviceTables := map[string]topology.PresentationTable{
51 + "ports": {
52 + Label: "Ports",
53 + Source: "data",
54 + BulletSource: true,
55 + Order: 1,
56 + Columns: []topology.PresentationTableColumn{
57 + {Key: "name", Label: "Port"},
58 + {Key: "oper_status", Label: "Status", Type: "badge"},
59 + {Key: "admin_status", Label: "Admin"},
60 + {Key: "port_type", Label: "Type", Type: "badge"},
61 + {Key: "link_mode", Label: "Mode", Type: "badge"},
62 + {Key: "topology_role", Label: "Role", Type: "badge"},
63 + {Key: "stp_state", Label: "STP", Type: "badge"},
64 + {Key: "vlan_ids", Label: "VLANs", Type: "count"},
65 + {Key: "fdb_mac_count", Label: "FDB", Type: "number"},
66 + {Key: "link_count", Label: "Links", Type: "number"},
67 + {Key: "neighbor_count", Label: "Neighbors", Type: "number"},
68 + },
69 + },
70 + "links": deviceLinkTable,
71 + }
72 +
73 + linkOnlyTables := map[string]topology.PresentationTable{
74 + "links": deviceLinkTable,
75 + }
76 +
77 + infoOnlyTabs := []topology.PresentationModalTab{
78 + {ID: "info", Label: "Info"},
79 + }
80 +
81 + segmentSummaryFields := []topology.PresentationSummaryField{
82 + {Key: "actor_type", Label: "Type", Sources: []string{"actor_type"}},
83 + {Key: "learned_sources", Label: "Discovered By", Sources: []string{"attributes.learned_sources"}},
84 + {Key: "ports_total", Label: "Ports", Sources: []string{"attributes.ports_total"}},
85 + {Key: "endpoints_total", Label: "Endpoints", Sources: []string{"attributes.endpoints_total"}},
86 + {Key: "source", Label: "Source", Sources: []string{"source"}},
87 + {Key: "layer", Label: "Layer", Sources: []string{"layer"}},
88 + }
89 +
90 + endpointSummaryFields := []topology.PresentationSummaryField{
91 + {Key: "actor_type", Label: "Type", Sources: []string{"actor_type"}},
92 + {Key: "vendor", Label: "Vendor", Sources: []string{"attributes.vendor", "attributes.vendor_derived"}},
93 + {Key: "learned_sources", Label: "Discovered By", Sources: []string{"attributes.learned_sources"}},
94 + {Key: "source", Label: "Source", Sources: []string{"source"}},
95 + {Key: "layer", Label: "Layer", Sources: []string{"layer"}},
96 + }
97 +
98 + deviceType := func(label, colorSlot string) topology.PresentationActorType {
99 + return topology.PresentationActorType{
100 + Label: label,
101 + ColorSlot: colorSlot,
102 + Border: true,
103 + Role: "actor",
104 + SizeByLinks: true,
105 + ShowPortBullets: true,
106 + SummaryFields: deviceSummaryFields,
107 + Tables: deviceTables,
108 + ModalTabs: infoOnlyTabs,
109 + }
110 + }
111 +
112 + return &topology.Presentation{
113 + ActorTypes: map[string]topology.PresentationActorType{
114 + "device": deviceType("Device", "primary"),
115 + "router": deviceType("Router", "primary"),
116 + "switch": deviceType("Switch", "primary"),
117 + "firewall": deviceType("Firewall", "warning"),
118 + "access_point": deviceType("Access Point", "info"),
119 + "server": deviceType("Server", "secondary"),
120 + "storage": deviceType("Storage", "secondary"),
121 + "load_balancer": deviceType("Load Balancer", "info"),
122 + "printer": deviceType("Printer", "neutral"),
123 + "phone": deviceType("Phone", "neutral"),
124 + "ups": deviceType("UPS", "structural"),
125 + "camera": deviceType("Camera", "neutral"),
126 + "segment": {
127 + Label: "Network segment",
128 + ColorSlot: "dim",
129 + SummaryFields: segmentSummaryFields,
130 + Tables: linkOnlyTables,
131 + ModalTabs: infoOnlyTabs,
132 + },
133 + "endpoint": {
134 + Label: "Inferred endpoint",
135 + ColorSlot: "derived",
136 + Border: true,
137 + Role: "endpoint",
138 + SummaryFields: endpointSummaryFields,
139 + Tables: linkOnlyTables,
140 + ModalTabs: infoOnlyTabs,
141 + },
142 + },
143 + LinkTypes: map[string]topology.PresentationLinkType{
144 + "lldp": {Label: "LLDP", ColorSlot: "accent", Width: 2},
145 + "cdp": {Label: "CDP", ColorSlot: "accent", Width: 2},
146 + "bridge": {Label: "Bridge", ColorSlot: "neutral"},
147 + "fdb": {Label: "FDB", ColorSlot: "neutral"},
148 + "stp": {Label: "STP", ColorSlot: "muted"},
149 + "arp": {Label: "ARP", ColorSlot: "muted"},
150 + "snmp": {Label: "SNMP", ColorSlot: "primary"},
151 + "probable": {Label: "Probable", ColorSlot: "dim"},
152 + },
153 + PortFields: []topology.PresentationPortField{
154 + {Key: "type", Label: "Type"},
155 + {Key: "role", Label: "Role"},
156 + {Key: "status", Label: "Status"},
157 + {Key: "mode", Label: "Mode"},
158 + {Key: "sources", Label: "Sources"},
159 + },
160 + PortTypes: map[string]topology.PresentationPortType{
161 + "lldp": {Label: "lldp/cdp", ColorSlot: "accent"},
162 + "switch_facing": {Label: "switch-facing", ColorSlot: "primary"},
163 + "host_facing": {Label: "host-facing", ColorSlot: "secondary"},
164 + "host_candidate": {Label: "host-candidate", ColorSlot: "info"},
165 + "trunk": {Label: "trunk", ColorSlot: "warning"},
166 + "access": {Label: "access", ColorSlot: "derived"},
167 + "topology": {Label: "unclassified", ColorSlot: "neutral"},
168 + "idle": {Label: "idle", ColorSlot: "muted"},
169 + "unknown": {Label: "unknown", ColorSlot: "dim"},
170 + },
171 + Legend: topology.PresentationLegend{
172 + Actors: []topology.PresentationLegendEntry{
173 + {Type: "router", Label: "Router"},
174 + {Type: "switch", Label: "Switch"},
175 + {Type: "firewall", Label: "Firewall"},
176 + {Type: "access_point", Label: "Access Point"},
177 + {Type: "server", Label: "Server"},
178 + {Type: "storage", Label: "Storage"},
179 + {Type: "load_balancer", Label: "Load Balancer"},
180 + {Type: "printer", Label: "Printer"},
181 + {Type: "phone", Label: "IP Phone"},
182 + {Type: "ups", Label: "UPS / PDU"},
183 + {Type: "camera", Label: "Camera / Media"},
184 + {Type: "device", Label: "Other device"},
185 + {Type: "endpoint", Label: "Inferred endpoint"},
186 + {Type: "segment", Label: "Network segment"},
187 + },
188 + Links: []topology.PresentationLegendEntry{
189 + {Type: "lldp", Label: "LLDP"},
190 + {Type: "cdp", Label: "CDP"},
191 + {Type: "snmp", Label: "SNMP"},
192 + {Type: "bridge", Label: "Bridge"},
193 + {Type: "probable", Label: "Probable"},
194 + },
195 + Ports: []topology.PresentationLegendEntry{
196 + {Type: "lldp", Label: "lldp/cdp"},
197 + {Type: "switch_facing", Label: "switch-facing"},
198 + {Type: "host_facing", Label: "host-facing"},
199 + {Type: "host_candidate", Label: "host-candidate"},
200 + {Type: "trunk", Label: "trunk"},
201 + {Type: "access", Label: "access"},
202 + {Type: "topology", Label: "unclassified"},
203 + {Type: "idle", Label: "idle"},
204 + },
205 + },
206 + ActorClickBehavior: "highlight_connections",
207 + }
208 +}
src/go/plugin/go.d/collector/snmp_topology/func_topology_presentation_types.go new
+134
@@ -0,0 +1,134 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "github.com/netdata/netdata/go/plugins/pkg/topology"
6 +
7 +func topologyPresentationActorTypes(
8 + deviceSummaryFields []topology.PresentationSummaryField,
9 + deviceTables map[string]topology.PresentationTable,
10 + linkOnlyTables map[string]topology.PresentationTable,
11 + infoOnlyTabs []topology.PresentationModalTab,
12 + segmentSummaryFields []topology.PresentationSummaryField,
13 + endpointSummaryFields []topology.PresentationSummaryField,
14 +) map[string]topology.PresentationActorType {
15 + deviceType := func(label, colorSlot string) topology.PresentationActorType {
16 + return topology.PresentationActorType{
17 + Label: label,
18 + ColorSlot: colorSlot,
19 + Border: true,
20 + Role: "actor",
21 + SizeByLinks: true,
22 + ShowPortBullets: true,
23 + SummaryFields: deviceSummaryFields,
24 + Tables: deviceTables,
25 + ModalTabs: infoOnlyTabs,
26 + }
27 + }
28 +
29 + return map[string]topology.PresentationActorType{
30 + "device": deviceType("Device", "primary"),
31 + "router": deviceType("Router", "primary"),
32 + "switch": deviceType("Switch", "primary"),
33 + "firewall": deviceType("Firewall", "warning"),
34 + "access_point": deviceType("Access Point", "info"),
35 + "server": deviceType("Server", "secondary"),
36 + "storage": deviceType("Storage", "secondary"),
37 + "load_balancer": deviceType("Load Balancer", "info"),
38 + "printer": deviceType("Printer", "neutral"),
39 + "phone": deviceType("Phone", "neutral"),
40 + "ups": deviceType("UPS", "structural"),
41 + "camera": deviceType("Camera", "neutral"),
42 + "segment": {
43 + Label: "Network segment",
44 + ColorSlot: "dim",
45 + SummaryFields: segmentSummaryFields,
46 + Tables: linkOnlyTables,
47 + ModalTabs: infoOnlyTabs,
48 + },
49 + "endpoint": {
50 + Label: "Inferred endpoint",
51 + ColorSlot: "derived",
52 + Border: true,
53 + Role: "endpoint",
54 + SummaryFields: endpointSummaryFields,
55 + Tables: linkOnlyTables,
56 + ModalTabs: infoOnlyTabs,
57 + },
58 + }
59 +}
60 +
61 +func topologyPresentationLinkTypes() map[string]topology.PresentationLinkType {
62 + return map[string]topology.PresentationLinkType{
63 + "lldp": {Label: "LLDP", ColorSlot: "accent", Width: 2},
64 + "cdp": {Label: "CDP", ColorSlot: "accent", Width: 2},
65 + "bridge": {Label: "Bridge", ColorSlot: "neutral"},
66 + "fdb": {Label: "FDB", ColorSlot: "neutral"},
67 + "stp": {Label: "STP", ColorSlot: "muted"},
68 + "arp": {Label: "ARP", ColorSlot: "muted"},
69 + "snmp": {Label: "SNMP", ColorSlot: "primary"},
70 + "probable": {Label: "Probable", ColorSlot: "dim"},
71 + }
72 +}
73 +
74 +func topologyPresentationPortFields() []topology.PresentationPortField {
75 + return []topology.PresentationPortField{
76 + {Key: "type", Label: "Type"},
77 + {Key: "role", Label: "Role"},
78 + {Key: "status", Label: "Status"},
79 + {Key: "mode", Label: "Mode"},
80 + {Key: "sources", Label: "Sources"},
81 + }
82 +}
83 +
84 +func topologyPresentationPortTypes() map[string]topology.PresentationPortType {
85 + return map[string]topology.PresentationPortType{
86 + "lldp": {Label: "lldp/cdp", ColorSlot: "accent"},
87 + "switch_facing": {Label: "switch-facing", ColorSlot: "primary"},
88 + "host_facing": {Label: "host-facing", ColorSlot: "secondary"},
89 + "host_candidate": {Label: "host-candidate", ColorSlot: "info"},
90 + "trunk": {Label: "trunk", ColorSlot: "warning"},
91 + "access": {Label: "access", ColorSlot: "derived"},
92 + "topology": {Label: "unclassified", ColorSlot: "neutral"},
93 + "idle": {Label: "idle", ColorSlot: "muted"},
94 + "unknown": {Label: "unknown", ColorSlot: "dim"},
95 + }
96 +}
97 +
98 +func topologyPresentationLegend() topology.PresentationLegend {
99 + return topology.PresentationLegend{
100 + Actors: []topology.PresentationLegendEntry{
101 + {Type: "router", Label: "Router"},
102 + {Type: "switch", Label: "Switch"},
103 + {Type: "firewall", Label: "Firewall"},
104 + {Type: "access_point", Label: "Access Point"},
105 + {Type: "server", Label: "Server"},
106 + {Type: "storage", Label: "Storage"},
107 + {Type: "load_balancer", Label: "Load Balancer"},
108 + {Type: "printer", Label: "Printer"},
109 + {Type: "phone", Label: "IP Phone"},
110 + {Type: "ups", Label: "UPS / PDU"},
111 + {Type: "camera", Label: "Camera / Media"},
112 + {Type: "device", Label: "Other device"},
113 + {Type: "endpoint", Label: "Inferred endpoint"},
114 + {Type: "segment", Label: "Network segment"},
115 + },
116 + Links: []topology.PresentationLegendEntry{
117 + {Type: "lldp", Label: "LLDP"},
118 + {Type: "cdp", Label: "CDP"},
119 + {Type: "snmp", Label: "SNMP"},
120 + {Type: "bridge", Label: "Bridge"},
121 + {Type: "probable", Label: "Probable"},
122 + },
123 + Ports: []topology.PresentationLegendEntry{
124 + {Type: "lldp", Label: "lldp/cdp"},
125 + {Type: "switch_facing", Label: "switch-facing"},
126 + {Type: "host_facing", Label: "host-facing"},
127 + {Type: "host_candidate", Label: "host-candidate"},
128 + {Type: "trunk", Label: "trunk"},
129 + {Type: "access", Label: "access"},
130 + {Type: "topology", Label: "unclassified"},
131 + {Type: "idle", Label: "idle"},
132 + },
133 + }
134 +}
src/go/plugin/go.d/collector/snmp_topology/func_topology_test.go new
+324
@@ -0,0 +1,324 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "context"
7 + "testing"
8 + "time"
9 +
10 + "github.com/netdata/netdata/go/plugins/pkg/funcapi"
11 + "github.com/stretchr/testify/assert"
12 + "github.com/stretchr/testify/require"
13 +)
14 +
15 +func TestTopologyMethodConfigIncludesSelectors(t *testing.T) {
16 + cfg := topologyMethodConfig()
17 + assert.True(t, cfg.AgentWide)
18 + require.Len(t, cfg.RequiredParams, 5)
19 +
20 + identity := cfg.RequiredParams[0]
21 + assert.Equal(t, topologyParamNodesIdentity, identity.ID)
22 + assert.Equal(t, funcapi.ParamSelect, identity.Selection)
23 + require.Len(t, identity.Options, 2)
24 + assert.Equal(t, topologyNodesIdentityIP, identity.Options[0].ID)
25 + assert.True(t, identity.Options[0].Default)
26 + assert.Equal(t, topologyNodesIdentityMAC, identity.Options[1].ID)
27 +
28 + mapType := cfg.RequiredParams[1]
29 + assert.Equal(t, topologyParamMapType, mapType.ID)
30 + assert.Equal(t, "Map", mapType.Name)
31 + assert.Equal(t, funcapi.ParamSelect, mapType.Selection)
32 + require.Len(t, mapType.Options, 3)
33 + assert.Equal(t, topologyMapTypeLLDPCDPManaged, mapType.Options[0].ID)
34 + assert.True(t, mapType.Options[0].Default)
35 + assert.Equal(t, topologyMapTypeHighConfidenceInferred, mapType.Options[1].ID)
36 + assert.Equal(t, topologyMapTypeAllDevicesLowConfidence, mapType.Options[2].ID)
37 +
38 + strategy := cfg.RequiredParams[2]
39 + assert.Equal(t, topologyParamInferenceStrategy, strategy.ID)
40 + assert.Equal(t, "Infer Strategy", strategy.Name)
41 + assert.Equal(t, funcapi.ParamSelect, strategy.Selection)
42 + require.Len(t, strategy.Options, 5)
43 + assert.Equal(t, topologyInferenceStrategyFDBMinimumKnowledge, strategy.Options[0].ID)
44 + assert.True(t, strategy.Options[0].Default)
45 + assert.Equal(t, topologyInferenceStrategySTPParentTree, strategy.Options[1].ID)
46 + assert.Equal(t, topologyInferenceStrategyFDBPairwise, strategy.Options[2].ID)
47 + assert.Equal(t, topologyInferenceStrategySTPFDBCorrelated, strategy.Options[3].ID)
48 + assert.Equal(t, topologyInferenceStrategyCDPFDBHybrid, strategy.Options[4].ID)
49 +
50 + managedFocus := cfg.RequiredParams[3]
51 + assert.Equal(t, topologyParamManagedDeviceFocus, managedFocus.ID)
52 + assert.Equal(t, "Focus On", managedFocus.Name)
53 + assert.Equal(t, funcapi.ParamMultiSelect, managedFocus.Selection)
54 + require.Len(t, managedFocus.Options, 1)
55 + assert.Equal(t, topologyManagedFocusAllDevices, managedFocus.Options[0].ID)
56 + assert.True(t, managedFocus.Options[0].Default)
57 +
58 + depth := cfg.RequiredParams[4]
59 + assert.Equal(t, topologyParamDepth, depth.ID)
60 + assert.Equal(t, "Focus Depth", depth.Name)
61 + assert.Equal(t, funcapi.ParamSelect, depth.Selection)
62 + require.NotEmpty(t, depth.Options)
63 + assert.Equal(t, topologyDepthAll, depth.Options[0].ID)
64 + assert.True(t, depth.Options[0].Default)
65 +}
66 +
67 +func TestFuncTopology_MethodParams(t *testing.T) {
68 + prev := snmpTopologyRegistry
69 + t.Cleanup(func() {
70 + snmpTopologyRegistry = prev
71 + })
72 +
73 + registry := newTopologyRegistry()
74 + snmpTopologyRegistry = registry
75 + registry.register(newTestTopologyCacheLLDP(
76 + "agent-test",
77 + time.Now().UTC(),
78 + "00:11:22:33:44:55",
79 + "sw-a",
80 + "10.0.0.1",
81 + "Gi0/1",
82 + "aa:bb:cc:dd:ee:ff",
83 + "sw-b",
84 + "10.0.0.2",
85 + "Gi0/2",
86 + ))
87 +
88 + f := &funcTopology{}
89 +
90 + params, err := f.MethodParams(context.Background(), topologyMethodID)
91 + require.NoError(t, err)
92 + require.Len(t, params, 5)
93 + assert.Equal(t, topologyParamNodesIdentity, params[0].ID)
94 + assert.Equal(t, topologyParamMapType, params[1].ID)
95 + assert.Equal(t, topologyParamInferenceStrategy, params[2].ID)
96 + assert.Equal(t, topologyParamManagedDeviceFocus, params[3].ID)
97 + assert.Equal(t, topologyParamDepth, params[4].ID)
98 + require.GreaterOrEqual(t, len(params[3].Options), 2)
99 + assert.Equal(t, topologyManagedFocusAllDevices, params[3].Options[0].ID)
100 + assert.Equal(t, "ip:10.0.0.1", params[3].Options[1].ID)
101 +
102 + params, err = f.MethodParams(context.Background(), "unknown")
103 + require.NoError(t, err)
104 + assert.Nil(t, params)
105 +}
106 +
107 +func TestFuncTopology_Handle_DefaultStrictL2(t *testing.T) {
108 + prev := snmpTopologyRegistry
109 + t.Cleanup(func() {
110 + snmpTopologyRegistry = prev
111 + })
112 +
113 + registry := newTopologyRegistry()
114 + snmpTopologyRegistry = registry
115 + registry.register(newTestTopologyCacheLLDP(
116 + "agent-test",
117 + time.Now().UTC(),
118 + "00:11:22:33:44:55",
119 + "sw-a",
120 + "10.0.0.1",
121 + "Gi0/1",
122 + "aa:bb:cc:dd:ee:ff",
123 + "sw-b",
124 + "10.0.0.2",
125 + "Gi0/2",
126 + ))
127 +
128 + f := &funcTopology{}
129 + resp := f.Handle(context.Background(), topologyMethodID, nil)
130 + require.NotNil(t, resp)
131 + assert.Equal(t, 200, resp.Status)
132 + assert.Equal(t, "topology", resp.ResponseType)
133 +
134 + data, ok := resp.Data.(topologyData)
135 + require.True(t, ok)
136 + assert.Equal(t, "2", data.Layer)
137 + assert.Equal(t, "summary", data.View)
138 +}
139 +
140 +func TestFuncTopology_Handle_AcceptsSelectorParams(t *testing.T) {
141 + prev := snmpTopologyRegistry
142 + t.Cleanup(func() {
143 + snmpTopologyRegistry = prev
144 + })
145 +
146 + registry := newTopologyRegistry()
147 + snmpTopologyRegistry = registry
148 + registry.register(newTestTopologyCacheLLDP(
149 + "agent-test",
150 + time.Now().UTC(),
151 + "00:11:22:33:44:55",
152 + "sw-a",
153 + "10.0.0.1",
154 + "Gi0/1",
155 + "aa:bb:cc:dd:ee:ff",
156 + "sw-b",
157 + "10.0.0.2",
158 + "Gi0/2",
159 + ))
160 +
161 + f := &funcTopology{}
162 + cfg := []funcapi.ParamConfig{
163 + topologyNodesIdentityParamConfig(),
164 + topologyMapTypeParamConfig(),
165 + topologyInferenceStrategyParamConfig(),
166 + topologyManagedFocusParamConfig(nil),
167 + topologyDepthParamConfig(),
168 + }
169 +
170 + params := funcapi.ResolveParams(cfg, map[string][]string{
171 + topologyParamNodesIdentity: {topologyNodesIdentityMAC},
172 + topologyParamMapType: {topologyMapTypeHighConfidenceInferred},
173 + topologyParamInferenceStrategy: {topologyInferenceStrategySTPFDBCorrelated},
174 + topologyParamManagedDeviceFocus: {"ip:10.0.0.1"},
175 + topologyParamDepth: {"2"},
176 + })
177 + resp := f.Handle(context.Background(), topologyMethodID, params)
178 + require.NotNil(t, resp)
179 + assert.Equal(t, 200, resp.Status)
180 + data, ok := resp.Data.(topologyData)
181 + require.True(t, ok)
182 + assert.Equal(t, "2", data.Layer)
183 +}
184 +
185 +func TestFuncTopology_Handle_UnknownSelectorsFallbackToDefaults(t *testing.T) {
186 + prev := snmpTopologyRegistry
187 + t.Cleanup(func() {
188 + snmpTopologyRegistry = prev
189 + })
190 +
191 + registry := newTopologyRegistry()
192 + snmpTopologyRegistry = registry
193 + registry.register(newTestTopologyCacheLLDP(
194 + "agent-test",
195 + time.Now().UTC(),
196 + "00:11:22:33:44:55",
197 + "sw-a",
198 + "10.0.0.1",
199 + "Gi0/1",
200 + "aa:bb:cc:dd:ee:ff",
201 + "sw-b",
202 + "10.0.0.2",
203 + "Gi0/2",
204 + ))
205 +
206 + f := &funcTopology{}
207 + cfg := []funcapi.ParamConfig{
208 + topologyNodesIdentityParamConfig(),
209 + topologyMapTypeParamConfig(),
210 + topologyInferenceStrategyParamConfig(),
211 + topologyManagedFocusParamConfig(nil),
212 + topologyDepthParamConfig(),
213 + }
214 +
215 + defaultResp := f.Handle(context.Background(), topologyMethodID, nil)
216 + require.NotNil(t, defaultResp)
217 + require.Equal(t, 200, defaultResp.Status)
218 + defaultData, ok := defaultResp.Data.(topologyData)
219 + require.True(t, ok)
220 +
221 + invalidParams := funcapi.ResolveParams(cfg, map[string][]string{
222 + topologyParamNodesIdentity: {"unknown"},
223 + topologyParamMapType: {"invalid"},
224 + topologyParamInferenceStrategy: {"invalid"},
225 + topologyParamManagedDeviceFocus: {"invalid"},
226 + topologyParamDepth: {"invalid"},
227 + })
228 + invalidResp := f.Handle(context.Background(), topologyMethodID, invalidParams)
229 + require.NotNil(t, invalidResp)
230 + require.Equal(t, 200, invalidResp.Status)
231 + invalidData, ok := invalidResp.Data.(topologyData)
232 + require.True(t, ok)
233 +
234 + assert.Equal(t, defaultData.Layer, invalidData.Layer)
235 + assert.Equal(t, defaultData.View, invalidData.View)
236 +}
237 +
238 +func TestNormalizeTopologyInferenceStrategy(t *testing.T) {
239 + assert.Equal(t, topologyInferenceStrategyFDBMinimumKnowledge, normalizeTopologyInferenceStrategy(""))
240 + assert.Equal(t, topologyInferenceStrategyFDBMinimumKnowledge, normalizeTopologyInferenceStrategy(topologyInferenceStrategyFDBMinimumKnowledge))
241 + assert.Equal(t, topologyInferenceStrategySTPParentTree, normalizeTopologyInferenceStrategy(topologyInferenceStrategySTPParentTree))
242 + assert.Equal(t, topologyInferenceStrategyFDBPairwise, normalizeTopologyInferenceStrategy(topologyInferenceStrategyFDBPairwise))
243 + assert.Equal(t, topologyInferenceStrategySTPFDBCorrelated, normalizeTopologyInferenceStrategy(topologyInferenceStrategySTPFDBCorrelated))
244 + assert.Equal(t, topologyInferenceStrategyCDPFDBHybrid, normalizeTopologyInferenceStrategy(topologyInferenceStrategyCDPFDBHybrid))
245 + assert.Equal(t, "", normalizeTopologyInferenceStrategy("invalid"))
246 +}
247 +
248 +func TestNormalizeTopologyManagedFocuses(t *testing.T) {
249 + assert.Equal(t, topologyManagedFocusAllDevices, normalizeTopologyManagedFocus(""))
250 + assert.Equal(t, "ip:10.0.0.1", normalizeTopologyManagedFocus(" ip:10.0.0.1 "))
251 + assert.Equal(t, []string{topologyManagedFocusAllDevices}, normalizeTopologyManagedFocuses(nil))
252 + assert.Equal(t, []string{topologyManagedFocusAllDevices}, normalizeTopologyManagedFocuses([]string{}))
253 + assert.Equal(t, []string{topologyManagedFocusAllDevices}, normalizeTopologyManagedFocuses([]string{""}))
254 + assert.Equal(t, []string{topologyManagedFocusAllDevices}, normalizeTopologyManagedFocuses([]string{" , , "}))
255 + assert.Equal(
256 + t,
257 + []string{topologyManagedFocusAllDevices},
258 + normalizeTopologyManagedFocuses([]string{"invalid"}),
259 + )
260 + assert.Equal(
261 + t,
262 + []string{"ip:10.0.0.1", "ip:10.0.0.2"},
263 + normalizeTopologyManagedFocuses([]string{"ip:10.0.0.2", "ip:10.0.0.1", "ip:10.0.0.2"}),
264 + )
265 + assert.Equal(
266 + t,
267 + []string{"ip:10.0.0.1", "ip:10.0.0.2"},
268 + normalizeTopologyManagedFocuses([]string{" ip:10.0.0.2 , ip:10.0.0.1 "}),
269 + )
270 + assert.Equal(
271 + t,
272 + []string{topologyManagedFocusAllDevices},
273 + normalizeTopologyManagedFocuses([]string{"ip:10.0.0.1", topologyManagedFocusAllDevices}),
274 + )
275 + assert.Equal(
276 + t,
277 + []string{topologyManagedFocusAllDevices},
278 + normalizeTopologyManagedFocuses([]string{"ip:10.0.0.1,all_devices"}),
279 + )
280 + assert.Equal(
281 + t,
282 + "ip:10.0.0.1,ip:10.0.0.2",
283 + formatTopologyManagedFocuses([]string{"ip:10.0.0.2", "ip:10.0.0.1"}),
284 + )
285 + assert.Equal(t, []string{topologyManagedFocusAllDevices}, parseTopologyManagedFocuses(""))
286 + assert.Equal(t, "10.0.0.1", topologyManagedFocusSelectedIP("ip:10.0.0.2,ip:10.0.0.1"))
287 + assert.Equal(t, []string{"10.0.0.1", "10.0.0.2"}, topologyManagedFocusSelectedIPs("ip:10.0.0.2,ip:10.0.0.1"))
288 + assert.True(t, isTopologyManagedFocusAllDevices(topologyManagedFocusAllDevices))
289 + assert.False(t, isTopologyManagedFocusAllDevices("ip:10.0.0.1"))
290 +}
291 +
292 +func newTestTopologyCacheLLDP(
293 + agentID string,
294 + ts time.Time,
295 + localChassis, localSysName, localMgmtIP, localPortID string,
296 + remoteChassis, remoteSysName, remoteMgmtIP, remotePortID string,
297 +) *topologyCache {
298 + cache := newTopologyCache()
299 + cache.updateTime = ts
300 + cache.lastUpdate = ts
301 + cache.agentID = agentID
302 + cache.localDevice = topologyDevice{
303 + ChassisID: localChassis,
304 + ChassisIDType: "macAddress",
305 + SysName: localSysName,
306 + ManagementIP: localMgmtIP,
307 + }
308 + cache.lldpLocPorts["1"] = &lldpLocPort{
309 + portNum: "1",
310 + portID: localPortID,
311 + portIDSubtype: "interfaceName",
312 + }
313 + cache.lldpRemotes["1:1"] = &lldpRemote{
314 + localPortNum: "1",
315 + remIndex: "1",
316 + chassisID: remoteChassis,
317 + chassisIDSubtype: "macAddress",
318 + portID: remotePortID,
319 + portIDSubtype: "interfaceName",
320 + sysName: remoteSysName,
321 + managementAddr: remoteMgmtIP,
322 + }
323 + return cache
324 +}
src/go/plugin/go.d/collector/snmp_topology/profile_filter.go new
+143
@@ -0,0 +1,143 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
7 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
8 +)
9 +
10 +// selectTopologyRefreshProfiles filters profiles to keep only topology metrics,
11 +// tags, and device metadata.
12 +// It mutates the passed-in profiles in place. Callers must pass cloned profiles
13 +// (ddsnmp.FindProfiles already returns clones).
14 +func selectTopologyRefreshProfiles(profiles []*ddsnmp.Profile) []*ddsnmp.Profile {
15 + if len(profiles) == 0 {
16 + return nil
17 + }
18 +
19 + selected := make([]*ddsnmp.Profile, 0, len(profiles))
20 + for _, prof := range profiles {
21 + if prof == nil || prof.Definition == nil {
22 + continue
23 + }
24 +
25 + filterProfileForTopology(prof)
26 + prof.Definition.Metadata = filterTopologyMetadata(prof.Definition.Metadata)
27 + prof.Definition.SysobjectIDMetadata = filterTopologySysobjectIDMetadata(prof.Definition.SysobjectIDMetadata)
28 + if !ddsnmp.ProfileHasCollectionData(prof.Definition) {
29 + continue
30 + }
31 +
32 + selected = append(selected, prof)
33 + }
34 +
35 + if len(selected) == 0 {
36 + return nil
37 + }
38 + return selected
39 +}
40 +
41 +func filterProfileForTopology(prof *ddsnmp.Profile) {
42 + def := prof.Definition
43 + def.Metrics = filterTopologyMetrics(def.Metrics)
44 + def.VirtualMetrics = ddsnmp.FilterVirtualMetricsBySources(def.VirtualMetrics, def.Metrics)
45 + if ddsnmp.ProfileContainsTopologyData(prof) || len(def.Metrics) > 0 {
46 + def.MetricTags = filterTopologyMetricTags(def.MetricTags)
47 + }
48 +}
49 +
50 +func filterTopologyMetrics(metrics []ddprofiledefinition.MetricsConfig) []ddprofiledefinition.MetricsConfig {
51 + if len(metrics) == 0 {
52 + return nil
53 + }
54 +
55 + filtered := metrics[:0]
56 + for _, metric := range metrics {
57 + if ddsnmp.MetricConfigContainsTopologyData(&metric) {
58 + filtered = append(filtered, metric)
59 + }
60 + }
61 +
62 + if len(filtered) == 0 {
63 + return nil
64 + }
65 + return filtered
66 +}
67 +
68 +func filterTopologyMetricTags(tags []ddprofiledefinition.MetricTagConfig) []ddprofiledefinition.MetricTagConfig {
69 + if len(tags) == 0 {
70 + return nil
71 + }
72 +
73 + filtered := tags[:0]
74 + for _, tag := range tags {
75 + if ddsnmp.MetricTagConfigContainsTopologyData(&tag) {
76 + filtered = append(filtered, tag)
77 + }
78 + }
79 +
80 + if len(filtered) == 0 {
81 + return nil
82 + }
83 + return filtered
84 +}
85 +
86 +func filterTopologyMetadata(meta ddprofiledefinition.MetadataConfig) ddprofiledefinition.MetadataConfig {
87 + if len(meta) == 0 {
88 + return nil
89 + }
90 +
91 + filtered := make(ddprofiledefinition.MetadataConfig)
92 + for resName, res := range meta {
93 + fields := make(map[string]ddprofiledefinition.MetadataField)
94 + for name, field := range res.Fields {
95 + if ddsnmp.MetadataFieldContainsTopologyData(name, &field) {
96 + fields[name] = field
97 + }
98 + }
99 +
100 + idTags := filterTopologyMetricTags(res.IDTags)
101 + if len(fields) == 0 && len(idTags) == 0 {
102 + continue
103 + }
104 +
105 + filtered[resName] = ddprofiledefinition.MetadataResourceConfig{
106 + Fields: fields,
107 + IDTags: idTags,
108 + }
109 + }
110 +
111 + if len(filtered) == 0 {
112 + return nil
113 + }
114 + return filtered
115 +}
116 +
117 +func filterTopologySysobjectIDMetadata(entries []ddprofiledefinition.SysobjectIDMetadataEntryConfig) []ddprofiledefinition.SysobjectIDMetadataEntryConfig {
118 + if len(entries) == 0 {
119 + return nil
120 + }
121 +
122 + filtered := make([]ddprofiledefinition.SysobjectIDMetadataEntryConfig, 0, len(entries))
123 + for _, entry := range entries {
124 + fields := make(map[string]ddprofiledefinition.MetadataField)
125 + for name, field := range entry.Metadata {
126 + if ddsnmp.MetadataFieldContainsTopologyData(name, &field) {
127 + fields[name] = field
128 + }
129 + }
130 + if len(fields) == 0 {
131 + continue
132 + }
133 + filtered = append(filtered, ddprofiledefinition.SysobjectIDMetadataEntryConfig{
134 + SysobjectID: entry.SysobjectID,
135 + Metadata: fields,
136 + })
137 + }
138 +
139 + if len(filtered) == 0 {
140 + return nil
141 + }
142 + return filtered
143 +}
src/go/plugin/go.d/collector/snmp_topology/profile_filter_test.go new
+124
@@ -0,0 +1,124 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/assert"
9 + "github.com/stretchr/testify/require"
10 +
11 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
12 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
13 +)
14 +
15 +func TestSelectTopologyRefreshProfiles_KeepsTopologyMetadataOnly(t *testing.T) {
16 + profiles := []*ddsnmp.Profile{{
17 + Definition: &ddprofiledefinition.ProfileDefinition{
18 + Metadata: ddprofiledefinition.MetadataConfig{
19 + "device": {
20 + Fields: map[string]ddprofiledefinition.MetadataField{
21 + "lldp_loc_sys_name": {
22 + Symbol: ddprofiledefinition.SymbolConfig{Name: "lldpLocSysName"},
23 + },
24 + "vendor": {
25 + Value: "Juniper",
26 + },
27 + },
28 + },
29 + },
30 + MetricTags: []ddprofiledefinition.MetricTagConfig{
31 + {
32 + Tag: "lldp_loc_chassis_id",
33 + Symbol: ddprofiledefinition.SymbolConfigCompat{
34 + Name: "lldpLocChassisId",
35 + },
36 + },
37 + {
38 + Tag: "ups_model",
39 + Symbol: ddprofiledefinition.SymbolConfigCompat{
40 + Name: "upsModel",
41 + },
42 + },
43 + },
44 + Metrics: []ddprofiledefinition.MetricsConfig{
45 + {
46 + Symbol: ddprofiledefinition.SymbolConfig{
47 + OID: "1.0.8802.1.1.2.1.3.7.1.2",
48 + Name: "_topology_lldp_loc_port_entry",
49 + },
50 + },
51 + {
52 + Symbol: ddprofiledefinition.SymbolConfig{
53 + OID: "1.3.6.1.2.1.33.1.2.1.0",
54 + Name: "upsBatteryStatus",
55 + },
56 + },
57 + },
58 + VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
59 + {
60 + Name: "lldpLocalPortRows",
61 + Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
62 + {Metric: "_topology_lldp_loc_port_entry", Table: "lldpLocPortTable"},
63 + },
64 + },
65 + {
66 + Name: "upsBatteryStatusTotal",
67 + Sources: []ddprofiledefinition.VirtualMetricSourceConfig{
68 + {Metric: "upsBatteryStatus", Table: "upsBatteryTable"},
69 + },
70 + },
71 + },
72 + },
73 + }}
74 +
75 + selected := selectTopologyRefreshProfiles(profiles)
76 + require.Len(t, selected, 1)
77 +
78 + prof := selected[0]
79 + require.NotNil(t, prof.Definition)
80 + require.Len(t, prof.Definition.Metrics, 1)
81 + assert.Equal(t, "_topology_lldp_loc_port_entry", prof.Definition.Metrics[0].Symbol.Name)
82 + require.Len(t, prof.Definition.VirtualMetrics, 1)
83 + assert.Equal(t, "lldpLocalPortRows", prof.Definition.VirtualMetrics[0].Name)
84 + require.Len(t, prof.Definition.MetricTags, 1)
85 + assert.Equal(t, "lldp_loc_chassis_id", prof.Definition.MetricTags[0].Tag)
86 + require.Len(t, prof.Definition.Metadata, 1)
87 + assert.Contains(t, prof.Definition.Metadata["device"].Fields, "lldp_loc_sys_name")
88 + assert.NotContains(t, prof.Definition.Metadata["device"].Fields, "vendor")
89 +}
90 +
91 +func TestFindTopologyProfiles_UsesDeclarativeProfileExtensions(t *testing.T) {
92 + profiles := (&Collector{}).findTopologyProfiles(ddsnmp.DeviceConnectionInfo{
93 + SysObjectID: "1.3.6.1.4.1.9.1.1",
94 + })
95 + require.NotEmpty(t, profiles)
96 +
97 + metricNames := make(map[string]struct{})
98 + metadataFields := make(map[string]struct{})
99 +
100 + for _, prof := range profiles {
101 + require.NotNil(t, prof.Definition)
102 + for _, metric := range prof.Definition.Metrics {
103 + if metric.Symbol.Name != "" {
104 + metricNames[metric.Symbol.Name] = struct{}{}
105 + }
106 + for _, sym := range metric.Symbols {
107 + metricNames[sym.Name] = struct{}{}
108 + }
109 + }
110 + for _, res := range prof.Definition.Metadata {
111 + for field := range res.Fields {
112 + metadataFields[field] = struct{}{}
113 + }
114 + }
115 + }
116 +
117 + assert.Contains(t, metricNames, "_topology_lldp_rem_entry")
118 + assert.Contains(t, metricNames, "_topology_cdp_cache_entry")
119 + assert.Contains(t, metricNames, "_topology_fdb_entry")
120 + assert.Contains(t, metricNames, "_topology_stp_port_entry")
121 + assert.Contains(t, metricNames, "_topology_vtp_vlan_entry")
122 + assert.Contains(t, metadataFields, "lldp_loc_sys_name")
123 + assert.Contains(t, metadataFields, "vtp_version")
124 +}
src/go/plugin/go.d/collector/snmp_topology/testdata/ATTRIBUTION.md new
+126
@@ -0,0 +1,126 @@
1 +<!-- markdownlint-disable-file MD013 MD032 MD034 MD043 -->
2 +
3 +# SNMP topology test data attribution
4 +
5 +Source: https://github.com/librenms/librenms
6 +License: GPL-3.0
7 +
8 +Files (verbatim copies from LibreNMS snmpsim testdata with LLDP/CDP neighbors):
9 +- tests/snmpsim/aix.snmprec
10 +- tests/snmpsim/aos6.snmprec
11 +- tests/snmpsim/aos7.snmprec
12 +- tests/snmpsim/aos.snmprec
13 +- tests/snmpsim/arista_eos.snmprec
14 +- tests/snmpsim/arista_eos_vrf.snmprec
15 +- tests/snmpsim/arubaos-cx_10.06.snmprec
16 +- tests/snmpsim/arubaos-cx_10.07.snmprec
17 +- tests/snmpsim/arubaos-cx_10.10.snmprec
18 +- tests/snmpsim/arubaos-cx_8360.snmprec
19 +- tests/snmpsim/arubaos-cx.snmprec
20 +- tests/snmpsim/boss_ers3510.snmprec
21 +- tests/snmpsim/boss_ers4950.snmprec
22 +- tests/snmpsim/bti800.snmprec
23 +- tests/snmpsim/ciena-sds.snmprec
24 +- tests/snmpsim/ciena-waveserver.snmprec
25 +- tests/snmpsim/ciscome1200.snmprec
26 +- tests/snmpsim/ciscosb_cbs250-24p-4x-v3.snmprec
27 +- tests/snmpsim/ciscosb_cbs350-4x.snmprec
28 +- tests/snmpsim/ciscosb_sg350x-24p.snmprec
29 +- tests/snmpsim/ciscosb_sg550x-8f8t.snmprec
30 +- tests/snmpsim/ciscosb_sx550x-24f.snmprec
31 +- tests/snmpsim/ciscowlc_cbw240ac.snmprec
32 +- tests/snmpsim/dell-os10.snmprec
33 +- tests/snmpsim/dell-sonic.snmprec
34 +- tests/snmpsim/dlink_des-3526.snmprec
35 +- tests/snmpsim/dlink_des-3550.snmprec
36 +- tests/snmpsim/dlink_dgs-1510-28.snmprec
37 +- tests/snmpsim/dlink_dgs-1510-28x-me.snmprec
38 +- tests/snmpsim/dlink_dgs-1510-28xmp-me.snmprec
39 +- tests/snmpsim/dlink_dgs-3000-28xmp.snmprec
40 +- tests/snmpsim/dlink_dgs-3420-28tc.snmprec
41 +- tests/snmpsim/dlink_dgs-3620-28sc.snmprec
42 +- tests/snmpsim/dlink_dgs-3627g.snmprec
43 +- tests/snmpsim/dnos_s4048.snmprec
44 +- tests/snmpsim/dnos.snmprec
45 +- tests/snmpsim/dnos_z9100-on.snmprec
46 +- tests/snmpsim/edgecos_2100-28p.snmprec
47 +- tests/snmpsim/edgecos_dcs203.snmprec
48 +- tests/snmpsim/edgecos_ecs2100-10t.snmprec
49 +- tests/snmpsim/edgecos_ecs4100-28t.snmprec
50 +- tests/snmpsim/edgecos_ecs4610-24f.snmprec
51 +- tests/snmpsim/edgeswitch_es-24-250w.snmprec
52 +- tests/snmpsim/edgeswitch_us-8.snmprec
53 +- tests/snmpsim/edgeswitch_us-8-v2.snmprec
54 +- tests/snmpsim/edgeswitch_usw-flex-xg.snmprec
55 +- tests/snmpsim/eltex-mes21xx_mes2124m.snmprec
56 +- tests/snmpsim/eltex-mes21xx_mes3124.snmprec
57 +- tests/snmpsim/eltex-mes23xx_mes2324b.snmprec
58 +- tests/snmpsim/eltex-mes23xx_mes2324fbac.snmprec
59 +- tests/snmpsim/eltex-mes23xx_mes2324fb.snmprec
60 +- tests/snmpsim/eltex-mes23xx_mes2324p.snmprec
61 +- tests/snmpsim/eltex-mes23xx_mes2348p.snmprec
62 +- tests/snmpsim/eltex-mes23xx_mes3324f.snmprec
63 +- tests/snmpsim/eltex-mes23xx_mes3324.snmprec
64 +- tests/snmpsim/eltex-mes23xx_mes3348.snmprec
65 +- tests/snmpsim/eltex-mes23xx_mes5324.snmprec
66 +- tests/snmpsim/eltex-mes24xx_mes2424bac.snmprec
67 +- tests/snmpsim/fortigate_60f3g4g.snmprec
68 +- tests/snmpsim/fortigate_60fospfv3.snmprec
69 +- tests/snmpsim/fortiswitch_148f-fpoe.snmprec
70 +- tests/snmpsim/fs-centec_s5850-48s2q4c.snmprec
71 +- tests/snmpsim/fs-switch_s3900-24f4s.snmprec
72 +- tests/snmpsim/fs-switch_s3900-24t4s.snmprec
73 +- tests/snmpsim/fs-switch_s3900.snmprec
74 +- tests/snmpsim/gam.snmprec
75 +- tests/snmpsim/ios_2960x.snmprec
76 +- tests/snmpsim/iosxe_c9400-svl.snmprec
77 +- tests/snmpsim/iosxe_c9400x-svl.snmprec
78 +- tests/snmpsim/iosxe_c9500h-svl.snmprec
79 +- tests/snmpsim/iosxe_c9500x-svl.snmprec
80 +- tests/snmpsim/iosxe_c9600-svl.snmprec
81 +- tests/snmpsim/iosxe_c9600x-svl.snmprec
82 +- tests/snmpsim/iosxe_c9800.snmprec
83 +- tests/snmpsim/iosxe_ie32008t2s-ios17-12.snmprec
84 +- tests/snmpsim/iosxr_ncs55a2.snmprec
85 +- tests/snmpsim/jetstream_vlans.snmprec
86 +- tests/snmpsim/junos_ex4600mp.snmprec
87 +- tests/snmpsim/junos_mx5t-isis.snmprec
88 +- tests/snmpsim/junos_qfx5100.snmprec
89 +- tests/snmpsim/junos_rpm.snmprec
90 +- tests/snmpsim/linksys-ss_lgs318p.snmprec
91 +- tests/snmpsim/moxa-awk_4131a.snmprec
92 +- tests/snmpsim/moxa-eds-4000-series_eds-4012-4gc.snmprec
93 +- tests/snmpsim/moxa-etherdevice_edsg512e.snmprec
94 +- tests/snmpsim/moxa-etherdevice_edsg516e.snmprec
95 +- tests/snmpsim/moxa-etherdevice_edsp506e.snmprec
96 +- tests/snmpsim/mypoweros.snmprec
97 +- tests/snmpsim/nxos_n3k-3064pq.snmprec
98 +- tests/snmpsim/pmp_450i.snmprec
99 +- tests/snmpsim/pmp_450m.snmprec
100 +- tests/snmpsim/pmp_450.snmprec
101 +- tests/snmpsim/pmp.snmprec
102 +- tests/snmpsim/procurve_e2910.snmprec
103 +- tests/snmpsim/procurve.snmprec
104 +- tests/snmpsim/raisecom-ros.snmprec
105 +- tests/snmpsim/routeros_crs317.snmprec
106 +- tests/snmpsim/routeros_rb433gl.snmprec
107 +- tests/snmpsim/routeros_rb750gr3.snmprec
108 +- tests/snmpsim/routeros_rb760igs.snmprec
109 +- tests/snmpsim/routeros.snmprec
110 +- tests/snmpsim/routeros_wifi.snmprec
111 +- tests/snmpsim/ruijie.snmprec
112 +- tests/snmpsim/scalance_sc646.snmprec
113 +- tests/snmpsim/scalance_xc206-2sfp.snmprec
114 +- tests/snmpsim/slxos_slx9150.snmprec
115 +- tests/snmpsim/smartax.snmprec
116 +- tests/snmpsim/voss_7432cq.snmprec
117 +- tests/snmpsim/voss_8608.snmprec
118 +- tests/snmpsim/voss_xa1440.snmprec
119 +- tests/snmpsim/vrp_5720.snmprec
120 +- tests/snmpsim/vrp_ac6605-26.snmprec
121 +- tests/snmpsim/zynos_gs1900-fdb.snmprec
122 +- tests/snmpsim/zynos_mgs3712.snmprec
123 +- tests/snmpsim/zynos.snmprec
124 +- tests/snmpsim/zynos_xgs4600.snmprec
125 +
126 +These files are used by topology cache tests to validate LLDP/CDP parsing with real device data.
src/go/plugin/go.d/collector/snmp_topology/topology_cache.go new
+136
@@ -0,0 +1,136 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sync"
7 + "time"
8 +)
9 +
10 +type topologyCache struct {
11 + mu sync.RWMutex
12 + lastUpdate time.Time
13 + updateTime time.Time
14 + staleAfter time.Duration
15 +
16 + agentID string
17 + localDevice topologyDevice
18 +
19 + lldpLocPorts map[string]*lldpLocPort
20 + lldpRemotes map[string]*lldpRemote
21 + cdpRemotes map[string]*cdpRemote
22 +
23 + ifNamesByIndex map[string]string
24 + ifStatusByIndex map[string]ifStatus
25 + ifIndexByIP map[string]string
26 + ifNetmaskByIP map[string]string
27 + bridgePortToIf map[string]string
28 + fdbEntries map[string]*fdbEntry
29 + fdbIDToVlanID map[string]string
30 + vlanIDToName map[string]string
31 + vtpVersion string
32 + stpBaseBridgeAddress string
33 + stpDesignatedRoot string
34 + stpPorts map[string]*stpPortEntry
35 + arpEntries map[string]*arpEntry
36 +}
37 +
38 +type ifStatus struct {
39 + admin string
40 + oper string
41 + ifType string
42 + ifDescr string
43 + ifAlias string
44 + mac string
45 + speedBps int64
46 + lastChange int64
47 + duplex string
48 +}
49 +
50 +type lldpLocPort struct {
51 + portNum string
52 + portID string
53 + portIDSubtype string
54 + portDesc string
55 +}
56 +
57 +type lldpRemote struct {
58 + localPortNum string
59 + remIndex string
60 + chassisID string
61 + chassisIDSubtype string
62 + portID string
63 + portIDSubtype string
64 + portDesc string
65 + sysName string
66 + sysDesc string
67 + sysCapSupported string
68 + sysCapEnabled string
69 + managementAddr string
70 + managementAddrType string
71 + managementAddrs []topologyManagementAddress
72 +}
73 +
74 +type cdpRemote struct {
75 + ifIndex string
76 + ifName string
77 + deviceIndex string
78 + deviceID string
79 + devicePort string
80 + platform string
81 + capabilities string
82 + addressType string
83 + address string
84 + version string
85 + vtpMgmtDomain string
86 + nativeVLAN string
87 + duplex string
88 + powerConsumption string
89 + mtu string
90 + sysName string
91 + sysObjectID string
92 + primaryMgmtAddrType string
93 + primaryMgmtAddr string
94 + secondaryMgmtAddrType string
95 + secondaryMgmtAddr string
96 + physicalLocation string
97 + lastChange string
98 + managementAddrs []topologyManagementAddress
99 +}
100 +
101 +type fdbEntry struct {
102 + mac string
103 + bridgePort string
104 + status string
105 + fdbID string
106 + vlanID string
107 + vlanName string
108 +}
109 +
110 +type stpPortEntry struct {
111 + port string
112 + vlanID string
113 + vlanName string
114 + priority string
115 + state string
116 + enable string
117 + pathCost string
118 + designatedRoot string
119 + designatedCost string
120 + designatedBridge string
121 + designatedPort string
122 +}
123 +
124 +type topologyVLANContext struct {
125 + vlanID string
126 + vlanName string
127 +}
128 +
129 +type arpEntry struct {
130 + ifIndex string
131 + ifName string
132 + ip string
133 + mac string
134 + addrType string
135 + state string
136 +}
src/go/plugin/go.d/collector/snmp_topology/topology_cache_cdp.go new
+88
@@ -0,0 +1,88 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +func (c *topologyCache) updateCdpRemote(tags map[string]string) {
6 + ifIndex := tags[tagCdpIfIndex]
7 + if ifIndex == "" {
8 + return
9 + }
10 +
11 + deviceIndex := tags[tagCdpDeviceIndex]
12 + key := ifIndex + ":" + deviceIndex
13 +
14 + entry := c.cdpRemotes[key]
15 + if entry == nil {
16 + entry = &cdpRemote{
17 + ifIndex: ifIndex,
18 + deviceIndex: deviceIndex,
19 + }
20 + c.cdpRemotes[key] = entry
21 + }
22 +
23 + if v := tags[tagCdpIfName]; v != "" {
24 + entry.ifName = v
25 + }
26 + if v := tags[tagCdpDeviceID]; v != "" {
27 + entry.deviceID = v
28 + }
29 + if v := tags[tagCdpAddressType]; v != "" {
30 + entry.addressType = v
31 + }
32 + if v := tags[tagCdpDevicePort]; v != "" {
33 + entry.devicePort = v
34 + }
35 + if v := tags[tagCdpVersion]; v != "" {
36 + entry.version = v
37 + }
38 + if v := tags[tagCdpPlatform]; v != "" {
39 + entry.platform = v
40 + }
41 + if v := tags[tagCdpCaps]; v != "" {
42 + entry.capabilities = v
43 + }
44 + if v := tags[tagCdpAddress]; v != "" {
45 + entry.address = v
46 + }
47 + if v := tags[tagCdpVTPDomain]; v != "" {
48 + entry.vtpMgmtDomain = v
49 + }
50 + if v := tags[tagCdpNativeVLAN]; v != "" {
51 + entry.nativeVLAN = v
52 + }
53 + if v := tags[tagCdpDuplex]; v != "" {
54 + entry.duplex = v
55 + }
56 + if v := tags[tagCdpPower]; v != "" {
57 + entry.powerConsumption = v
58 + }
59 + if v := tags[tagCdpMTU]; v != "" {
60 + entry.mtu = v
61 + }
62 + if v := tags[tagCdpSysName]; v != "" {
63 + entry.sysName = v
64 + }
65 + if v := tags[tagCdpSysObjectID]; v != "" {
66 + entry.sysObjectID = v
67 + }
68 + if v := tags[tagCdpPrimaryMgmtAddrType]; v != "" {
69 + entry.primaryMgmtAddrType = v
70 + }
71 + if v := tags[tagCdpPrimaryMgmtAddr]; v != "" {
72 + entry.primaryMgmtAddr = v
73 + }
74 + if v := tags[tagCdpSecondaryMgmtAddrType]; v != "" {
75 + entry.secondaryMgmtAddrType = v
76 + }
77 + if v := tags[tagCdpSecondaryMgmtAddr]; v != "" {
78 + entry.secondaryMgmtAddr = v
79 + }
80 + if v := tags[tagCdpPhysicalLocation]; v != "" {
81 + entry.physicalLocation = v
82 + }
83 + if v := tags[tagCdpLastChange]; v != "" {
84 + entry.lastChange = v
85 + }
86 +
87 + entry.managementAddrs = appendCdpManagementAddresses(entry, entry.managementAddrs)
88 +}
src/go/plugin/go.d/collector/snmp_topology/topology_cache_fdb.go new
+111
@@ -0,0 +1,111 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +func (c *topologyCache) updateFdbEntry(tags map[string]string) {
8 + c.updateLocalBridgeIdentityFromTags(tags)
9 +
10 + mac := normalizeMAC(firstNonEmpty(tags[tagFdbMac], tags[tagDot1qFdbMac]))
11 + if mac == "" {
12 + return
13 + }
14 +
15 + bridgePort := strings.TrimSpace(firstNonEmpty(tags[tagFdbBridgePort], tags[tagDot1qFdbPort]))
16 + if bridgePort == "" || bridgePort == "0" {
17 + return
18 + }
19 +
20 + fdbID := strings.TrimSpace(tags[tagDot1qFdbID])
21 + contextVLANID := strings.TrimSpace(tags[tagTopologyContextVLANID])
22 + contextVLANName := strings.TrimSpace(tags[tagTopologyContextVLANName])
23 + key := strings.Join([]string{mac, bridgePort, strings.ToLower(fdbID), strings.ToLower(contextVLANID)}, "|")
24 + entry := c.fdbEntries[key]
25 + if entry == nil {
26 + entry = &fdbEntry{
27 + mac: mac,
28 + bridgePort: bridgePort,
29 + fdbID: fdbID,
30 + }
31 + c.fdbEntries[key] = entry
32 + }
33 +
34 + if v := strings.TrimSpace(firstNonEmpty(tags[tagFdbStatus], tags[tagDot1qFdbStatus])); v != "" {
35 + entry.status = v
36 + }
37 + if entry.vlanID == "" && contextVLANID != "" {
38 + entry.vlanID = contextVLANID
39 + }
40 + if entry.vlanName == "" && contextVLANName != "" {
41 + entry.vlanName = contextVLANName
42 + }
43 + if entry.fdbID == "" && fdbID != "" {
44 + entry.fdbID = fdbID
45 + }
46 + if entry.vlanID == "" && entry.fdbID != "" {
47 + if vlanID := strings.TrimSpace(c.fdbIDToVlanID[entry.fdbID]); vlanID != "" {
48 + entry.vlanID = vlanID
49 + }
50 + }
51 + if entry.vlanName == "" && entry.vlanID != "" {
52 + if vlanName := strings.TrimSpace(c.vlanIDToName[entry.vlanID]); vlanName != "" {
53 + entry.vlanName = vlanName
54 + }
55 + }
56 +}
57 +
58 +func (c *topologyCache) updateDot1qVlanMap(tags map[string]string) {
59 + fdbID := strings.TrimSpace(tags[tagDot1qVlanFdbID])
60 + if fdbID == "" {
61 + return
62 + }
63 +
64 + vlanID := strings.TrimSpace(tags[tagDot1qVlanID])
65 + if vlanID == "" {
66 + vlanID = strings.TrimSpace(tags[tagDot1qVlanID1])
67 + }
68 + if vlanID == "" {
69 + return
70 + }
71 +
72 + c.fdbIDToVlanID[fdbID] = vlanID
73 + for _, entry := range c.fdbEntries {
74 + if entry == nil || strings.TrimSpace(entry.fdbID) != fdbID {
75 + continue
76 + }
77 + if strings.TrimSpace(entry.vlanID) == "" {
78 + entry.vlanID = vlanID
79 + }
80 + if strings.TrimSpace(entry.vlanName) == "" {
81 + entry.vlanName = strings.TrimSpace(c.vlanIDToName[vlanID])
82 + }
83 + }
84 +}
85 +
86 +func (c *topologyCache) updateVtpVlanEntry(tags map[string]string) {
87 + vlanID := strings.TrimSpace(tags[tagVtpVlanIndex])
88 + vlanName := strings.TrimSpace(tags[tagVtpVlanName])
89 + if vlanID == "" || vlanName == "" {
90 + return
91 + }
92 +
93 + vlanType := strings.TrimSpace(tags[tagVtpVlanType])
94 + vlanState := strings.ToLower(strings.TrimSpace(tags[tagVtpVlanState]))
95 + if vlanState != "" && vlanState != "1" && vlanState != "operational" {
96 + return
97 + }
98 + if vlanType != "" && vlanType != "1" && strings.ToLower(vlanType) != "ethernet" {
99 + return
100 + }
101 +
102 + c.vlanIDToName[vlanID] = vlanName
103 + for _, entry := range c.fdbEntries {
104 + if entry == nil || strings.TrimSpace(entry.vlanID) != vlanID {
105 + continue
106 + }
107 + if strings.TrimSpace(entry.vlanName) == "" {
108 + entry.vlanName = vlanName
109 + }
110 + }
111 +}
src/go/plugin/go.d/collector/snmp_topology/topology_cache_ingest.go new
+78
@@ -0,0 +1,78 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strconv"
7 +
8 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
9 +)
10 +
11 +func (c *Collector) updateTopologyProfileTags(pms []*ddsnmp.ProfileMetrics) {
12 + if c.topologyCache == nil {
13 + return
14 + }
15 +
16 + c.topologyCache.mu.Lock()
17 + defer c.topologyCache.mu.Unlock()
18 +
19 + for _, pm := range pms {
20 + tags := topologyMetadataValues(pm.DeviceMetadata)
21 + if len(tags) == 0 {
22 + continue
23 + }
24 +
25 + c.topologyCache.applyLLDPLocalDeviceProfileTags(tags)
26 + c.topologyCache.updateLocalBridgeIdentityFromTags(tags)
27 + c.topologyCache.applySTPProfileTags(tags)
28 + c.topologyCache.applyVTPProfileTags(tags)
29 + }
30 +}
31 +
32 +func (c *Collector) updateTopologyCacheEntry(m ddsnmp.Metric) {
33 + if c.topologyCache == nil {
34 + return
35 + }
36 +
37 + c.topologyCache.mu.Lock()
38 + defer c.topologyCache.mu.Unlock()
39 +
40 + c.topologyCache.ingestMetric(m.Name, m.Tags)
41 +}
42 +
43 +func (c *Collector) updateTopologyScalarMetric(m ddsnmp.Metric) {
44 + if c == nil || c.topologyCache == nil {
45 + return
46 + }
47 + if !isTopologySysUptimeMetric(m.Name) || m.Value <= 0 {
48 + return
49 + }
50 +
51 + c.topologyCache.mu.Lock()
52 + defer c.topologyCache.mu.Unlock()
53 +
54 + local := c.topologyCache.localDevice
55 + local.SysUptime = m.Value
56 + local.Labels = ensureLabels(local.Labels)
57 + setTopologyMetadataLabelIfMissing(local.Labels, "sys_uptime", strconv.FormatInt(m.Value, 10))
58 + c.topologyCache.localDevice = local
59 +}
60 +
61 +func topologyMetadataValues(meta map[string]ddsnmp.MetaTag) map[string]string {
62 + if len(meta) == 0 {
63 + return nil
64 + }
65 +
66 + values := make(map[string]string, len(meta))
67 + for key, tag := range meta {
68 + if tag.Value == "" {
69 + continue
70 + }
71 + values[key] = tag.Value
72 + }
73 +
74 + if len(values) == 0 {
75 + return nil
76 + }
77 + return values
78 +}
src/go/plugin/go.d/collector/snmp_topology/topology_cache_interfaces.go new
+104
@@ -0,0 +1,104 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "math"
7 + "strings"
8 +)
9 +
10 +func (c *topologyCache) updateIfNameByIndex(tags map[string]string) {
11 + ifIndex := strings.TrimSpace(tags[tagTopoIfIndex])
12 + if ifIndex == "" {
13 + return
14 + }
15 +
16 + ifName := strings.TrimSpace(tags[tagTopoIfName])
17 + if ifName != "" {
18 + c.ifNamesByIndex[ifIndex] = ifName
19 + }
20 +
21 + status := c.ifStatusByIndex[ifIndex]
22 + if ifType := normalizeInterfaceType(tags[tagTopoIfType]); ifType != "" {
23 + status.ifType = ifType
24 + }
25 + if admin := normalizeInterfaceAdminStatus(tags[tagTopoIfAdmin]); admin != "" {
26 + status.admin = admin
27 + }
28 + if oper := normalizeInterfaceOperStatus(tags[tagTopoIfOper]); oper != "" {
29 + status.oper = oper
30 + }
31 + if ifDescr := strings.TrimSpace(tags[tagTopoIfDescr]); ifDescr != "" {
32 + status.ifDescr = ifDescr
33 + }
34 + if ifAlias := strings.TrimSpace(tags[tagTopoIfAlias]); ifAlias != "" {
35 + status.ifAlias = ifAlias
36 + }
37 + if mac := normalizeMAC(tags[tagTopoIfPhys]); mac != "" && mac != "00:00:00:00:00:00" {
38 + status.mac = mac
39 + }
40 + if ifHighSpeed := parsePositiveInt64(tags[tagTopoIfHigh]); ifHighSpeed > 0 {
41 + if ifHighSpeed > math.MaxInt64/topologyHighSpeedMultiplier {
42 + status.speedBps = math.MaxInt64
43 + } else {
44 + status.speedBps = ifHighSpeed * topologyHighSpeedMultiplier
45 + }
46 + } else if ifSpeed := parsePositiveInt64(tags[tagTopoIfSpeed]); ifSpeed > 0 {
47 + status.speedBps = ifSpeed
48 + }
49 + if lastChange := parsePositiveInt64(tags[tagTopoIfLast]); lastChange > 0 {
50 + status.lastChange = lastChange
51 + }
52 + if duplex := normalizeInterfaceDuplex(tags[tagTopoIfDuplex]); duplex != "" {
53 + status.duplex = duplex
54 + }
55 + if status.ifType != "" ||
56 + status.admin != "" ||
57 + status.oper != "" ||
58 + status.ifDescr != "" ||
59 + status.ifAlias != "" ||
60 + status.mac != "" ||
61 + status.speedBps > 0 ||
62 + status.lastChange > 0 ||
63 + status.duplex != "" {
64 + c.ifStatusByIndex[ifIndex] = status
65 + }
66 +}
67 +
68 +func (c *topologyCache) updateIfIndexByIP(tags map[string]string) {
69 + ifIndex := strings.TrimSpace(tags[tagTopoIfIndex])
70 + if ifIndex == "" {
71 + return
72 + }
73 +
74 + ip := normalizeIPAddress(tags[tagTopoIPAddr])
75 + if ip == "" {
76 + return
77 + }
78 +
79 + c.ifIndexByIP[ip] = ifIndex
80 + c.localDevice.ManagementAddresses = appendManagementAddress(c.localDevice.ManagementAddresses, topologyManagementAddress{
81 + Address: ip,
82 + AddressType: managementAddressTypeFromIP(ip),
83 + Source: "ip_mib",
84 + })
85 + if mask := normalizeIPAddress(tags[tagTopoIPMask]); mask != "" {
86 + c.ifNetmaskByIP[ip] = mask
87 + }
88 +}
89 +
90 +func (c *topologyCache) updateBridgePortMap(tags map[string]string) {
91 + c.updateLocalBridgeIdentityFromTags(tags)
92 +
93 + basePort := strings.TrimSpace(tags[tagBridgeBasePort])
94 + if basePort == "" {
95 + return
96 + }
97 +
98 + ifIndex := strings.TrimSpace(tags[tagBridgeIfIndex])
99 + if ifIndex == "" {
100 + return
101 + }
102 +
103 + c.bridgePortToIf[basePort] = ifIndex
104 +}
src/go/plugin/go.d/collector/snmp_topology/topology_cache_lifecycle.go new
+72
@@ -0,0 +1,72 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "time"
6 +
7 +func newTopologyCache() *topologyCache {
8 + return &topologyCache{
9 + lldpLocPorts: make(map[string]*lldpLocPort),
10 + lldpRemotes: make(map[string]*lldpRemote),
11 + cdpRemotes: make(map[string]*cdpRemote),
12 + ifNamesByIndex: make(map[string]string),
13 + ifStatusByIndex: make(map[string]ifStatus),
14 + ifIndexByIP: make(map[string]string),
15 + ifNetmaskByIP: make(map[string]string),
16 + bridgePortToIf: make(map[string]string),
17 + fdbEntries: make(map[string]*fdbEntry),
18 + fdbIDToVlanID: make(map[string]string),
19 + vlanIDToName: make(map[string]string),
20 + stpPorts: make(map[string]*stpPortEntry),
21 + arpEntries: make(map[string]*arpEntry),
22 + }
23 +}
24 +
25 +func (c *topologyCache) replaceWith(src *topologyCache) {
26 + if c == nil || src == nil {
27 + return
28 + }
29 +
30 + c.lastUpdate = src.lastUpdate
31 + c.updateTime = src.updateTime
32 + c.staleAfter = src.staleAfter
33 + c.agentID = src.agentID
34 + c.localDevice = src.localDevice
35 + c.lldpLocPorts = src.lldpLocPorts
36 + c.lldpRemotes = src.lldpRemotes
37 + c.cdpRemotes = src.cdpRemotes
38 + c.ifNamesByIndex = src.ifNamesByIndex
39 + c.ifStatusByIndex = src.ifStatusByIndex
40 + c.ifIndexByIP = src.ifIndexByIP
41 + c.ifNetmaskByIP = src.ifNetmaskByIP
42 + c.bridgePortToIf = src.bridgePortToIf
43 + c.fdbEntries = src.fdbEntries
44 + c.fdbIDToVlanID = src.fdbIDToVlanID
45 + c.vlanIDToName = src.vlanIDToName
46 + c.vtpVersion = src.vtpVersion
47 + c.stpBaseBridgeAddress = src.stpBaseBridgeAddress
48 + c.stpDesignatedRoot = src.stpDesignatedRoot
49 + c.stpPorts = src.stpPorts
50 + c.arpEntries = src.arpEntries
51 +}
52 +
53 +func (c *topologyCache) hasFreshSnapshotAt(now time.Time) bool {
54 + if c == nil || c.lastUpdate.IsZero() {
55 + return false
56 + }
57 + if c.staleAfter > 0 && now.After(c.lastUpdate.Add(c.staleAfter)) {
58 + return false
59 + }
60 + return true
61 +}
62 +
63 +func (c *Collector) finalizeTopologyCache() {
64 + if c.topologyCache == nil {
65 + return
66 + }
67 +
68 + c.topologyCache.mu.Lock()
69 + defer c.topologyCache.mu.Unlock()
70 +
71 + c.topologyCache.lastUpdate = c.topologyCache.updateTime
72 +}
src/go/plugin/go.d/collector/snmp_topology/topology_cache_lldp.go new
+153
@@ -0,0 +1,153 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +func (c *topologyCache) updateLldpLocPort(tags map[string]string) {
8 + portNum := tags[tagLldpLocPortNum]
9 + if portNum == "" {
10 + return
11 + }
12 +
13 + entry := c.lldpLocPorts[portNum]
14 + if entry == nil {
15 + entry = &lldpLocPort{portNum: portNum}
16 + c.lldpLocPorts[portNum] = entry
17 + }
18 +
19 + if v := tags[tagLldpLocPortID]; v != "" {
20 + entry.portID = v
21 + }
22 + if v := tags[tagLldpLocPortIDSubtype]; v != "" {
23 + entry.portIDSubtype = normalizeLLDPSubtype(v, lldpPortIDSubtypeMap)
24 + }
25 + if v := tags[tagLldpLocPortDesc]; v != "" {
26 + entry.portDesc = v
27 + }
28 +}
29 +
30 +func (c *topologyCache) updateLldpLocManAddr(tags map[string]string) {
31 + addrHex := tags[tagLldpLocMgmtAddr]
32 + if addrHex == "" {
33 + return
34 + }
35 +
36 + addr, addrType := normalizeManagementAddress(addrHex, tags[tagLldpLocMgmtAddrSubtype])
37 + if addr == "" {
38 + return
39 + }
40 +
41 + mgmt := topologyManagementAddress{
42 + Address: addr,
43 + AddressType: addrType,
44 + IfSubtype: tags[tagLldpLocMgmtAddrIfSubtype],
45 + IfID: tags[tagLldpLocMgmtAddrIfID],
46 + OID: tags[tagLldpLocMgmtAddrOID],
47 + Source: "lldp_local",
48 + }
49 +
50 + c.localDevice.ManagementAddresses = appendManagementAddress(c.localDevice.ManagementAddresses, mgmt)
51 +}
52 +
53 +func (c *topologyCache) updateLldpRemote(tags map[string]string) {
54 + localPort := tags[tagLldpLocPortNum]
55 + if localPort == "" {
56 + return
57 + }
58 +
59 + remIndex := tags[tagLldpRemIndex]
60 + if remIndex == "" {
61 + return
62 + }
63 + key := localPort + ":" + remIndex
64 +
65 + entry := c.lldpRemotes[key]
66 + if entry == nil {
67 + entry = &lldpRemote{
68 + localPortNum: localPort,
69 + remIndex: remIndex,
70 + }
71 + c.lldpRemotes[key] = entry
72 + }
73 +
74 + if v := tags[tagLldpRemChassisID]; v != "" {
75 + entry.chassisID = v
76 + }
77 + if v := tags[tagLldpRemChassisIDSubtype]; v != "" {
78 + entry.chassisIDSubtype = normalizeLLDPSubtype(v, lldpChassisIDSubtypeMap)
79 + }
80 + if v := tags[tagLldpRemPortID]; v != "" {
81 + entry.portID = v
82 + }
83 + if v := tags[tagLldpRemPortIDSubtype]; v != "" {
84 + entry.portIDSubtype = normalizeLLDPSubtype(v, lldpPortIDSubtypeMap)
85 + }
86 + if v := tags[tagLldpRemPortDesc]; v != "" {
87 + entry.portDesc = v
88 + }
89 + if v := tags[tagLldpRemSysName]; v != "" {
90 + entry.sysName = v
91 + }
92 + if v := tags[tagLldpRemSysDesc]; v != "" {
93 + entry.sysDesc = v
94 + }
95 + if v := tags[tagLldpRemSysCapSupported]; v != "" {
96 + entry.sysCapSupported = v
97 + }
98 + if v := tags[tagLldpRemSysCapEnabled]; v != "" {
99 + entry.sysCapEnabled = v
100 + }
101 + if v := tags[tagLldpRemMgmtAddr]; v != "" {
102 + entry.managementAddr = v
103 + addr, addrType := normalizeManagementAddress(v, tags[tagLldpRemMgmtAddrSubtype])
104 + if addr != "" {
105 + entry.managementAddrs = appendManagementAddress(entry.managementAddrs, topologyManagementAddress{
106 + Address: addr,
107 + AddressType: addrType,
108 + Source: "lldp_remote",
109 + })
110 + }
111 + }
112 +}
113 +
114 +func (c *topologyCache) updateLldpRemManAddr(tags map[string]string) {
115 + localPort := tags[tagLldpLocPortNum]
116 + if localPort == "" {
117 + return
118 + }
119 +
120 + remIndex := tags[tagLldpRemIndex]
121 + if remIndex == "" {
122 + return
123 + }
124 +
125 + key := localPort + ":" + remIndex
126 + entry := c.lldpRemotes[key]
127 + if entry == nil {
128 + entry = &lldpRemote{
129 + localPortNum: localPort,
130 + remIndex: remIndex,
131 + }
132 + c.lldpRemotes[key] = entry
133 + }
134 +
135 + addrHex := tags[tagLldpRemMgmtAddr]
136 + if strings.TrimSpace(addrHex) == "" {
137 + addrHex = reconstructLldpRemMgmtAddrHex(tags)
138 + }
139 + addr, addrType := normalizeManagementAddress(addrHex, tags[tagLldpRemMgmtAddrSubtype])
140 + if addr == "" {
141 + return
142 + }
143 +
144 + mgmt := topologyManagementAddress{
145 + Address: addr,
146 + AddressType: addrType,
147 + IfSubtype: tags[tagLldpRemMgmtAddrIfSubtype],
148 + IfID: tags[tagLldpRemMgmtAddrIfID],
149 + OID: tags[tagLldpRemMgmtAddrOID],
150 + Source: "lldp_remote",
151 + }
152 + entry.managementAddrs = appendManagementAddress(entry.managementAddrs, mgmt)
153 +}
src/go/plugin/go.d/collector/snmp_topology/topology_cache_metric_dispatch.go new
+55
@@ -0,0 +1,55 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +func (c *topologyCache) ingestMetric(metricName string, tags map[string]string) {
8 + switch metricName {
9 + case metricLldpLocPortEntry:
10 + c.updateLldpLocPort(tags)
11 + case metricLldpLocManAddrEntry:
12 + c.updateLldpLocManAddr(tags)
13 + case metricLldpRemEntry:
14 + c.updateLldpRemote(tags)
15 + case metricLldpRemManAddrEntry, metricLldpRemManAddrCompat:
16 + c.updateLldpRemManAddr(tags)
17 + case metricCdpCacheEntry:
18 + c.updateCdpRemote(tags)
19 + case metricTopologyIfNameEntry, metricTopologyIfStatusEntry, metricTopologyIfDuplexEntry:
20 + c.updateIfNameByIndex(tags)
21 + case metricTopologyIPIfEntry:
22 + c.updateIfIndexByIP(tags)
23 + case metricBridgePortMapEntry:
24 + c.updateBridgePortMap(tags)
25 + case metricFdbEntry, metricDot1qFdbEntry:
26 + c.updateFdbEntry(tags)
27 + case metricDot1qVlanEntry:
28 + c.updateDot1qVlanMap(tags)
29 + case metricStpPortEntry:
30 + c.updateStpPortEntry(tags)
31 + case metricVtpVlanEntry:
32 + c.updateVtpVlanEntry(tags)
33 + case metricArpEntry, metricArpLegacyEntry:
34 + c.updateArpEntry(tags)
35 + }
36 +}
37 +
38 +func isTopologySysUptimeMetric(name string) bool {
39 + switch strings.ToLower(strings.TrimSpace(name)) {
40 + case "sysuptime", "systemuptime":
41 + return true
42 + default:
43 + return false
44 + }
45 +}
46 +
47 +func isTopologyMetric(name string) bool {
48 + switch name {
49 + case metricLldpLocPortEntry, metricLldpLocManAddrEntry, metricLldpRemEntry, metricLldpRemManAddrEntry, metricLldpRemManAddrCompat, metricCdpCacheEntry,
50 + metricTopologyIfNameEntry, metricTopologyIfStatusEntry, metricTopologyIfDuplexEntry, metricTopologyIPIfEntry, metricBridgePortMapEntry, metricFdbEntry, metricDot1qFdbEntry, metricDot1qVlanEntry, metricStpPortEntry, metricVtpVlanEntry, metricArpEntry, metricArpLegacyEntry:
51 + return true
52 + default:
53 + return false
54 + }
55 +}
src/go/plugin/go.d/collector/snmp_topology/topology_cache_profile_tags.go new
+80
@@ -0,0 +1,80 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +func (c *topologyCache) applyLLDPLocalDeviceProfileTags(tags map[string]string) {
8 + if c == nil || len(tags) == 0 {
9 + return
10 + }
11 +
12 + if v := tags[tagLldpLocChassisID]; v != "" && strings.TrimSpace(c.localDevice.ChassisID) == "" {
13 + c.localDevice.ChassisID = v
14 + }
15 + if v := tags[tagLldpLocChassisIDSubtype]; v != "" && strings.TrimSpace(c.localDevice.ChassisIDType) == "" {
16 + c.localDevice.ChassisIDType = normalizeLLDPSubtype(v, lldpChassisIDSubtypeMap)
17 + }
18 + if v := tags[tagLldpLocSysName]; v != "" && strings.TrimSpace(c.localDevice.SysName) == "" {
19 + c.localDevice.SysName = v
20 + }
21 + if v := tags[tagLldpLocSysDesc]; v != "" && strings.TrimSpace(c.localDevice.SysDescr) == "" {
22 + c.localDevice.SysDescr = v
23 + }
24 + if v := tags[tagLldpLocSysCapSupported]; v != "" {
25 + c.localDevice.Labels = ensureLabels(c.localDevice.Labels)
26 + c.localDevice.Labels[tagLldpLocSysCapSupported] = v
27 + caps := decodeLLDPCapabilities(v)
28 + if len(caps) > 0 {
29 + c.localDevice.CapabilitiesSupported = caps
30 + }
31 + }
32 + if v := tags[tagLldpLocSysCapEnabled]; v != "" {
33 + c.localDevice.Labels = ensureLabels(c.localDevice.Labels)
34 + c.localDevice.Labels[tagLldpLocSysCapEnabled] = v
35 + caps := decodeLLDPCapabilities(v)
36 + if len(caps) > 0 {
37 + c.localDevice.CapabilitiesEnabled = caps
38 + if len(c.localDevice.Capabilities) == 0 {
39 + c.localDevice.Capabilities = caps
40 + }
41 + }
42 + }
43 +}
44 +
45 +func (c *topologyCache) applySTPProfileTags(tags map[string]string) {
46 + if c == nil || len(tags) == 0 {
47 + return
48 + }
49 + if v := stpBridgeAddressToMAC(tags[tagStpDesignatedRoot]); v != "" {
50 + c.stpDesignatedRoot = v
51 + }
52 +}
53 +
54 +func (c *topologyCache) applyVTPProfileTags(tags map[string]string) {
55 + if c == nil || len(tags) == 0 {
56 + return
57 + }
58 + if v := strings.TrimSpace(tags[tagVtpVersion]); v != "" {
59 + c.vtpVersion = v
60 + }
61 +}
62 +
63 +func (c *topologyCache) applyAuthoritativeBridgeIdentity(mac string) {
64 + mac = normalizeMAC(mac)
65 + if mac == "" || mac == "00:00:00:00:00:00" {
66 + return
67 + }
68 + c.stpBaseBridgeAddress = mac
69 + c.localDevice.ChassisID = mac
70 + c.localDevice.ChassisIDType = "macAddress"
71 +}
72 +
73 +func (c *topologyCache) updateLocalBridgeIdentityFromTags(tags map[string]string) {
74 + if c == nil || len(tags) == 0 {
75 + return
76 + }
77 + if v := stpBridgeAddressToMAC(firstNonEmpty(tags[tagBridgeBaseAddress], tags[tagLegacyStpBaseBridgeAddr])); v != "" {
78 + c.applyAuthoritativeBridgeIdentity(v)
79 + }
80 +}
src/go/plugin/go.d/collector/snmp_topology/topology_cache_stp_arp.go new
+93
@@ -0,0 +1,93 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +func (c *topologyCache) updateStpPortEntry(tags map[string]string) {
8 + port := strings.TrimSpace(tags[tagStpPort])
9 + if port == "" {
10 + return
11 + }
12 +
13 + contextVLANID := strings.TrimSpace(tags[tagTopologyContextVLANID])
14 + stpPortKey := port
15 + if contextVLANID != "" {
16 + stpPortKey = port + "|vlan:" + strings.ToLower(contextVLANID)
17 + }
18 + entry := c.stpPorts[stpPortKey]
19 + if entry == nil {
20 + entry = &stpPortEntry{port: port}
21 + c.stpPorts[stpPortKey] = entry
22 + }
23 + if contextVLANID != "" {
24 + entry.vlanID = contextVLANID
25 + }
26 + if v := strings.TrimSpace(tags[tagTopologyContextVLANName]); v != "" {
27 + entry.vlanName = v
28 + }
29 + if v := strings.TrimSpace(tags[tagStpPortPriority]); v != "" {
30 + entry.priority = v
31 + }
32 + if v := strings.TrimSpace(tags[tagStpPortState]); v != "" {
33 + entry.state = v
34 + }
35 + if v := strings.TrimSpace(tags[tagStpPortEnable]); v != "" {
36 + entry.enable = v
37 + }
38 + if v := strings.TrimSpace(tags[tagStpPortPathCost]); v != "" {
39 + entry.pathCost = v
40 + }
41 + if v := stpBridgeAddressToMAC(tags[tagStpPortDesignatedRoot]); v != "" {
42 + entry.designatedRoot = v
43 + }
44 + if v := strings.TrimSpace(tags[tagStpPortDesignatedCost]); v != "" {
45 + entry.designatedCost = v
46 + }
47 + if v := stpBridgeAddressToMAC(tags[tagStpPortDesignatedBridge]); v != "" {
48 + entry.designatedBridge = v
49 + }
50 + if v := stpDesignatedPortString(tags[tagStpPortDesignatedPort]); v != "" {
51 + entry.designatedPort = v
52 + }
53 +}
54 +
55 +func (c *topologyCache) updateArpEntry(tags map[string]string) {
56 + ip := normalizeIPAddress(tags[tagArpIP])
57 + mac := normalizeMAC(tags[tagArpMac])
58 + if ip == "" || mac == "" {
59 + return
60 + }
61 +
62 + ifIndex := strings.TrimSpace(tags[tagArpIfIndex])
63 + ifName := strings.TrimSpace(tags[tagArpIfName])
64 + if ifName == "" && ifIndex != "" {
65 + ifName = c.ifNamesByIndex[ifIndex]
66 + }
67 +
68 + if ifIndex != "" && ifName != "" {
69 + c.ifNamesByIndex[ifIndex] = ifName
70 + }
71 +
72 + key := strings.Join([]string{ifIndex, ip, mac}, "|")
73 + entry := c.arpEntries[key]
74 + if entry == nil {
75 + entry = &arpEntry{
76 + ifIndex: ifIndex,
77 + ifName: ifName,
78 + ip: ip,
79 + mac: mac,
80 + }
81 + c.arpEntries[key] = entry
82 + }
83 +
84 + if v := strings.TrimSpace(tags[tagArpState]); v != "" {
85 + entry.state = v
86 + }
87 + if v := strings.TrimSpace(tags[tagArpType]); v != "" && entry.state == "" {
88 + entry.state = v
89 + }
90 + if v := strings.TrimSpace(tags[tagArpAddrType]); v != "" {
91 + entry.addrType = v
92 + }
93 +}
src/go/plugin/go.d/collector/snmp_topology/topology_cache_tags.go new
+154
@@ -0,0 +1,154 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +const (
6 + metricLldpLocPortEntry = "_topology_lldp_loc_port_entry"
7 + metricLldpLocManAddrEntry = "_topology_lldp_loc_man_addr_entry"
8 + metricLldpRemEntry = "_topology_lldp_rem_entry"
9 + metricLldpRemManAddrEntry = "_topology_lldp_rem_man_addr_entry"
10 + metricLldpRemManAddrCompat = "_topology_lldp_rem_man_addr_compat_entry"
11 + metricCdpCacheEntry = "_topology_cdp_cache_entry"
12 + metricTopologyIfNameEntry = "_topology_if_name_entry"
13 + metricTopologyIfStatusEntry = "_topology_if_status_entry"
14 + metricTopologyIfDuplexEntry = "_topology_if_duplex_entry"
15 + metricTopologyIPIfEntry = "_topology_ip_if_index_entry"
16 + metricBridgePortMapEntry = "_topology_bridge_port_if_index_entry"
17 + metricFdbEntry = "_topology_fdb_entry"
18 + metricDot1qFdbEntry = "_topology_qbridge_fdb_entry"
19 + metricDot1qVlanEntry = "_topology_qbridge_vlan_entry"
20 + metricStpPortEntry = "_topology_stp_port_entry"
21 + metricVtpVlanEntry = "_topology_vtp_vlan_entry"
22 + metricArpEntry = "_topology_arp_entry"
23 + metricArpLegacyEntry = "_topology_arp_legacy_entry"
24 +)
25 +
26 +const (
27 + tagLldpLocChassisID = "lldp_loc_chassis_id"
28 + tagLldpLocChassisIDSubtype = "lldp_loc_chassis_id_subtype"
29 + tagLldpLocSysName = "lldp_loc_sys_name"
30 + tagLldpLocSysDesc = "lldp_loc_sys_desc"
31 + tagLldpLocSysCapSupported = "lldp_loc_sys_cap_supported"
32 + tagLldpLocSysCapEnabled = "lldp_loc_sys_cap_enabled"
33 +
34 + tagLldpLocMgmtAddrSubtype = "lldp_loc_mgmt_addr_subtype"
35 + tagLldpLocMgmtAddr = "lldp_loc_mgmt_addr"
36 + tagLldpLocMgmtAddrLen = "lldp_loc_mgmt_addr_len"
37 + tagLldpLocMgmtAddrIfSubtype = "lldp_loc_mgmt_addr_if_subtype"
38 + tagLldpLocMgmtAddrIfID = "lldp_loc_mgmt_addr_if_id"
39 + tagLldpLocMgmtAddrOID = "lldp_loc_mgmt_addr_oid"
40 +
41 + tagLldpLocPortNum = "lldp_loc_port_num"
42 + tagLldpLocPortID = "lldp_loc_port_id"
43 + tagLldpLocPortIDSubtype = "lldp_loc_port_id_subtype"
44 + tagLldpLocPortDesc = "lldp_loc_port_desc"
45 +
46 + tagLldpRemIndex = "lldp_rem_index"
47 + tagLldpRemChassisID = "lldp_rem_chassis_id"
48 + tagLldpRemChassisIDSubtype = "lldp_rem_chassis_id_subtype"
49 + tagLldpRemPortID = "lldp_rem_port_id"
50 + tagLldpRemPortIDSubtype = "lldp_rem_port_id_subtype"
51 + tagLldpRemPortDesc = "lldp_rem_port_desc"
52 + tagLldpRemSysName = "lldp_rem_sys_name"
53 + tagLldpRemSysDesc = "lldp_rem_sys_desc"
54 + tagLldpRemMgmtAddr = "lldp_rem_mgmt_addr"
55 + tagLldpRemSysCapSupported = "lldp_rem_sys_cap_supported"
56 + tagLldpRemSysCapEnabled = "lldp_rem_sys_cap_enabled"
57 +
58 + tagLldpRemMgmtAddrSubtype = "lldp_rem_mgmt_addr_subtype"
59 + tagLldpRemMgmtAddrLen = "lldp_rem_mgmt_addr_len"
60 + tagLldpRemMgmtAddrOctetPref = "lldp_rem_mgmt_addr_octet_"
61 + tagLldpRemMgmtAddrIfSubtype = "lldp_rem_mgmt_addr_if_subtype"
62 + tagLldpRemMgmtAddrIfID = "lldp_rem_mgmt_addr_if_id"
63 + tagLldpRemMgmtAddrOID = "lldp_rem_mgmt_addr_oid"
64 +
65 + tagCdpIfIndex = "cdp_if_index"
66 + tagCdpIfName = "cdp_if_name"
67 + tagCdpDeviceIndex = "cdp_device_index"
68 + tagCdpDeviceID = "cdp_device_id"
69 + tagCdpAddressType = "cdp_address_type"
70 + tagCdpDevicePort = "cdp_device_port"
71 + tagCdpVersion = "cdp_version"
72 + tagCdpPlatform = "cdp_platform"
73 + tagCdpCaps = "cdp_capabilities"
74 + tagCdpAddress = "cdp_address"
75 + tagCdpVTPDomain = "cdp_vtp_mgmt_domain"
76 + tagCdpNativeVLAN = "cdp_native_vlan"
77 + tagCdpDuplex = "cdp_duplex"
78 + tagCdpPower = "cdp_power_consumption"
79 + tagCdpMTU = "cdp_mtu"
80 + tagCdpSysName = "cdp_sys_name"
81 + tagCdpSysObjectID = "cdp_sys_object_id"
82 + tagCdpPrimaryMgmtAddrType = "cdp_primary_mgmt_addr_type"
83 + tagCdpPrimaryMgmtAddr = "cdp_primary_mgmt_addr"
84 + tagCdpSecondaryMgmtAddrType = "cdp_secondary_mgmt_addr_type"
85 + tagCdpSecondaryMgmtAddr = "cdp_secondary_mgmt_addr"
86 + tagCdpPhysicalLocation = "cdp_physical_location"
87 + tagCdpLastChange = "cdp_last_change"
88 +
89 + tagTopoIfIndex = "topo_if_index"
90 + tagTopoIfName = "topo_if_name"
91 + tagTopoIfType = "topo_if_type"
92 + tagTopoIfAdmin = "topo_if_admin_status"
93 + tagTopoIfOper = "topo_if_oper_status"
94 + tagTopoIfPhys = "topo_if_phys_address"
95 + tagTopoIfDescr = "topo_if_descr"
96 + tagTopoIfAlias = "topo_if_alias"
97 + tagTopoIfSpeed = "topo_if_speed"
98 + tagTopoIfHigh = "topo_if_high_speed"
99 + tagTopoIfLast = "topo_if_last_change"
100 + tagTopoIfDuplex = "topo_if_duplex"
101 + tagTopoIPAddr = "topo_ip_addr"
102 + tagTopoIPMask = "topo_ip_netmask"
103 +
104 + tagBridgeBasePort = "bridge_base_port"
105 + tagBridgeIfIndex = "bridge_if_index"
106 +
107 + tagFdbMac = "fdb_mac"
108 + tagFdbBridgePort = "fdb_bridge_port"
109 + tagFdbStatus = "fdb_status"
110 + tagDot1qFdbID = "dot1q_fdb_id"
111 + tagDot1qFdbMac = "dot1q_fdb_mac"
112 + tagDot1qFdbPort = "dot1q_fdb_bridge_port"
113 + tagDot1qFdbStatus = "dot1q_fdb_status"
114 + tagDot1qVlanID = "dot1q_vlan_id"
115 + tagDot1qVlanID1 = "dot1q_vlan_id_idx1"
116 + tagDot1qVlanFdbID = "dot1q_vlan_fdb_id"
117 + tagBridgeBaseAddress = "bridge_base_address"
118 + tagLegacyStpBaseBridgeAddr = "stp_base_bridge_address"
119 + // Backward-compatibility alias for tests/older in-memory tag references.
120 + tagStpBaseBridgeAddress = tagLegacyStpBaseBridgeAddr
121 + tagStpDesignatedRoot = "stp_designated_root"
122 + tagStpPort = "stp_port"
123 + tagStpPortPriority = "stp_port_priority"
124 + tagStpPortState = "stp_port_state"
125 + tagStpPortEnable = "stp_port_enable"
126 + tagStpPortPathCost = "stp_port_path_cost"
127 + tagStpPortDesignatedRoot = "stp_port_designated_root"
128 + tagStpPortDesignatedCost = "stp_port_designated_cost"
129 + tagStpPortDesignatedBridge = "stp_port_designated_bridge"
130 + tagStpPortDesignatedPort = "stp_port_designated_port"
131 + tagVtpVersion = "vtp_version"
132 + tagVtpVlanIndex = "vtp_vlan_index"
133 + tagVtpVlanState = "vtp_vlan_state"
134 + tagVtpVlanType = "vtp_vlan_type"
135 + tagVtpVlanName = "vtp_vlan_name"
136 +
137 + tagArpIfIndex = "arp_if_index"
138 + tagArpIfName = "arp_if_name"
139 + tagArpIP = "arp_ip"
140 + tagArpMac = "arp_mac"
141 + tagArpType = "arp_type"
142 + tagArpState = "arp_state"
143 + tagArpAddrType = "arp_addr_type"
144 +
145 + // Internal collector tags used when ingesting additional VLAN-context snapshots.
146 + tagTopologyContextVLANID = "_topology_context_vlan_id"
147 + tagTopologyContextVLANName = "_topology_context_vlan_name"
148 +)
149 +
150 +const (
151 + topologyProfileChartIDPrefix = "snmp_device_prof_"
152 + topologyProfileChartContextPrefix = "snmp.device_prof_"
153 + topologyHighSpeedMultiplier = int64(1_000_000)
154 +)
src/go/plugin/go.d/collector/snmp_topology/topology_cache_test.go new
+1571
@@ -0,0 +1,1571 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "slices"
7 + "sort"
8 + "strings"
9 + "testing"
10 + "time"
11 +
12 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
13 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
14 + "github.com/stretchr/testify/assert"
15 + "github.com/stretchr/testify/require"
16 +)
17 +
18 +func newTestCollector(dev ddsnmp.DeviceConnectionInfo) *Collector {
19 + cache := newTopologyCache()
20 + cache.localDevice = buildLocalTopologyDevice(dev)
21 + cache.agentID = dev.Hostname
22 + cache.updateTime = time.Now()
23 + return &Collector{
24 + topologyCache: cache,
25 + deviceCaches: map[string]*topologyCache{dev.Hostname: cache},
26 + }
27 +}
28 +
29 +func TestTopologyCache_LldpSnapshot(t *testing.T) {
30 + coll := newTestCollector(ddsnmp.DeviceConnectionInfo{
31 + Hostname: "10.0.0.1", SysObjectID: "1.3.6.1.4.1.9.1.1", SysName: "sw1", SysDescr: "Switch 1", SysLocation: "dc1",
32 + })
33 +
34 + pms := []*ddsnmp.ProfileMetrics{{
35 + DeviceMetadata: map[string]ddsnmp.MetaTag{
36 + tagLldpLocChassisID: {Value: "00:11:22:33:44:55"},
37 + tagLldpLocChassisIDSubtype: {Value: "4"},
38 + },
39 + }}
40 + coll.updateTopologyProfileTags(pms)
41 +
42 + coll.updateTopologyCacheEntry(ddsnmp.Metric{
43 + Name: metricLldpLocPortEntry,
44 + Tags: map[string]string{
45 + tagLldpLocPortNum: "1",
46 + tagLldpLocPortID: "Gi0/1",
47 + tagLldpLocPortIDSubtype: "5",
48 + tagLldpLocPortDesc: "uplink",
49 + },
50 + })
51 + coll.updateTopologyCacheEntry(ddsnmp.Metric{
52 + Name: metricLldpRemEntry,
53 + Tags: map[string]string{
54 + tagLldpLocPortNum: "1",
55 + tagLldpRemIndex: "1",
56 + tagLldpRemChassisID: "aa:bb:cc:dd:ee:ff",
57 + tagLldpRemChassisIDSubtype: "4",
58 + tagLldpRemPortID: "Gi0/2",
59 + tagLldpRemPortIDSubtype: "5",
60 + tagLldpRemPortDesc: "downlink",
61 + tagLldpRemSysName: "sw2",
62 + },
63 + })
64 +
65 + coll.finalizeTopologyCache()
66 +
67 + coll.topologyCache.mu.RLock()
68 + data, ok := coll.topologyCache.snapshot()
69 + coll.topologyCache.mu.RUnlock()
70 +
71 + require.True(t, ok)
72 + require.Len(t, data.Actors, 2)
73 + require.Len(t, data.Links, 1)
74 +
75 + link := data.Links[0]
76 + assert.Equal(t, "lldp", link.Protocol)
77 + assert.Equal(t, "bidirectional", link.Direction)
78 + assert.Equal(t, "Gi0/1", link.Src.Attributes["port_id"])
79 + assert.Equal(t, "Gi0/2", link.Dst.Attributes["port_id"])
80 + assert.Equal(t, "sw2", link.Dst.Attributes["sys_name"])
81 +}
82 +
83 +func TestTopologyCache_CdpSnapshot(t *testing.T) {
84 + cache := newTopologyCache()
85 + cache.updateTime = time.Now()
86 + cache.lastUpdate = cache.updateTime
87 + cache.agentID = "agent1"
88 + cache.localDevice = topologyDevice{
89 + ChassisID: "00:11:22:33:44:55",
90 + ChassisIDType: "macAddress",
91 + ManagementIP: "10.0.0.1",
92 + }
93 +
94 + cache.cdpRemotes["2:1"] = &cdpRemote{
95 + ifIndex: "2",
96 + ifName: "Gi0/2",
97 + deviceID: "sw3",
98 + devicePort: "Gi0/3",
99 + address: "10.0.0.3",
100 + }
101 +
102 + cache.mu.RLock()
103 + data, ok := cache.snapshot()
104 + cache.mu.RUnlock()
105 +
106 + require.True(t, ok)
107 + require.Len(t, data.Actors, 2)
108 + require.Len(t, data.Links, 1)
109 + assert.Equal(t, "cdp", data.Links[0].Protocol)
110 + assert.Equal(t, "bidirectional", data.Links[0].Direction)
111 + assert.Equal(t, "Gi0/2", data.Links[0].Src.Attributes["if_name"])
112 + assert.Equal(t, "Gi0/3", data.Links[0].Dst.Attributes["port_id"])
113 +}
114 +
115 +func TestTopologyCache_UpdateTopologyProfileTags_STPBridgeAddressSetsSNMPIdentity(t *testing.T) {
116 + coll := newTestCollector(ddsnmp.DeviceConnectionInfo{Hostname: "10.20.4.2"})
117 + coll.topologyCache.localDevice.ChassisID = "10.20.4.2"
118 + coll.topologyCache.localDevice.ChassisIDType = "management_ip"
119 +
120 + coll.updateTopologyProfileTags([]*ddsnmp.ProfileMetrics{{
121 + DeviceMetadata: map[string]ddsnmp.MetaTag{
122 + tagBridgeBaseAddress: {Value: "\"18 FD 74 33 1A 9C \""},
123 + },
124 + }})
125 +
126 + require.Equal(t, "18:fd:74:33:1a:9c", coll.topologyCache.stpBaseBridgeAddress)
127 + require.Equal(t, "18:fd:74:33:1a:9c", coll.topologyCache.localDevice.ChassisID)
128 + require.Equal(t, "macAddress", coll.topologyCache.localDevice.ChassisIDType)
129 +}
130 +
131 +func TestTopologyCache_UpdateFdbEntry_STPBridgeAddressTagSetsSNMPIdentity(t *testing.T) {
132 + cache := newTopologyCache()
133 + cache.localDevice = topologyDevice{
134 + ChassisID: "10.20.4.2",
135 + ChassisIDType: "management_ip",
136 + }
137 +
138 + cache.updateFdbEntry(map[string]string{
139 + tagStpBaseBridgeAddress: "18 FD 74 33 1A 9C",
140 + tagFdbMac: "70:49:a2:65:72:cd",
141 + tagFdbBridgePort: "7",
142 + tagFdbStatus: "learned",
143 + })
144 +
145 + require.Equal(t, "18:fd:74:33:1a:9c", cache.stpBaseBridgeAddress)
146 + require.Equal(t, "18:fd:74:33:1a:9c", cache.localDevice.ChassisID)
147 + require.Equal(t, "macAddress", cache.localDevice.ChassisIDType)
148 +}
149 +
150 +func TestTopologyCache_BuildEngineObservation_DerivesBaseBridgeMACFromInterfacePhysAddress(t *testing.T) {
151 + cache := newTopologyCache()
152 + cache.updateTime = time.Now()
153 + cache.lastUpdate = cache.updateTime
154 + cache.agentID = "agent1"
155 + cache.localDevice = topologyDevice{
156 + ChassisID: "10.20.4.2",
157 + ChassisIDType: "management_ip",
158 + ManagementIP: "10.20.4.2",
159 + }
160 +
161 + cache.updateIfIndexByIP(map[string]string{
162 + tagTopoIPAddr: "10.20.4.2",
163 + tagTopoIfIndex: "1",
164 + })
165 + cache.updateIfNameByIndex(map[string]string{
166 + tagTopoIfIndex: "1",
167 + tagTopoIfName: "Port1",
168 + tagTopoIfPhys: "\"18 FD 74 33 1A 9C \"",
169 + })
170 +
171 + obs := cache.buildEngineObservation(cache.localDevice)
172 + require.Equal(t, "18:fd:74:33:1a:9c", obs.BaseBridgeAddress)
173 + require.Equal(t, "macAddress:18:fd:74:33:1a:9c", obs.DeviceID)
174 +}
175 +
176 +func TestTopologyCache_UpdateIfIndexByIP_CollectsAllSNMPDeviceIPs(t *testing.T) {
177 + cache := newTopologyCache()
178 +
179 + cache.updateIfIndexByIP(map[string]string{
180 + tagTopoIfIndex: "1",
181 + tagTopoIPAddr: "10.20.4.1",
182 + tagTopoIPMask: "255.255.255.0",
183 + })
184 + cache.updateIfIndexByIP(map[string]string{
185 + tagTopoIfIndex: "2",
186 + tagTopoIPAddr: "10.20.4.2",
187 + tagTopoIPMask: "255.255.255.0",
188 + })
189 + cache.updateIfIndexByIP(map[string]string{
190 + tagTopoIfIndex: "3",
191 + tagTopoIPAddr: "2001:db8::1",
192 + })
193 + // Duplicate row should not duplicate management address entries.
194 + cache.updateIfIndexByIP(map[string]string{
195 + tagTopoIfIndex: "1",
196 + tagTopoIPAddr: "10.20.4.1",
197 + tagTopoIPMask: "255.255.255.0",
198 + })
199 +
200 + require.Equal(t, "1", cache.ifIndexByIP["10.20.4.1"])
201 + require.Equal(t, "2", cache.ifIndexByIP["10.20.4.2"])
202 + require.Equal(t, "3", cache.ifIndexByIP["2001:db8::1"])
203 +
204 + addrs := cache.localDevice.ManagementAddresses
205 + require.Len(t, addrs, 3)
206 + require.Contains(t, addrs, topologyManagementAddress{
207 + Address: "10.20.4.1",
208 + AddressType: "ipv4",
209 + Source: "ip_mib",
210 + })
211 + require.Contains(t, addrs, topologyManagementAddress{
212 + Address: "10.20.4.2",
213 + AddressType: "ipv4",
214 + Source: "ip_mib",
215 + })
216 + require.Contains(t, addrs, topologyManagementAddress{
217 + Address: "2001:db8::1",
218 + AddressType: "ipv6",
219 + Source: "ip_mib",
220 + })
221 +}
222 +
223 +func TestTopologyCache_UpdateTopologyProfileTags_LLDPDoesNotOverrideExistingSNMPIdentity(t *testing.T) {
224 + coll := newTestCollector(ddsnmp.DeviceConnectionInfo{Hostname: "10.20.4.2"})
225 + coll.topologyCache.localDevice.ChassisID = "18:fd:74:33:1a:9c"
226 + coll.topologyCache.localDevice.ChassisIDType = "macAddress"
227 + coll.topologyCache.localDevice.SysName = "MikroTik-Switch"
228 +
229 + coll.updateTopologyProfileTags([]*ddsnmp.ProfileMetrics{{
230 + DeviceMetadata: map[string]ddsnmp.MetaTag{
231 + tagLldpLocChassisID: {Value: "00:11:22:33:44:55"},
232 + tagLldpLocChassisIDSubtype: {Value: "4"},
233 + tagLldpLocSysName: {Value: "lldp-name"},
234 + },
235 + }})
236 +
237 + require.Equal(t, "18:fd:74:33:1a:9c", coll.topologyCache.localDevice.ChassisID)
238 + require.Equal(t, "macAddress", coll.topologyCache.localDevice.ChassisIDType)
239 + require.Equal(t, "MikroTik-Switch", coll.topologyCache.localDevice.SysName)
240 +}
241 +
242 +func TestTopologyCache_CdpSnapshotHexAddress(t *testing.T) {
243 + cache := newTopologyCache()
244 + cache.updateTime = time.Now()
245 + cache.lastUpdate = cache.updateTime
246 + cache.agentID = "agent1"
247 + cache.localDevice = topologyDevice{
248 + ChassisID: "00:11:22:33:44:55",
249 + ChassisIDType: "macAddress",
250 + SysName: "sw1",
251 + ManagementIP: "10.0.0.1",
252 + }
253 +
254 + cache.cdpRemotes["2:1"] = &cdpRemote{
255 + ifIndex: "2",
256 + ifName: "Gi0/2",
257 + deviceID: "sw3",
258 + sysName: "sw3",
259 + devicePort: "Gi0/3",
260 + address: "0a000003",
261 + }
262 +
263 + cache.mu.RLock()
264 + data, ok := cache.snapshot()
265 + cache.mu.RUnlock()
266 +
267 + require.True(t, ok)
268 + require.Len(t, data.Links, 1)
269 + assert.Equal(t, "cdp", data.Links[0].Protocol)
270 + assert.Equal(t, "bidirectional", data.Links[0].Direction)
271 + assert.True(t, linkHasRawAddressMetric(data.Links[0], "0a000003"))
272 +
273 + remote := findDeviceActorBySysName(data, "sw3")
274 + require.NotNil(t, remote)
275 + assert.Contains(t, remote.Match.IPAddresses, "10.0.0.3")
276 +}
277 +
278 +func TestTopologyCache_UpdateLldpRemote_IgnoresRowsWithoutRemoteIndex(t *testing.T) {
279 + cache := newTopologyCache()
280 +
281 + cache.updateLldpRemote(map[string]string{
282 + tagLldpLocPortNum: "7",
283 + tagLldpRemSysName: "sw-b",
284 + })
285 +
286 + require.Empty(t, cache.lldpRemotes)
287 +}
288 +
289 +func TestTopologyCache_CdpSnapshotRawAddressWithoutIP(t *testing.T) {
290 + cache := newTopologyCache()
291 + cache.updateTime = time.Now()
292 + cache.lastUpdate = cache.updateTime
293 + cache.agentID = "agent1"
294 + cache.localDevice = topologyDevice{
295 + ChassisID: "00:11:22:33:44:55",
296 + ChassisIDType: "macAddress",
297 + SysName: "sw1",
298 + ManagementIP: "10.0.0.1",
299 + }
300 +
301 + cache.cdpRemotes["2:1"] = &cdpRemote{
302 + ifIndex: "2",
303 + ifName: "Gi0/2",
304 + deviceID: "edge-sw3",
305 + sysName: "edge-sw3",
306 + devicePort: "Gi0/3",
307 + address: "edge-sw3.mgmt.local",
308 + }
309 +
310 + cache.mu.RLock()
311 + data, ok := cache.snapshot()
312 + cache.mu.RUnlock()
313 +
314 + require.True(t, ok)
315 + require.Len(t, data.Links, 1)
316 + assert.Equal(t, "cdp", data.Links[0].Protocol)
317 + assert.Equal(t, "bidirectional", data.Links[0].Direction)
318 + assert.True(t, linkHasRawAddressMetric(data.Links[0], "edge-sw3.mgmt.local"))
319 +}
320 +
321 +func TestTopologyCache_SnapshotBidirectionalPairMetadata(t *testing.T) {
322 + cache := newTopologyCache()
323 + cache.updateTime = time.Now()
324 + cache.lastUpdate = cache.updateTime
325 + cache.agentID = "agent1"
326 + cache.localDevice = topologyDevice{
327 + ChassisID: "00:11:22:33:44:55",
328 + ChassisIDType: "macAddress",
329 + SysName: "sw1",
330 + ManagementIP: "10.0.0.1",
331 + }
332 + cache.lldpLocPorts["1"] = &lldpLocPort{
333 + portNum: "1",
334 + portID: "Gi0/1",
335 + portIDSubtype: "interfaceName",
336 + portDesc: "uplink",
337 + }
338 + cache.lldpRemotes["1:1"] = &lldpRemote{
339 + localPortNum: "1",
340 + remIndex: "1",
341 + chassisID: "aa:bb:cc:dd:ee:ff",
342 + chassisIDSubtype: "macAddress",
343 + portID: "Gi0/2",
344 + portIDSubtype: "interfaceName",
345 + portDesc: "downlink",
346 + sysName: "sw2",
347 + managementAddr: "10.0.0.2",
348 + }
349 +
350 + cache.mu.RLock()
351 + data, ok := cache.snapshot()
352 + cache.mu.RUnlock()
353 +
354 + require.True(t, ok)
355 + require.Len(t, data.Links, 1)
356 + link := data.Links[0]
357 + require.Equal(t, "lldp", link.Protocol)
358 + require.Equal(t, "bidirectional", link.Direction)
359 + require.Equal(t, true, link.Metrics["pair_consistent"])
360 + require.Equal(t, 1, data.Stats["links_bidirectional"])
361 + require.Equal(t, 0, data.Stats["links_unidirectional"])
362 +}
363 +
364 +func TestTopologyCache_SnapshotMergesRemoteIdentityAcrossProtocols(t *testing.T) {
365 + cache := newTopologyCache()
366 + cache.updateTime = time.Now()
367 + cache.lastUpdate = cache.updateTime
368 + cache.agentID = "agent1"
369 + cache.localDevice = topologyDevice{
370 + ChassisID: "00:11:22:33:44:55",
371 + ChassisIDType: "macAddress",
372 + SysName: "sw1",
373 + ManagementIP: "10.0.0.1",
374 + }
375 + cache.lldpLocPorts["1"] = &lldpLocPort{
376 + portNum: "1",
377 + portID: "Gi0/1",
378 + portIDSubtype: "interfaceName",
379 + }
380 + cache.lldpRemotes["1:1"] = &lldpRemote{
381 + localPortNum: "1",
382 + remIndex: "1",
383 + chassisID: "aa:bb:cc:dd:ee:ff",
384 + chassisIDSubtype: "macAddress",
385 + portID: "Gi0/2",
386 + portIDSubtype: "interfaceName",
387 + sysName: "sw2",
388 + managementAddr: "10.0.0.2",
389 + }
390 + cache.cdpRemotes["1:1"] = &cdpRemote{
391 + ifIndex: "1",
392 + ifName: "Gi0/1",
393 + deviceID: "sw2.domain.local",
394 + sysName: "sw2",
395 + devicePort: "Gi0/2",
396 + address: "10.0.0.2",
397 + }
398 +
399 + cache.mu.RLock()
400 + data, ok := cache.snapshot()
401 + cache.mu.RUnlock()
402 +
403 + require.True(t, ok)
404 + require.Equal(t, 2, countDeviceActors(data))
405 + require.NotNil(t, findLinkByProtocol(data, "lldp"))
406 + require.NotNil(t, findLinkByProtocol(data, "cdp"))
407 +
408 + remoteIdentityMatches := 0
409 + for _, actor := range data.Actors {
410 + if actor.ActorType != "device" {
411 + continue
412 + }
413 + if actor.Match.SysName == "sw2" ||
414 + containsString(actor.Match.ChassisIDs, "aa:bb:cc:dd:ee:ff") ||
415 + containsString(actor.Match.IPAddresses, "10.0.0.2") {
416 + remoteIdentityMatches++
417 + }
418 + }
419 + require.Equal(t, 1, remoteIdentityMatches)
420 +}
421 +
422 +func TestTopologyCache_LLDPManagementAddressesAndCaps(t *testing.T) {
423 + coll := newTestCollector(ddsnmp.DeviceConnectionInfo{
424 + Hostname: "10.0.0.1", SysObjectID: "1.3.6.1.4.1.9.1.1", SysName: "sw1", SysDescr: "Switch 1",
425 + })
426 + coll.updateTopologyProfileTags([]*ddsnmp.ProfileMetrics{{
427 + DeviceMetadata: map[string]ddsnmp.MetaTag{
428 + tagLldpLocChassisID: {Value: "00:11:22:33:44:55"},
429 + tagLldpLocChassisIDSubtype: {Value: "4"},
430 + tagLldpLocSysCapEnabled: {Value: "80"},
431 + tagLldpLocSysCapSupported: {Value: "80"},
432 + },
433 + }})
434 +
435 + coll.updateTopologyCacheEntry(ddsnmp.Metric{
436 + Name: metricLldpLocManAddrEntry,
437 + Tags: map[string]string{
438 + tagLldpLocMgmtAddrSubtype: "2",
439 + tagLldpLocMgmtAddr: "0a000001",
440 + tagLldpLocMgmtAddrIfID: "1",
441 + },
442 + })
443 + coll.updateTopologyCacheEntry(ddsnmp.Metric{
444 + Name: metricLldpRemManAddrEntry,
445 + Tags: map[string]string{
446 + tagLldpLocPortNum: "1",
447 + tagLldpRemIndex: "1",
448 + tagLldpRemMgmtAddrSubtype: "2",
449 + tagLldpRemMgmtAddr: "0a000002",
450 + },
451 + })
452 + coll.updateTopologyCacheEntry(ddsnmp.Metric{
453 + Name: metricLldpRemManAddrEntry,
454 + Tags: map[string]string{
455 + tagLldpLocPortNum: "1",
456 + tagLldpRemIndex: "1",
457 + tagLldpRemMgmtAddrSubtype: "1",
458 + tagLldpRemMgmtAddr: "31302e32302e342e3834", // "10.20.4.84" ASCII-hex
459 + },
460 + })
461 + coll.updateTopologyCacheEntry(ddsnmp.Metric{
462 + Name: metricLldpRemManAddrEntry,
463 + Tags: map[string]string{
464 + tagLldpLocPortNum: "1",
465 + tagLldpRemIndex: "1",
466 + tagLldpRemMgmtAddrSubtype: "1",
467 + tagLldpRemMgmtAddr: "666330303a663835333a6363643a653739333a3a31", // "fc00:f853:ccd:e793::1" ASCII-hex
468 + },
469 + })
470 + coll.updateTopologyCacheEntry(ddsnmp.Metric{
471 + Name: metricLldpRemManAddrEntry,
472 + Tags: map[string]string{
473 + tagLldpLocPortNum: "1",
474 + tagLldpRemIndex: "1",
475 + tagLldpRemMgmtAddrSubtype: "1",
476 + tagLldpRemMgmtAddrLen: "4",
477 + tagLldpRemMgmtAddrOctetPref + "1": "10",
478 + tagLldpRemMgmtAddrOctetPref + "2": "20",
479 + tagLldpRemMgmtAddrOctetPref + "3": "4",
480 + tagLldpRemMgmtAddrOctetPref + "4": "21",
481 + },
482 + })
483 + coll.updateTopologyCacheEntry(ddsnmp.Metric{
484 + Name: metricLldpRemEntry,
485 + Tags: map[string]string{
486 + tagLldpLocPortNum: "1",
487 + tagLldpRemIndex: "1",
488 + tagLldpRemChassisID: "aa:bb:cc:dd:ee:ff",
489 + tagLldpRemChassisIDSubtype: "4",
490 + tagLldpRemSysName: "sw2",
491 + tagLldpRemSysCapEnabled: "80",
492 + },
493 + })
494 +
495 + coll.finalizeTopologyCache()
496 +
497 + coll.topologyCache.mu.RLock()
498 + data, ok := coll.topologyCache.snapshot()
499 + coll.topologyCache.mu.RUnlock()
500 +
501 + require.True(t, ok)
502 + require.Greater(t, len(data.Actors), 1)
503 + require.True(t, actorHasAttributeList(data, "management_addresses"))
504 + require.True(t, actorHasAttributeList(data, "capabilities_enabled"))
505 + require.True(t, containsMgmtAddr(data, map[string]struct{}{
506 + "10.0.0.2": {},
507 + "10.20.4.21": {},
508 + "10.20.4.84": {},
509 + "fc00:f853:ccd:e793::1": {},
510 + }))
511 +}
512 +
513 +func TestTopologyCache_CDPManagementAddresses(t *testing.T) {
514 + cache := newTopologyCache()
515 + cache.updateTime = time.Now()
516 + cache.lastUpdate = cache.updateTime
517 + cache.agentID = "agent1"
518 + cache.localDevice = topologyDevice{
519 + ChassisID: "00:11:22:33:44:55",
520 + ChassisIDType: "macAddress",
521 + ManagementIP: "10.0.0.1",
522 + }
523 +
524 + cache.updateCdpRemote(map[string]string{
525 + tagCdpIfIndex: "2",
526 + tagCdpDeviceIndex: "1",
527 + tagCdpDeviceID: "sw3",
528 + tagCdpPrimaryMgmtAddrType: "1",
529 + tagCdpPrimaryMgmtAddr: "0a000003",
530 + tagCdpSecondaryMgmtAddrType: "1",
531 + tagCdpSecondaryMgmtAddr: "0a000004",
532 + })
533 +
534 + cache.mu.RLock()
535 + data, ok := cache.snapshot()
536 + cache.mu.RUnlock()
537 +
538 + require.True(t, ok)
539 + require.True(t, containsMgmtAddr(data, map[string]struct{}{"10.0.0.3": {}, "10.0.0.4": {}}))
540 +}
541 +
542 +func TestTopologyCache_FDBAndARPEnrichment(t *testing.T) {
543 + cache := newTopologyCache()
544 + cache.updateTime = time.Now()
545 + cache.lastUpdate = cache.updateTime
546 + cache.agentID = "agent1"
547 + cache.localDevice = topologyDevice{
548 + ChassisID: "00:11:22:33:44:55",
549 + ChassisIDType: "macAddress",
550 + ManagementIP: "10.0.0.1",
551 + }
552 +
553 + cache.updateIfNameByIndex(map[string]string{
554 + tagTopoIfIndex: "3",
555 + tagTopoIfName: "Port3",
556 + })
557 + cache.updateBridgePortMap(map[string]string{
558 + tagBridgeBasePort: "3",
559 + tagBridgeIfIndex: "3",
560 + })
561 + cache.updateFdbEntry(map[string]string{
562 + tagFdbMac: "7049a26572cd",
563 + tagFdbBridgePort: "3",
564 + tagFdbStatus: "learned",
565 + })
566 + cache.updateArpEntry(map[string]string{
567 + tagArpIfIndex: "3",
568 + tagArpIfName: "Port3",
569 + tagArpIP: "10.20.4.84",
570 + tagArpMac: "70:49:a2:65:72:cd",
571 + tagArpState: "reachable",
572 + })
573 +
574 + cache.mu.RLock()
575 + data, ok := cache.snapshot()
576 + cache.mu.RUnlock()
577 +
578 + require.True(t, ok)
579 + require.GreaterOrEqual(t, len(data.Actors), 2)
580 + require.Len(t, data.Links, 1)
581 +
582 + require.NotNil(t, findLinkByProtocol(data, "fdb"))
583 + require.Nil(t, findLinkByProtocol(data, "bridge"))
584 + require.Nil(t, findLinkByProtocol(data, "arp"))
585 +
586 + ep := findActorByMAC(data, "70:49:a2:65:72:cd")
587 + require.NotNil(t, ep)
588 + assert.Equal(t, "endpoint", ep.ActorType)
589 + assert.Contains(t, ep.Match.IPAddresses, "10.20.4.84")
590 + assert.Equal(t, "single_port_mac", ep.Attributes["attachment_source"])
591 + assert.Equal(t, "Port3", ep.Attributes["attached_port"])
592 +}
593 +
594 +func TestTopologyCache_Dot1qVLANEnrichment(t *testing.T) {
595 + cache := newTopologyCache()
596 + cache.updateTime = time.Now()
597 + cache.lastUpdate = cache.updateTime
598 + cache.agentID = "agent1"
599 + cache.localDevice = topologyDevice{
600 + ChassisID: "00:11:22:33:44:55",
601 + ChassisIDType: "macAddress",
602 + ManagementIP: "10.0.0.1",
603 + }
604 +
605 + cache.updateBridgePortMap(map[string]string{
606 + tagBridgeBasePort: "7",
607 + tagBridgeIfIndex: "3",
608 + })
609 + cache.updateFdbEntry(map[string]string{
610 + tagDot1qFdbID: "100",
611 + tagDot1qFdbMac: "7049a26572cd",
612 + tagDot1qFdbPort: "7",
613 + tagDot1qFdbStatus: "learned",
614 + })
615 + cache.updateDot1qVlanMap(map[string]string{
616 + tagDot1qVlanID: "200",
617 + tagDot1qVlanFdbID: "100",
618 + })
619 +
620 + obs := cache.buildEngineObservation(cache.localDevice)
621 + require.Len(t, obs.FDBEntries, 1)
622 + require.Equal(t, "200", obs.FDBEntries[0].VLANID)
623 + require.Equal(t, "70:49:a2:65:72:cd", obs.FDBEntries[0].MAC)
624 +}
625 +
626 +func TestTopologyCache_VTPVLANNameEnrichment(t *testing.T) {
627 + cache := newTopologyCache()
628 + cache.updateTime = time.Now()
629 + cache.lastUpdate = cache.updateTime
630 + cache.agentID = "agent1"
631 + cache.localDevice = topologyDevice{
632 + ChassisID: "00:11:22:33:44:55",
633 + ChassisIDType: "macAddress",
634 + ManagementIP: "10.0.0.1",
635 + }
636 +
637 + cache.updateBridgePortMap(map[string]string{
638 + tagBridgeBasePort: "7",
639 + tagBridgeIfIndex: "3",
640 + })
641 + cache.updateDot1qVlanMap(map[string]string{
642 + tagDot1qVlanID: "200",
643 + tagDot1qVlanFdbID: "100",
644 + })
645 + cache.updateVtpVlanEntry(map[string]string{
646 + tagVtpVlanIndex: "200",
647 + tagVtpVlanState: "operational",
648 + tagVtpVlanType: "1",
649 + tagVtpVlanName: "servers",
650 + })
651 + cache.updateFdbEntry(map[string]string{
652 + tagDot1qFdbID: "100",
653 + tagDot1qFdbMac: "7049a26572cd",
654 + tagDot1qFdbPort: "7",
655 + tagDot1qFdbStatus: "learned",
656 + })
657 +
658 + obs := cache.buildEngineObservation(cache.localDevice)
659 + require.Len(t, obs.FDBEntries, 1)
660 + require.Equal(t, "200", obs.FDBEntries[0].VLANID)
661 + require.Equal(t, "servers", obs.FDBEntries[0].VLANName)
662 +}
663 +
664 +func TestTopologyCache_STPObservation(t *testing.T) {
665 + cache := newTopologyCache()
666 + cache.updateTime = time.Now()
667 + cache.lastUpdate = cache.updateTime
668 + cache.agentID = "agent1"
669 + cache.localDevice = topologyDevice{
670 + ChassisID: "00:11:22:33:44:55",
671 + ChassisIDType: "macAddress",
672 + ManagementIP: "10.0.0.1",
673 + }
674 + cache.stpBaseBridgeAddress = "00:11:22:33:44:55"
675 + cache.updateBridgePortMap(map[string]string{
676 + tagBridgeBasePort: "3",
677 + tagBridgeIfIndex: "3",
678 + })
679 + cache.updateIfNameByIndex(map[string]string{
680 + tagTopoIfIndex: "3",
681 + tagTopoIfName: "Port3",
682 + })
683 + cache.updateStpPortEntry(map[string]string{
684 + tagStpPort: "3",
685 + tagStpPortState: "forwarding",
686 + tagStpPortEnable: "enabled",
687 + tagStpPortPathCost: "4",
688 + tagStpPortDesignatedBridge: "800066778899aabb",
689 + tagStpPortDesignatedPort: "8001",
690 + })
691 +
692 + obs := cache.buildEngineObservation(cache.localDevice)
693 + require.Equal(t, "00:11:22:33:44:55", obs.BaseBridgeAddress)
694 + require.Len(t, obs.STPPorts, 1)
695 + require.Equal(t, "3", obs.STPPorts[0].Port)
696 + require.Equal(t, 3, obs.STPPorts[0].IfIndex)
697 + require.Equal(t, "Port3", obs.STPPorts[0].IfName)
698 + require.Equal(t, "66:77:88:99:aa:bb", obs.STPPorts[0].DesignatedBridge)
699 +}
700 +
701 +func TestTopologyCache_BuildEngineObservation_DerivesBaseBridgeMACFromFDBSelfEntries(t *testing.T) {
702 + cache := newTopologyCache()
703 + cache.updateTime = time.Now()
704 + cache.lastUpdate = cache.updateTime
705 + cache.agentID = "agent1"
706 + cache.localDevice = topologyDevice{
707 + ChassisID: "10.20.4.2",
708 + ChassisIDType: "management_ip",
709 + ManagementIP: "10.20.4.2",
710 + }
711 + // Device reports FDB rows but no LLDP local chassis; derive identity from self FDB MAC.
712 + cache.updateFdbEntry(map[string]string{
713 + tagFdbMac: "18:fd:74:33:1a:9c",
714 + tagFdbBridgePort: "1",
715 + tagFdbStatus: "self",
716 + })
717 +
718 + obs := cache.buildEngineObservation(cache.localDevice)
719 + require.Equal(t, "18:fd:74:33:1a:9c", obs.BaseBridgeAddress)
720 + require.Equal(t, "macAddress:18:fd:74:33:1a:9c", obs.DeviceID)
721 +}
722 +
723 +func TestTopologyCache_InterfaceStatusObservation(t *testing.T) {
724 + cache := newTopologyCache()
725 + cache.updateTime = time.Now()
726 + cache.lastUpdate = cache.updateTime
727 + cache.agentID = "agent1"
728 + cache.localDevice = topologyDevice{
729 + ChassisID: "00:11:22:33:44:55",
730 + ChassisIDType: "macAddress",
731 + ManagementIP: "10.0.0.1",
732 + }
733 +
734 + cache.updateIfNameByIndex(map[string]string{
735 + tagTopoIfIndex: "3",
736 + tagTopoIfName: "Port3",
737 + tagTopoIfAdmin: "up",
738 + tagTopoIfOper: "lowerLayerDown",
739 + })
740 +
741 + obs := cache.buildEngineObservation(cache.localDevice)
742 + require.Len(t, obs.Interfaces, 1)
743 + require.Equal(t, "Port3", obs.Interfaces[0].IfName)
744 + require.Equal(t, "up", obs.Interfaces[0].AdminStatus)
745 + require.Equal(t, "lowerLayerDown", obs.Interfaces[0].OperStatus)
746 +}
747 +
748 +func TestTopologyCache_InterfaceStatusObservation_FallsBackToIfIndexWhenIfNameMissing(t *testing.T) {
749 + cache := newTopologyCache()
750 + cache.updateTime = time.Now()
751 + cache.lastUpdate = cache.updateTime
752 + cache.agentID = "agent1"
753 + cache.localDevice = topologyDevice{
754 + ChassisID: "00:11:22:33:44:55",
755 + ChassisIDType: "macAddress",
756 + ManagementIP: "10.0.0.1",
757 + }
758 +
759 + cache.updateIfNameByIndex(map[string]string{
760 + tagTopoIfIndex: "7",
761 + tagTopoIfType: "ethernetCsmacd(6)",
762 + tagTopoIfAdmin: "up(1)",
763 + tagTopoIfOper: "up(1)",
764 + })
765 +
766 + obs := cache.buildEngineObservation(cache.localDevice)
767 + require.Len(t, obs.Interfaces, 1)
768 + require.Equal(t, 7, obs.Interfaces[0].IfIndex)
769 + require.Equal(t, "7", obs.Interfaces[0].IfName)
770 + require.Equal(t, "7", obs.Interfaces[0].IfDescr)
771 + require.Equal(t, "ethernetcsmacd", obs.Interfaces[0].InterfaceType)
772 + require.Equal(t, "up", obs.Interfaces[0].AdminStatus)
773 + require.Equal(t, "up", obs.Interfaces[0].OperStatus)
774 +}
775 +
776 +func TestStpBridgeAddressToMAC_ParsesAndRejectsSentinels(t *testing.T) {
777 + tests := []struct {
778 + name string
779 + in string
780 + status stpBridgeIDStatus
781 + mac string
782 + }{
783 + {
784 + name: "bridge-id-hex",
785 + in: "800066778899aabb",
786 + status: stpBridgeIDValid,
787 + mac: "66:77:88:99:aa:bb",
788 + },
789 + {
790 + name: "priority-bridge-id",
791 + in: "32768-66.77.88.99.aa.bb",
792 + status: stpBridgeIDValid,
793 + mac: "66:77:88:99:aa:bb",
794 + },
795 + {
796 + name: "quoted-hex-string",
797 + in: "\"18 FD 74 33 1A 9C \"",
798 + status: stpBridgeIDValid,
799 + mac: "18:fd:74:33:1a:9c",
800 + },
801 + {
802 + name: "hex-string-prefix",
803 + in: "Hex-STRING: 18 FD 74 33 1A 9C",
804 + status: stpBridgeIDValid,
805 + mac: "18:fd:74:33:1a:9c",
806 + },
807 + {
808 + name: "sentinel-text-empty",
809 + in: "0-00.00.00.00.00.00",
810 + status: stpBridgeIDEmpty,
811 + mac: "",
812 + },
813 + {
814 + name: "sentinel-hex-empty",
815 + in: "302d30302e30302e30302e30302e30302e3030",
816 + status: stpBridgeIDEmpty,
817 + mac: "",
818 + },
819 + {
820 + name: "invalid",
821 + in: "not-a-bridge-id",
822 + status: stpBridgeIDInvalid,
823 + mac: "",
824 + },
825 + }
826 +
827 + for _, tt := range tests {
828 + t.Run(tt.name, func(t *testing.T) {
829 + mac, status := parseSTPBridgeID(tt.in, 0)
830 + require.Equal(t, tt.status, status)
831 + require.Equal(t, tt.mac, mac)
832 + require.Equal(t, tt.mac, stpBridgeAddressToMAC(tt.in))
833 + })
834 + }
835 +}
836 +
837 +func TestTopologyCache_VTPVLANContexts_SortedAndValidated(t *testing.T) {
838 + cache := newTopologyCache()
839 + cache.vlanIDToName["200"] = "servers"
840 + cache.vlanIDToName["10"] = "users"
841 + cache.vlanIDToName["abc"] = "invalid"
842 + cache.vlanIDToName[""] = "invalid-empty"
843 +
844 + contexts := cache.vtpVLANContexts()
845 + require.Len(t, contexts, 2)
846 + require.Equal(t, "10", contexts[0].vlanID)
847 + require.Equal(t, "users", contexts[0].vlanName)
848 + require.Equal(t, "200", contexts[1].vlanID)
849 + require.Equal(t, "servers", contexts[1].vlanName)
850 +}
851 +
852 +func TestTopologyCache_VLANContextFDBEntriesRemainDistinct(t *testing.T) {
853 + cache := newTopologyCache()
854 + cache.updateBridgePortMap(map[string]string{
855 + tagBridgeBasePort: "7",
856 + tagBridgeIfIndex: "3",
857 + })
858 + cache.updateIfNameByIndex(map[string]string{
859 + tagTopoIfIndex: "3",
860 + tagTopoIfName: "Port3",
861 + })
862 +
863 + cache.updateFdbEntry(map[string]string{
864 + tagFdbMac: "70:49:a2:65:72:cd",
865 + tagFdbBridgePort: "7",
866 + tagFdbStatus: "learned",
867 + tagTopologyContextVLANID: "10",
868 + tagTopologyContextVLANName: "users",
869 + })
870 + cache.updateFdbEntry(map[string]string{
871 + tagFdbMac: "70:49:a2:65:72:cd",
872 + tagFdbBridgePort: "7",
873 + tagFdbStatus: "learned",
874 + tagTopologyContextVLANID: "200",
875 + tagTopologyContextVLANName: "servers",
876 + })
877 +
878 + obs := cache.buildEngineObservation(cache.localDevice)
879 + require.Len(t, obs.FDBEntries, 2)
880 + require.Equal(t, "10", obs.FDBEntries[0].VLANID)
881 + require.Equal(t, "users", obs.FDBEntries[0].VLANName)
882 + require.Equal(t, "200", obs.FDBEntries[1].VLANID)
883 + require.Equal(t, "servers", obs.FDBEntries[1].VLANName)
884 +}
885 +
886 +func TestPickManagementIP_DeterministicAcrossInputOrder(t *testing.T) {
887 + addrsA := []topologyManagementAddress{
888 + {Address: "10.20.4.60", Source: "src-a"},
889 + {Address: "10.20.4.205", Source: "src-b"},
890 + }
891 + addrsB := []topologyManagementAddress{
892 + {Address: "10.20.4.205", Source: "src-b"},
893 + {Address: "10.20.4.60", Source: "src-a"},
894 + }
895 +
896 + require.Equal(t, "10.20.4.205", pickManagementIP(addrsA))
897 + require.Equal(t, pickManagementIP(addrsA), pickManagementIP(addrsB))
898 +
899 + rawA := []topologyManagementAddress{
900 + {Address: "zeta"},
901 + {Address: "alpha"},
902 + }
903 + rawB := []topologyManagementAddress{
904 + {Address: "alpha"},
905 + {Address: "zeta"},
906 + }
907 + require.Equal(t, "alpha", pickManagementIP(rawA))
908 + require.Equal(t, pickManagementIP(rawA), pickManagementIP(rawB))
909 +}
910 +
911 +func TestTopologyCache_SnapshotDeterministicEndpointIPSelection(t *testing.T) {
912 + cache := newTopologyCache()
913 + cache.updateTime = time.Now()
914 + cache.lastUpdate = cache.updateTime
915 + cache.agentID = "agent1"
916 + cache.localDevice = topologyDevice{
917 + ChassisID: "00:11:22:33:44:55",
918 + ChassisIDType: "macAddress",
919 + ManagementIP: "10.0.0.1",
920 + }
921 +
922 + cache.updateIfNameByIndex(map[string]string{
923 + tagTopoIfIndex: "3",
924 + tagTopoIfName: "Port3",
925 + })
926 + cache.updateBridgePortMap(map[string]string{
927 + tagBridgeBasePort: "3",
928 + tagBridgeIfIndex: "3",
929 + })
930 + cache.updateFdbEntry(map[string]string{
931 + tagFdbMac: "d8:5e:d3:0e:c5:e6",
932 + tagFdbBridgePort: "3",
933 + tagFdbStatus: "learned",
934 + })
935 + cache.updateArpEntry(map[string]string{
936 + tagArpIfIndex: "3",
937 + tagArpIfName: "Port3",
938 + tagArpIP: "10.20.4.60",
939 + tagArpMac: "d8:5e:d3:0e:c5:e6",
940 + tagArpState: "reachable",
941 + })
942 + cache.updateArpEntry(map[string]string{
943 + tagArpIfIndex: "3",
944 + tagArpIfName: "Port3",
945 + tagArpIP: "10.20.4.205",
946 + tagArpMac: "d8:5e:d3:0e:c5:e6",
947 + tagArpState: "reachable",
948 + })
949 +
950 + expectedIPs := []string{"10.20.4.205", "10.20.4.60"}
951 + for range 25 {
952 + cache.mu.RLock()
953 + data, ok := cache.snapshot()
954 + cache.mu.RUnlock()
955 +
956 + require.True(t, ok)
957 + ep := findActorByMAC(data, "d8:5e:d3:0e:c5:e6")
958 + require.NotNil(t, ep)
959 + require.Equal(t, expectedIPs, ep.Match.IPAddresses)
960 + }
961 +}
962 +
963 +func TestTopologyCache_SnapshotDeterministicOrdering(t *testing.T) {
964 + cache := newTopologyCache()
965 + cache.updateTime = time.Now()
966 + cache.lastUpdate = cache.updateTime
967 + cache.agentID = "agent1"
968 + cache.localDevice = topologyDevice{
969 + ChassisID: "00:11:22:33:44:55",
970 + ChassisIDType: "macAddress",
971 + ManagementIP: "10.0.0.1",
972 + }
973 + cache.lldpLocPorts["2"] = &lldpLocPort{portNum: "2", portID: "Gi0/2", portIDSubtype: "5"}
974 + cache.lldpLocPorts["1"] = &lldpLocPort{portNum: "1", portID: "Gi0/1", portIDSubtype: "5"}
975 + cache.lldpRemotes["2:1"] = &lldpRemote{
976 + localPortNum: "2",
977 + remIndex: "1",
978 + chassisID: "00:00:00:00:00:22",
979 + chassisIDSubtype: "macAddress",
980 + sysName: "sw2",
981 + }
982 + cache.lldpRemotes["1:1"] = &lldpRemote{
983 + localPortNum: "1",
984 + remIndex: "1",
985 + chassisID: "00:00:00:00:00:11",
986 + chassisIDSubtype: "macAddress",
987 + sysName: "sw1",
988 + }
989 + cache.cdpRemotes["3:1"] = &cdpRemote{
990 + ifIndex: "3",
991 + ifName: "Gi0/3",
992 + deviceID: "sw3",
993 + devicePort: "Gi0/4",
994 + address: "10.0.0.3",
995 + }
996 +
997 + cache.mu.RLock()
998 + data, ok := cache.snapshot()
999 + cache.mu.RUnlock()
1000 +
1001 + require.True(t, ok)
1002 + require.NotEmpty(t, data.Actors)
1003 + require.NotEmpty(t, data.Links)
1004 +
1005 + actorOrder := make([]string, 0, len(data.Actors))
1006 + for _, actor := range data.Actors {
1007 + actorOrder = append(actorOrder, actor.ActorType+"|"+canonicalMatchKey(actor.Match))
1008 + }
1009 + expectedActorOrder := append([]string(nil), actorOrder...)
1010 + sort.Strings(expectedActorOrder)
1011 + assert.Equal(t, expectedActorOrder, actorOrder)
1012 +
1013 + linkOrder := make([]string, 0, len(data.Links))
1014 + for _, link := range data.Links {
1015 + linkOrder = append(linkOrder, topologyLinkSortKey(link))
1016 + }
1017 + expectedLinkOrder := append([]string(nil), linkOrder...)
1018 + sort.Strings(expectedLinkOrder)
1019 + assert.Equal(t, expectedLinkOrder, linkOrder)
1020 +}
1021 +
1022 +func TestTopologyCache_BuildEngineObservations_SeparatesProtocolSpecificRemoteObservations(t *testing.T) {
1023 + cache := newTopologyCache()
1024 + cache.localDevice = topologyDevice{
1025 + ChassisID: "00:11:22:33:44:55",
1026 + ChassisIDType: "macAddress",
1027 + SysName: "sw-a",
1028 + ManagementIP: "10.0.0.1",
1029 + }
1030 + cache.lldpLocPorts["1"] = &lldpLocPort{
1031 + portNum: "1",
1032 + portID: "Gi0/1",
1033 + portIDSubtype: "interfaceName",
1034 + portDesc: "uplink",
1035 + }
1036 + cache.lldpRemotes["1:1"] = &lldpRemote{
1037 + localPortNum: "1",
1038 + remIndex: "1",
1039 + chassisID: "aa:bb:cc:dd:ee:ff",
1040 + chassisIDSubtype: "macAddress",
1041 + portID: "Gi0/2",
1042 + portIDSubtype: "interfaceName",
1043 + portDesc: "downlink",
1044 + sysName: "sw-b",
1045 + managementAddr: "10.0.0.2",
1046 + }
1047 + cache.cdpRemotes["1:1"] = &cdpRemote{
1048 + ifIndex: "1",
1049 + ifName: "Gi0/1",
1050 + deviceID: "sw-b",
1051 + sysName: "switch-b",
1052 + devicePort: "Gi0/2",
1053 + address: "10.0.0.2",
1054 + }
1055 +
1056 + observations, localDeviceID := cache.buildEngineObservations(cache.localDevice)
1057 + require.Equal(t, "macAddress:00:11:22:33:44:55", localDeviceID)
1058 + require.Len(t, observations, 3)
1059 + require.Equal(t, localDeviceID, observations[0].DeviceID)
1060 +
1061 + var lldpObservation *topologyengine.L2Observation
1062 + var cdpObservation *topologyengine.L2Observation
1063 + for i := 1; i < len(observations); i++ {
1064 + observation := &observations[i]
1065 + switch {
1066 + case len(observation.LLDPRemotes) > 0:
1067 + lldpObservation = observation
1068 + case len(observation.CDPRemotes) > 0:
1069 + cdpObservation = observation
1070 + }
1071 + }
1072 +
1073 + require.NotNil(t, lldpObservation)
1074 + require.NotNil(t, cdpObservation)
1075 + require.Equal(t, lldpObservation.DeviceID, cdpObservation.DeviceID)
1076 + require.Equal(t, "macAddress:aa:bb:cc:dd:ee:ff", lldpObservation.DeviceID)
1077 + require.Equal(t, "10.0.0.2", lldpObservation.ManagementIP)
1078 + require.Equal(t, "10.0.0.2", cdpObservation.ManagementIP)
1079 + require.Equal(t, "sw-b", lldpObservation.Hostname)
1080 + require.Equal(t, "switch-b", cdpObservation.Hostname)
1081 + require.Len(t, lldpObservation.LLDPRemotes, 1)
1082 + require.Len(t, cdpObservation.CDPRemotes, 1)
1083 +}
1084 +
1085 +func TestTopologyObservationIdentityResolver_ReusesStableRemoteIdentityAcrossSignals(t *testing.T) {
1086 + resolver := newTopologyObservationIdentityResolver(topologyengine.L2Observation{
1087 + DeviceID: "macAddress:00:11:22:33:44:55",
1088 + Hostname: "sw-a",
1089 + ManagementIP: "10.0.0.1",
1090 + ChassisID: "00:11:22:33:44:55",
1091 + })
1092 +
1093 + idFromLLDP := resolver.resolve([]string{"sw-b"}, "AA-BB-CC-DD-EE-FF", "macAddress", "10.0.0.2")
1094 + idFromCDP := resolver.resolve([]string{"switch-b", "sw-b"}, "", "", "10.0.0.2")
1095 + idFromMgmtIP := resolver.resolve([]string{"switch-b"}, "", "", "10.0.0.2")
1096 +
1097 + require.Equal(t, "macAddress:aa:bb:cc:dd:ee:ff", idFromLLDP)
1098 + require.Equal(t, idFromLLDP, idFromCDP)
1099 + require.Equal(t, idFromLLDP, idFromMgmtIP)
1100 +}
1101 +
1102 +func TestDecodePrintableASCII_HumanReadableHex(t *testing.T) {
1103 + bs, err := decodeHexString("766d7831")
1104 + require.NoError(t, err)
1105 +
1106 + decoded := decodePrintableASCII(bs)
1107 + require.Equal(t, "vmx1", decoded)
1108 +}
1109 +
1110 +func TestDecodePrintableASCII_HexValueIsNotNumeric(t *testing.T) {
1111 + bs, err := decodeHexString("766d7831")
1112 + require.NoError(t, err)
1113 +
1114 + decoded := decodePrintableASCII(bs)
1115 + assert.NotRegexp(t, "^[0-9]+$", decoded)
1116 +}
1117 +
1118 +func TestNormalizeInterfaceAdminStatusAcceptsEnumStrings(t *testing.T) {
1119 + tests := []struct {
1120 + in string
1121 + want string
1122 + }{
1123 + {in: "up(1)", want: "up"},
1124 + {in: "down(2)", want: "down"},
1125 + {in: "testing(3)", want: "testing"},
1126 + {in: "UP (1)", want: "up"},
1127 + {in: "invalid(9)", want: ""},
1128 + }
1129 +
1130 + for _, tc := range tests {
1131 + assert.Equal(t, tc.want, normalizeInterfaceAdminStatus(tc.in), tc.in)
1132 + }
1133 +}
1134 +
1135 +func TestNormalizeInterfaceOperStatusAcceptsEnumStrings(t *testing.T) {
1136 + tests := []struct {
1137 + in string
1138 + want string
1139 + }{
1140 + {in: "up(1)", want: "up"},
1141 + {in: "down(2)", want: "down"},
1142 + {in: "testing(3)", want: "testing"},
1143 + {in: "unknown(4)", want: "unknown"},
1144 + {in: "dormant(5)", want: "dormant"},
1145 + {in: "notPresent(6)", want: "notPresent"},
1146 + {in: "lowerLayerDown(7)", want: "lowerLayerDown"},
1147 + {in: "LOWERLAYERDOWN (7)", want: "lowerLayerDown"},
1148 + {in: "invalid(9)", want: ""},
1149 + }
1150 +
1151 + for _, tc := range tests {
1152 + assert.Equal(t, tc.want, normalizeInterfaceOperStatus(tc.in), tc.in)
1153 + }
1154 +}
1155 +
1156 +func TestNormalizeInterfaceTypeAcceptsEnumStrings(t *testing.T) {
1157 + tests := []struct {
1158 + in string
1159 + want string
1160 + }{
1161 + {in: "ethernetCsmacd(6)", want: "ethernetcsmacd"},
1162 + {in: "6", want: "ethernetcsmacd"},
1163 + {in: "ieee8023adLag(161)", want: "ieee8023adlag"},
1164 + {in: "161", want: "ieee8023adlag"},
1165 + {in: "l2vlan(135)", want: "l2vlan"},
1166 + {in: "", want: ""},
1167 + }
1168 +
1169 + for _, tc := range tests {
1170 + assert.Equal(t, tc.want, normalizeInterfaceType(tc.in), tc.in)
1171 + }
1172 +}
1173 +
1174 +func TestTopologyCache_UpdateIfNameByIndex_StoresStatusWithoutIfName(t *testing.T) {
1175 + cache := newTopologyCache()
1176 +
1177 + cache.updateIfNameByIndex(map[string]string{
1178 + tagTopoIfIndex: "7",
1179 + tagTopoIfName: "swp07",
1180 + })
1181 +
1182 + cache.updateIfNameByIndex(map[string]string{
1183 + tagTopoIfIndex: "7",
1184 + tagTopoIfAdmin: "up(1)",
1185 + tagTopoIfOper: "down(2)",
1186 + })
1187 +
1188 + require.Equal(t, "swp07", cache.ifNamesByIndex["7"])
1189 + require.Equal(t, "up", cache.ifStatusByIndex["7"].admin)
1190 + require.Equal(t, "down", cache.ifStatusByIndex["7"].oper)
1191 +}
1192 +
1193 +func TestTopologyCache_UpdateIfNameByIndex_StoresExtendedInterfaceFields(t *testing.T) {
1194 + cache := newTopologyCache()
1195 +
1196 + cache.updateIfNameByIndex(map[string]string{
1197 + tagTopoIfIndex: "9",
1198 + tagTopoIfName: "swp09",
1199 + tagTopoIfAlias: "uplink-core",
1200 + tagTopoIfDescr: "Uplink Port 9",
1201 + tagTopoIfPhys: "AA BB CC DD EE FF",
1202 + tagTopoIfHigh: "1000",
1203 + tagTopoIfLast: "34567",
1204 + tagTopoIfDuplex: "3",
1205 + })
1206 +
1207 + status := cache.ifStatusByIndex["9"]
1208 + require.Equal(t, "Uplink Port 9", status.ifDescr)
1209 + require.Equal(t, "uplink-core", status.ifAlias)
1210 + require.Equal(t, "aa:bb:cc:dd:ee:ff", status.mac)
1211 + require.EqualValues(t, 1000_000_000, status.speedBps)
1212 + require.EqualValues(t, 34567, status.lastChange)
1213 + require.Equal(t, "full", status.duplex)
1214 +}
1215 +
1216 +func TestBuildLocalTopologyDevice_IncludesSysContactVendorAndModel(t *testing.T) {
1217 + dev := ddsnmp.DeviceConnectionInfo{
1218 + Hostname: "10.0.0.1",
1219 + SysObjectID: "1.3.6.1.4.1.9.1.1",
1220 + SysName: "sw1",
1221 + SysDescr: "Switch 1",
1222 + SysContact: "ops@example.net",
1223 + SysLocation: "dc1",
1224 + Vendor: "Cisco",
1225 + Model: "C9300-24T",
1226 + VnodeGUID: "11111111-1111-1111-1111-111111111111",
1227 + VnodeLabels: map[string]string{
1228 + "serial": "SN-12345",
1229 + "version": "17.9.4",
1230 + "firmware": "1.2.3",
1231 + "hardware_rev": "A1",
1232 + "sys_uptime": "123456",
1233 + },
1234 + }
1235 +
1236 + device := buildLocalTopologyDevice(dev)
1237 + require.Equal(t, "1.3.6.1.4.1.9.1.1", device.SysObjectID)
1238 + require.Equal(t, "sw1", device.SysName)
1239 + require.Equal(t, "Switch 1", device.SysDescr)
1240 + require.Equal(t, "ops@example.net", device.SysContact)
1241 + require.Equal(t, "dc1", device.SysLocation)
1242 + require.Equal(t, "Cisco", device.Vendor)
1243 + require.Equal(t, "C9300-24T", device.Model)
1244 + require.Equal(t, "SN-12345", device.SerialNumber)
1245 + require.Equal(t, "17.9.4", device.SoftwareVersion)
1246 + require.Equal(t, "1.2.3", device.FirmwareVersion)
1247 + require.Equal(t, "A1", device.HardwareVersion)
1248 + require.EqualValues(t, 123456, device.SysUptime)
1249 + require.Equal(t, "11111111-1111-1111-1111-111111111111", device.NetdataHostID)
1250 + require.Equal(t, topologyProfileChartIDPrefix, device.ChartIDPrefix)
1251 + require.Equal(t, topologyProfileChartContextPrefix, device.ChartContextPrefix)
1252 +}
1253 +
1254 +func TestCollector_UpdateTopologyScalarMetric_StoresSysUptime(t *testing.T) {
1255 + for _, metricName := range []string{"sysUpTime", "systemUptime"} {
1256 + t.Run(metricName, func(t *testing.T) {
1257 + coll := &Collector{
1258 + topologyCache: newTopologyCache(),
1259 + }
1260 + coll.topologyCache.localDevice = topologyDevice{}
1261 +
1262 + coll.updateTopologyScalarMetric(ddsnmp.Metric{
1263 + Name: metricName,
1264 + Value: 4321,
1265 + })
1266 +
1267 + require.EqualValues(t, 4321, coll.topologyCache.localDevice.SysUptime)
1268 + require.Equal(t, "4321", coll.topologyCache.localDevice.Labels["sys_uptime"])
1269 + })
1270 + }
1271 +}
1272 +
1273 +func TestCollector_IngestTopologyProfileMetrics_IncludesHiddenMetrics(t *testing.T) {
1274 + coll := &Collector{
1275 + topologyCache: newTopologyCache(),
1276 + }
1277 +
1278 + coll.ingestTopologyProfileMetrics([]*ddsnmp.ProfileMetrics{
1279 + {
1280 + HiddenMetrics: []ddsnmp.Metric{
1281 + {
1282 + Name: metricLldpLocPortEntry,
1283 + Tags: map[string]string{
1284 + tagLldpLocPortNum: "7",
1285 + tagLldpLocPortID: "Gi1/0/7",
1286 + tagLldpLocPortIDSubtype: "5",
1287 + tagLldpLocPortDesc: "Gi1/0/7",
1288 + },
1289 + },
1290 + {
1291 + Name: metricLldpRemEntry,
1292 + Tags: map[string]string{
1293 + tagLldpLocPortNum: "7",
1294 + tagLldpRemIndex: "1",
1295 + tagLldpRemChassisID: "001122334455",
1296 + tagLldpRemChassisIDSubtype: "4",
1297 + tagLldpRemPortID: "Gi1/0/1",
1298 + tagLldpRemPortIDSubtype: "5",
1299 + tagLldpRemSysName: "edge-sw1",
1300 + tagLldpRemSysCapEnabled: "28",
1301 + },
1302 + },
1303 + },
1304 + Metrics: []ddsnmp.Metric{
1305 + {
1306 + Name: "systemUptime",
1307 + Value: 1234,
1308 + },
1309 + },
1310 + },
1311 + })
1312 +
1313 + require.Contains(t, coll.topologyCache.lldpLocPorts, "7")
1314 + require.Contains(t, coll.topologyCache.lldpRemotes, "7:1")
1315 + require.EqualValues(t, 1234, coll.topologyCache.localDevice.SysUptime)
1316 + require.Equal(t, "1234", coll.topologyCache.localDevice.Labels["sys_uptime"])
1317 +}
1318 +
1319 +func TestBuildLocalTopologyDevice_MapsVersionToSoftwareOnly(t *testing.T) {
1320 + dev := ddsnmp.DeviceConnectionInfo{
1321 + Hostname: "10.0.0.2",
1322 + VnodeGUID: "22222222-2222-2222-2222-222222222222",
1323 + VnodeLabels: map[string]string{
1324 + "version": "9.1.2",
1325 + },
1326 + }
1327 +
1328 + device := buildLocalTopologyDevice(dev)
1329 + require.Equal(t, "9.1.2", device.SoftwareVersion)
1330 + require.Empty(t, device.FirmwareVersion)
1331 + require.Empty(t, device.HardwareVersion)
1332 +}
1333 +
1334 +func TestAugmentLocalActorFromCache_InjectsIdentityFields(t *testing.T) {
1335 + data := topologyData{
1336 + Actors: []topologyActor{
1337 + {
1338 + ActorType: "device",
1339 + Match: topologyMatch{
1340 + SysName: "sw1",
1341 + ChassisIDs: []string{"00:11:22:33:44:55"},
1342 + IPAddresses: []string{"10.0.0.1"},
1343 + },
1344 + Attributes: map[string]any{
1345 + "vendor_derived": "Acme Derived",
1346 + "vendor_derived_source": "mac_oui",
1347 + "vendor_derived_confidence": "low",
1348 + "vendor_derived_match_prefix": "00:11:22",
1349 + "if_statuses": []map[string]any{
1350 + {
1351 + "if_index": 1,
1352 + "if_name": "swp07",
1353 + },
1354 + },
1355 + },
1356 + },
1357 + },
1358 + }
1359 +
1360 + local := topologyDevice{
1361 + ChassisID: "00:11:22:33:44:55",
1362 + SysName: "sw1",
1363 + SysDescr: "Switch 1",
1364 + SysContact: "ops@example.net",
1365 + SysLocation: "dc1",
1366 + SysUptime: 987654,
1367 + Vendor: "Cisco",
1368 + Model: "C9300-24T",
1369 + SerialNumber: "SN-12345",
1370 + SoftwareVersion: "17.9.4",
1371 + FirmwareVersion: "1.2.3",
1372 + HardwareVersion: "A1",
1373 + NetdataHostID: "11111111-1111-1111-1111-111111111111",
1374 + ChartIDPrefix: topologyProfileChartIDPrefix,
1375 + ChartContextPrefix: topologyProfileChartContextPrefix,
1376 + DeviceCharts: map[string]string{
1377 + "ping_rtt": "ping_rtt",
1378 + },
1379 + InterfaceCharts: map[string]topologyInterfaceChartRef{
1380 + "swp07": {
1381 + ChartIDSuffix: "swp07",
1382 + AvailableMetrics: []string{"ifErrors", "ifTraffic"},
1383 + },
1384 + },
1385 + }
1386 +
1387 + augmentLocalActorFromCache(&data, local)
1388 +
1389 + actor := findDeviceActorBySysName(data, "sw1")
1390 + require.NotNil(t, actor)
1391 + require.Equal(t, "Switch 1", actor.Attributes["sys_descr"])
1392 + require.Equal(t, "ops@example.net", actor.Attributes["sys_contact"])
1393 + require.Equal(t, "dc1", actor.Attributes["sys_location"])
1394 + require.EqualValues(t, 987654, actor.Attributes["sys_uptime"])
1395 + require.Equal(t, "Cisco", actor.Attributes["vendor"])
1396 + require.Equal(t, "snmp", actor.Attributes["vendor_source"])
1397 + require.Equal(t, "high", actor.Attributes["vendor_confidence"])
1398 + require.Equal(t, "Acme Derived", actor.Attributes["vendor_derived"])
1399 + require.Equal(t, "mac_oui", actor.Attributes["vendor_derived_source"])
1400 + require.Equal(t, "low", actor.Attributes["vendor_derived_confidence"])
1401 + require.Equal(t, "00:11:22", actor.Attributes["vendor_derived_match_prefix"])
1402 + require.Equal(t, "C9300-24T", actor.Attributes["model"])
1403 + require.Equal(t, "SN-12345", actor.Attributes["serial_number"])
1404 + require.Equal(t, "17.9.4", actor.Attributes["software_version"])
1405 + require.Equal(t, "1.2.3", actor.Attributes["firmware_version"])
1406 + require.Equal(t, "A1", actor.Attributes["hardware_version"])
1407 + require.Equal(t, "11111111-1111-1111-1111-111111111111", actor.Attributes["netdata_host_id"])
1408 + require.Equal(t, topologyProfileChartIDPrefix, actor.Attributes["chart_id_prefix"])
1409 + require.Equal(t, topologyProfileChartContextPrefix, actor.Attributes["chart_context_prefix"])
1410 +
1411 + deviceCharts, ok := actor.Attributes["device_charts"].(map[string]any)
1412 + require.True(t, ok)
1413 + require.Equal(t, "ping_rtt", deviceCharts["ping_rtt"])
1414 +
1415 + statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
1416 + require.True(t, ok)
1417 + require.Len(t, statuses, 1)
1418 + require.Equal(t, "swp07", statuses[0]["chart_id_suffix"])
1419 + require.Equal(t, []string{"ifErrors", "ifTraffic"}, statuses[0]["available_metrics"])
1420 +}
1421 +
1422 +/* Chart cross-linking test removed — feature dropped during split.
1423 +func TestCollector_SyncTopologyChartReferences(t *testing.T) {
1424 + charts := &collectorapi.Charts{}
1425 + require.NoError(t, charts.Add(
1426 + &collectorapi.Chart{
1427 + ID: "snmp_device_prof_sysUpTime",
1428 + Title: "System Uptime",
1429 + Units: "1",
1430 + Fam: "sys",
1431 + Ctx: "snmp.device_prof_sysUpTime",
1432 + Dims: collectorapi.Dims{
1433 + {ID: "snmp_device_prof_sysUpTime", Name: "sysUpTime"},
1434 + },
1435 + },
1436 + &collectorapi.Chart{
1437 + ID: "snmp_device_prof_ifTraffic_swp07",
1438 + Title: "Traffic swp07",
1439 + Units: "bit/s",
1440 + Fam: "ifTraffic",
1441 + Ctx: "snmp.device_prof_ifTraffic",
1442 + Dims: collectorapi.Dims{
1443 + {ID: "snmp_device_prof_ifTraffic_swp07_in", Name: "in"},
1444 + },
1445 + },
1446 + &collectorapi.Chart{
1447 + ID: "ping_rtt",
1448 + Title: "Ping round-trip time",
1449 + Units: "milliseconds",
1450 + Fam: "Ping/RTT",
1451 + Ctx: "snmp.device_ping_rtt",
1452 + Dims: collectorapi.Dims{
1453 + {ID: "ping_rtt_avg", Name: "avg"},
1454 + },
1455 + },
1456 + ))
1457 +
1458 + coll := &Collector{
1459 + charts: charts,
1460 + seenScalarMetrics: map[string]bool{"sysUpTime": true},
1461 + ifaceCache: newIfaceCache(),
1462 + topologyCache: newTopologyCache(),
1463 + vnode: &vnodes.VirtualNode{GUID: "11111111-1111-1111-1111-111111111111"},
1464 + }
1465 +
1466 + coll.ifaceCache.interfaces["swp07"] = &ifaceEntry{
1467 + name: "swp07",
1468 + availableMetrics: map[string]struct{}{
1469 + "ifTraffic": {},
1470 + "ifErrors": {},
1471 + },
1472 + updated: true,
1473 + }
1474 +
1475 + coll.syncTopologyChartReferences()
1476 +
1477 + local := coll.topologyCache.localDevice
1478 + require.Equal(t, "11111111-1111-1111-1111-111111111111", local.NetdataHostID)
1479 + require.Equal(t, topologyProfileChartIDPrefix, local.ChartIDPrefix)
1480 + require.Equal(t, topologyProfileChartContextPrefix, local.ChartContextPrefix)
1481 + require.Equal(t, "snmp_device_prof_sysUpTime", local.DeviceCharts["sysUpTime"])
1482 + require.Equal(t, "ping_rtt", local.DeviceCharts["ping_rtt"])
1483 + require.Contains(t, local.InterfaceCharts, "swp07")
1484 + require.Equal(t, "swp07", local.InterfaceCharts["swp07"].ChartIDSuffix)
1485 + require.Equal(t, []string{"ifTraffic"}, local.InterfaceCharts["swp07"].AvailableMetrics)
1486 +}
1487 +*/
1488 +
1489 +func actorHasAttributeList(snapshot topologyData, key string) bool {
1490 + for _, actor := range snapshot.Actors {
1491 + if actor.Attributes == nil {
1492 + continue
1493 + }
1494 + value, ok := actor.Attributes[key]
1495 + if !ok || value == nil {
1496 + continue
1497 + }
1498 + switch v := value.(type) {
1499 + case []string:
1500 + if len(v) > 0 {
1501 + return true
1502 + }
1503 + case []topologyManagementAddress:
1504 + if len(v) > 0 {
1505 + return true
1506 + }
1507 + case []any:
1508 + if len(v) > 0 {
1509 + return true
1510 + }
1511 + default:
1512 + return true
1513 + }
1514 + }
1515 + return false
1516 +}
1517 +
1518 +func findLinkByProtocol(snapshot topologyData, protocol string) *topologyLink {
1519 + for i := range snapshot.Links {
1520 + if snapshot.Links[i].Protocol == protocol {
1521 + return &snapshot.Links[i]
1522 + }
1523 + }
1524 + return nil
1525 +}
1526 +
1527 +func findActorByMAC(snapshot topologyData, mac string) *topologyActor {
1528 + for i := range snapshot.Actors {
1529 + if slices.Contains(snapshot.Actors[i].Match.MacAddresses, mac) {
1530 + return &snapshot.Actors[i]
1531 + }
1532 + }
1533 + return nil
1534 +}
1535 +
1536 +func countDeviceActors(snapshot topologyData) int {
1537 + total := 0
1538 + for _, actor := range snapshot.Actors {
1539 + if actor.ActorType == "device" {
1540 + total++
1541 + }
1542 + }
1543 + return total
1544 +}
1545 +
1546 +func containsString(values []string, target string) bool {
1547 + return slices.Contains(values, target)
1548 +}
1549 +
1550 +func linkHasRawAddressMetric(link topologyLink, raw string) bool {
1551 + raw = strings.TrimSpace(raw)
1552 + if raw == "" || len(link.Metrics) == 0 {
1553 + return false
1554 + }
1555 + srcRaw, srcOK := link.Metrics["src_remote_address_raw"].(string)
1556 + dstRaw, dstOK := link.Metrics["dst_remote_address_raw"].(string)
1557 + return (srcOK && srcRaw == raw) || (dstOK && dstRaw == raw)
1558 +}
1559 +
1560 +func findDeviceActorBySysName(snapshot topologyData, sysName string) *topologyActor {
1561 + for i := range snapshot.Actors {
1562 + actor := &snapshot.Actors[i]
1563 + if actor.ActorType != "device" {
1564 + continue
1565 + }
1566 + if actor.Match.SysName == sysName {
1567 + return actor
1568 + }
1569 + }
1570 + return nil
1571 +}
src/go/plugin/go.d/collector/snmp_topology/topology_dns.go new
+141
@@ -0,0 +1,141 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "context"
7 + "net"
8 + "net/netip"
9 + "sort"
10 + "strings"
11 + "sync"
12 + "time"
13 +)
14 +
15 +const (
16 + topologyReverseDNSTimeout = 50 * time.Millisecond
17 + topologyReverseDNSCacheTTL = 10 * time.Minute
18 + topologyReverseDNSNegTTL = 30 * time.Second
19 +)
20 +
21 +type topologyReverseDNSCacheEntry struct {
22 + name string
23 + expiresAt time.Time
24 +}
25 +
26 +type topologyReverseDNSResolver struct {
27 + mu sync.RWMutex
28 + timeout time.Duration
29 + ttl time.Duration
30 + cache map[string]topologyReverseDNSCacheEntry
31 +}
32 +
33 +func newTopologyReverseDNSResolver(timeout, ttl time.Duration) *topologyReverseDNSResolver {
34 + return &topologyReverseDNSResolver{
35 + timeout: timeout,
36 + ttl: ttl,
37 + cache: make(map[string]topologyReverseDNSCacheEntry),
38 + }
39 +}
40 +
41 +// lookupCached returns the cached result for ip without performing any network I/O.
42 +// Returns "" when the IP has never been resolved or its cache entry has expired.
43 +func (r *topologyReverseDNSResolver) lookupCached(ip string) string {
44 + if r == nil {
45 + return ""
46 + }
47 + addr, err := netip.ParseAddr(strings.TrimSpace(ip))
48 + if err != nil || !addr.IsValid() {
49 + return ""
50 + }
51 + ip = addr.Unmap().String()
52 +
53 + r.mu.RLock()
54 + entry, ok := r.cache[ip]
55 + r.mu.RUnlock()
56 + if ok && time.Now().Before(entry.expiresAt) {
57 + return entry.name
58 + }
59 + return ""
60 +}
61 +
62 +func (r *topologyReverseDNSResolver) lookup(ip string) string {
63 + if r == nil {
64 + return ""
65 + }
66 + addr, err := netip.ParseAddr(strings.TrimSpace(ip))
67 + if err != nil || !addr.IsValid() {
68 + return ""
69 + }
70 + ip = addr.Unmap().String()
71 + now := time.Now()
72 +
73 + r.mu.RLock()
74 + entry, ok := r.cache[ip]
75 + r.mu.RUnlock()
76 + if ok && now.Before(entry.expiresAt) {
77 + return entry.name
78 + }
79 +
80 + ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
81 + defer cancel()
82 + names, err := net.DefaultResolver.LookupAddr(ctx, ip)
83 + resolved := ""
84 + if err == nil {
85 + resolved = topologyNormalizeReverseDNSName(names)
86 + }
87 + ttl := r.ttl
88 + if resolved == "" && topologyReverseDNSNegTTL > 0 {
89 + ttl = topologyReverseDNSNegTTL
90 + }
91 +
92 + r.mu.Lock()
93 + r.cache[ip] = topologyReverseDNSCacheEntry{
94 + name: resolved,
95 + expiresAt: now.Add(ttl),
96 + }
97 + r.mu.Unlock()
98 +
99 + return resolved
100 +}
101 +
102 +func topologyNormalizeReverseDNSName(names []string) string {
103 + if len(names) == 0 {
104 + return ""
105 + }
106 + seen := make(map[string]struct{}, len(names))
107 + out := make([]string, 0, len(names))
108 + for _, name := range names {
109 + name = strings.TrimSpace(name)
110 + name = strings.TrimSuffix(name, ".")
111 + name = strings.ToLower(name)
112 + if name == "" {
113 + continue
114 + }
115 + if _, ok := seen[name]; ok {
116 + continue
117 + }
118 + seen[name] = struct{}{}
119 + out = append(out, name)
120 + }
121 + if len(out) == 0 {
122 + return ""
123 + }
124 + sort.Strings(out)
125 + return out[0]
126 +}
127 +
128 +var defaultTopologyReverseDNSResolver = newTopologyReverseDNSResolver(topologyReverseDNSTimeout, topologyReverseDNSCacheTTL)
129 +
130 +// resolveTopologyReverseDNSName performs a live DNS lookup (with cache).
131 +// Used during the collector's Collect() cycle to warm the cache.
132 +func resolveTopologyReverseDNSName(ip string) string {
133 + return defaultTopologyReverseDNSResolver.lookup(ip)
134 +}
135 +
136 +// resolveTopologyReverseDNSNameCached returns a cached DNS name if available,
137 +// or an empty string if the IP has not been resolved yet. Never blocks on network I/O.
138 +// Used during function responses to avoid external calls.
139 +func resolveTopologyReverseDNSNameCached(ip string) string {
140 + return defaultTopologyReverseDNSResolver.lookupCached(ip)
141 +}
src/go/plugin/go.d/collector/snmp_topology/topology_hex_normalization.go new
+65
@@ -0,0 +1,65 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "encoding/hex"
7 + "net"
8 + "strings"
9 +)
10 +
11 +func normalizeMAC(value string) string {
12 + value = normalizeSNMPHexText(value)
13 + if value == "" {
14 + return ""
15 + }
16 +
17 + if hw, err := net.ParseMAC(value); err == nil {
18 + return strings.ToLower(hw.String())
19 + }
20 +
21 + clean := strings.NewReplacer(":", "", "-", "", ".", "", " ", "").Replace(strings.ToLower(value))
22 + if clean == "" {
23 + return ""
24 + }
25 +
26 + bs, err := decodeHexString(clean)
27 + if err != nil || len(bs) != 6 {
28 + return ""
29 + }
30 +
31 + return strings.ToLower(net.HardwareAddr(bs).String())
32 +}
33 +
34 +func normalizeHexToken(value string) string {
35 + value = strings.TrimSpace(value)
36 + if value == "" {
37 + return ""
38 + }
39 +
40 + if mac := normalizeMAC(value); mac != "" {
41 + return mac
42 + }
43 + if ip := normalizeIPAddress(value); ip != "" {
44 + return ip
45 + }
46 + return strings.TrimSpace(value)
47 +}
48 +
49 +func normalizeHexIdentifier(value string) string {
50 + value = normalizeSNMPHexText(value)
51 + if value == "" {
52 + return ""
53 + }
54 +
55 + bs, err := decodeHexString(value)
56 + if err == nil && len(bs) > 0 {
57 + return strings.ToLower(hex.EncodeToString(bs))
58 + }
59 +
60 + clean := strings.NewReplacer(":", "", "-", "", ".", "", " ", "").Replace(strings.ToLower(value))
61 + if clean == "" {
62 + return ""
63 + }
64 + return clean
65 +}
src/go/plugin/go.d/collector/snmp_topology/topology_integration_test.go new
+230
@@ -0,0 +1,230 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +//go:build topology_fixtures
4 +
5 +package snmptopology
6 +
7 +import (
8 + "context"
9 + "net"
10 + "os"
11 + "path/filepath"
12 + "strconv"
13 + "strings"
14 + "testing"
15 + "time"
16 +
17 + "github.com/gosnmp/gosnmp"
18 + "github.com/stretchr/testify/require"
19 +
20 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
21 +)
22 +
23 +func TestTopologyIntegrationWithSnmpsim(t *testing.T) {
24 + endpoint := strings.TrimSpace(os.Getenv("NETDATA_SNMPSIM_ENDPOINT"))
25 + communitiesRaw := strings.TrimSpace(os.Getenv("NETDATA_SNMPSIM_COMMUNITIES"))
26 + if endpoint == "" || communitiesRaw == "" {
27 + t.Skip("missing NETDATA_SNMPSIM_ENDPOINT or NETDATA_SNMPSIM_COMMUNITIES")
28 + }
29 +
30 + host, port := parseSnmpEndpoint(t, endpoint)
31 + communities := splitCSV(communitiesRaw)
32 +
33 + for _, community := range communities {
34 + expectation := integrationExpectationForCommunity(t, community)
35 + snapshot := collectTopologySnapshotFromDevice(t, integrationV2DeviceInfo(host, port, community))
36 + assertExpectedTopologySnapshot(t, community, expectation, snapshot)
37 + }
38 +}
39 +
40 +type topologyIntegrationExpectation struct {
41 + protocol string
42 + fixture string
43 + fixtureData snmprecTopology
44 +}
45 +
46 +func integrationExpectationForCommunity(t *testing.T, community string) topologyIntegrationExpectation {
47 + t.Helper()
48 +
49 + var expectation topologyIntegrationExpectation
50 + switch strings.ToLower(strings.TrimSpace(community)) {
51 + case "lldp1":
52 + expectation.protocol = "lldp"
53 + expectation.fixture = "arubaos-cx_10.10.snmprec"
54 + case "lldp2":
55 + expectation.protocol = "lldp"
56 + expectation.fixture = "aos6.snmprec"
57 + case "cdp1":
58 + expectation.protocol = "cdp"
59 + expectation.fixture = "ciscosb_sg350x-24p.snmprec"
60 + default:
61 + t.Fatalf("unexpected integration community %q", community)
62 + }
63 +
64 + expectation.fixtureData = parseSnmprecTopology(t, filepath.Join("../../../../testdata/snmp/snmprec", expectation.fixture))
65 + return expectation
66 +}
67 +
68 +func TestTopologyIntegrationWithSnmpsimV3(t *testing.T) {
69 + endpoint := strings.TrimSpace(os.Getenv("NETDATA_SNMPSIM_V3_ENDPOINT"))
70 + contextsRaw := strings.TrimSpace(os.Getenv("NETDATA_SNMPSIM_V3_CONTEXTS"))
71 + user := strings.TrimSpace(os.Getenv("NETDATA_SNMPSIM_V3_USER"))
72 + level := strings.TrimSpace(os.Getenv("NETDATA_SNMPSIM_V3_SECURITY_LEVEL"))
73 + authProto := strings.TrimSpace(os.Getenv("NETDATA_SNMPSIM_V3_AUTH_PROTO"))
74 + authKey := strings.TrimSpace(os.Getenv("NETDATA_SNMPSIM_V3_AUTH_KEY"))
75 + privProto := strings.TrimSpace(os.Getenv("NETDATA_SNMPSIM_V3_PRIV_PROTO"))
76 + privKey := strings.TrimSpace(os.Getenv("NETDATA_SNMPSIM_V3_PRIV_KEY"))
77 +
78 + if endpoint == "" || contextsRaw == "" || user == "" || level == "" || authProto == "" || authKey == "" || privProto == "" || privKey == "" {
79 + t.Skip("missing NETDATA_SNMPSIM_V3_* variables")
80 + }
81 +
82 + host, port := parseSnmpEndpoint(t, endpoint)
83 + contexts := splitCSV(contextsRaw)
84 +
85 + for _, contextName := range contexts {
86 + expectation := integrationExpectationForCommunity(t, contextName)
87 + snapshot := collectTopologySnapshotFromDevice(t, integrationV3DeviceInfo(host, port, contextName, user, level, authProto, authKey, privProto, privKey))
88 + assertExpectedTopologySnapshot(t, contextName, expectation, snapshot)
89 + }
90 +}
91 +
92 +func collectTopologySnapshotFromDevice(t *testing.T, dev ddsnmp.DeviceConnectionInfo) topologyData {
93 + t.Helper()
94 +
95 + deviceKey := "integration:" + dev.SNMPVersion + ":" + dev.SysName
96 + ddsnmp.DeviceRegistry.Register(deviceKey, dev)
97 + defer ddsnmp.DeviceRegistry.Unregister(deviceKey)
98 +
99 + coll := New()
100 + coll.Config = Config{UpdateEvery: 1}
101 + require.NoError(t, coll.Init(context.Background()))
102 + defer coll.Cleanup(context.Background())
103 +
104 + require.NoError(t, coll.Check(context.Background()))
105 + _ = coll.Collect(context.Background())
106 +
107 + var snapshot topologyData
108 + cacheKey := dev.Hostname + ":" + strconv.Itoa(dev.Port)
109 + require.Eventuallyf(t, func() bool {
110 + cache := coll.deviceCaches[cacheKey]
111 + if cache == nil {
112 + return false
113 + }
114 + cache.mu.RLock()
115 + defer cache.mu.RUnlock()
116 +
117 + var ok bool
118 + snapshot, ok = cache.snapshot()
119 + return ok
120 + }, 5*time.Second, 100*time.Millisecond, "topology snapshot did not become available for %q", dev.SysName)
121 +
122 + return snapshot
123 +}
124 +
125 +func integrationV2DeviceInfo(host string, port int, community string) ddsnmp.DeviceConnectionInfo {
126 + return ddsnmp.DeviceConnectionInfo{
127 + Hostname: host,
128 + Port: port,
129 + SNMPVersion: gosnmp.Version2c.String(),
130 + Community: community,
131 + MaxRepetitions: 25,
132 + MaxOIDs: 60,
133 + Timeout: 5,
134 + Retries: 1,
135 + SysName: community,
136 + SysObjectID: integrationSysObjectID(community),
137 + }
138 +}
139 +
140 +func integrationV3DeviceInfo(host string, port int, contextName, user, level, authProto, authKey, privProto, privKey string) ddsnmp.DeviceConnectionInfo {
141 + return ddsnmp.DeviceConnectionInfo{
142 + Hostname: host,
143 + Port: port,
144 + SNMPVersion: gosnmp.Version3.String(),
145 + V3User: user,
146 + V3SecurityLevel: level,
147 + V3AuthProto: authProto,
148 + V3AuthKey: authKey,
149 + V3PrivProto: privProto,
150 + V3PrivKey: privKey,
151 + V3ContextName: contextName,
152 + MaxRepetitions: 25,
153 + MaxOIDs: 60,
154 + Timeout: 5,
155 + Retries: 1,
156 + SysName: contextName,
157 + SysObjectID: integrationSysObjectID(contextName),
158 + }
159 +}
160 +
161 +func assertExpectedTopologySnapshot(t *testing.T, subject string, expectation topologyIntegrationExpectation, snapshot topologyData) {
162 + t.Helper()
163 +
164 + require.Greaterf(t, len(snapshot.Links), 0, "expected topology links for %q", subject)
165 + require.Truef(t, hasProtocolLink(snapshot, expectation.protocol), "expected %s links for %q", expectation.protocol, subject)
166 +
167 + switch expectation.protocol {
168 + case "lldp":
169 + require.Truef(t, hasLinkableLLDP(expectation.fixtureData), "fixture %q does not contain linkable LLDP data", expectation.fixture)
170 + if len(expectation.fixtureData.lldpSysNames) > 0 {
171 + require.Truef(t, containsSysName(snapshot, expectation.fixtureData.lldpSysNames), "expected LLDP sysName from fixture %q", expectation.fixture)
172 + }
173 + if len(expectation.fixtureData.lldpMgmtAddrs) > 0 {
174 + require.Truef(t, containsMgmtAddr(snapshot, expectation.fixtureData.lldpMgmtAddrs), "expected LLDP management address from fixture %q", expectation.fixture)
175 + }
176 + case "cdp":
177 + require.Truef(t, hasLinkableCDP(expectation.fixtureData), "fixture %q does not contain linkable CDP data", expectation.fixture)
178 + if len(expectation.fixtureData.cdpDeviceIDs) > 0 {
179 + require.Truef(t, containsIdentifier(snapshot, expectation.fixtureData.cdpDeviceIDs), "expected CDP device identifier from fixture %q", expectation.fixture)
180 + }
181 + if len(expectation.fixtureData.cdpSysNames) > 0 {
182 + require.Truef(t, containsSysName(snapshot, expectation.fixtureData.cdpSysNames), "expected CDP sysName from fixture %q", expectation.fixture)
183 + }
184 + if len(expectation.fixtureData.cdpMgmtAddrs) > 0 {
185 + require.Truef(t, containsMgmtAddr(snapshot, expectation.fixtureData.cdpMgmtAddrs), "expected CDP management address from fixture %q", expectation.fixture)
186 + }
187 + }
188 +}
189 +
190 +func integrationSysObjectID(community string) string {
191 + switch strings.ToLower(community) {
192 + case "lldp1":
193 + return "1.3.6.1.4.1.47196.4.1.1.1.254" // arubaos-cx_10.10.snmprec
194 + case "lldp2":
195 + return "1.3.6.1.4.1.6486.800.1.1.2.1.13.1.2" // aos6.snmprec
196 + case "cdp1":
197 + return "1.3.6.1.4.1.9.6.1.94.24.5" // ciscosb_sg350x-24p.snmprec
198 + default:
199 + return ""
200 + }
201 +}
202 +
203 +func parseSnmpEndpoint(t *testing.T, endpoint string) (string, int) {
204 + if host, portStr, err := net.SplitHostPort(endpoint); err == nil {
205 + port, err := parsePort(portStr)
206 + require.NoError(t, err)
207 + return host, port
208 + }
209 + return endpoint, 161
210 +}
211 +
212 +func parsePort(value string) (int, error) {
213 + port, err := strconv.Atoi(value)
214 + if err != nil {
215 + return 0, err
216 + }
217 + return port, nil
218 +}
219 +
220 +func splitCSV(value string) []string {
221 + parts := strings.Split(value, ",")
222 + out := make([]string, 0, len(parts))
223 + for _, part := range parts {
224 + p := strings.TrimSpace(part)
225 + if p != "" {
226 + out = append(out, p)
227 + }
228 + }
229 + return out
230 +}
src/go/plugin/go.d/collector/snmp_topology/topology_interface_iana_types.go new
+310
@@ -0,0 +1,310 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +// Complete IANA ifType registry (IANAifType-MIB, updated 2026-02-24).
6 +// Source: https://www.iana.org/assignments/ianaiftype-mib/ianaiftype-mib
7 +var ianaIfTypeByNumber = map[string]string{
8 + "1": "other",
9 + "2": "regular1822",
10 + "3": "hdh1822",
11 + "4": "ddnx25",
12 + "5": "rfc877x25",
13 + "6": "ethernetcsmacd",
14 + "7": "iso88023csmacd",
15 + "8": "iso88024tokenbus",
16 + "9": "iso88025tokenring",
17 + "10": "iso88026man",
18 + "11": "starlan",
19 + "12": "proteon10mbit",
20 + "13": "proteon80mbit",
21 + "14": "hyperchannel",
22 + "15": "fddi",
23 + "16": "lapb",
24 + "17": "sdlc",
25 + "18": "ds1",
26 + "19": "e1",
27 + "20": "basicisdn",
28 + "21": "primaryisdn",
29 + "22": "proppointtopointserial",
30 + "23": "ppp",
31 + "24": "softwareloopback",
32 + "25": "eon",
33 + "26": "ethernet3mbit",
34 + "27": "nsip",
35 + "28": "slip",
36 + "29": "ultra",
37 + "30": "ds3",
38 + "31": "sip",
39 + "32": "framerelay",
40 + "33": "rs232",
41 + "34": "para",
42 + "35": "arcnet",
43 + "36": "arcnetplus",
44 + "37": "atm",
45 + "38": "miox25",
46 + "39": "sonet",
47 + "40": "x25ple",
48 + "41": "iso88022llc",
49 + "42": "localtalk",
50 + "43": "smdsdxi",
51 + "44": "framerelayservice",
52 + "45": "v35",
53 + "46": "hssi",
54 + "47": "hippi",
55 + "48": "modem",
56 + "49": "aal5",
57 + "50": "sonetpath",
58 + "51": "sonetvt",
59 + "52": "smdsicip",
60 + "53": "propvirtual",
61 + "54": "propmultiplexor",
62 + "55": "ieee80212",
63 + "56": "fibrechannel",
64 + "57": "hippiinterface",
65 + "58": "framerelayinterconnect",
66 + "59": "aflane8023",
67 + "60": "aflane8025",
68 + "61": "cctemul",
69 + "62": "fastether",
70 + "63": "isdn",
71 + "64": "v11",
72 + "65": "v36",
73 + "66": "g703at64k",
74 + "67": "g703at2mb",
75 + "68": "qllc",
76 + "69": "fastetherfx",
77 + "70": "channel",
78 + "71": "ieee80211",
79 + "72": "ibm370parchan",
80 + "73": "escon",
81 + "74": "dlsw",
82 + "75": "isdns",
83 + "76": "isdnu",
84 + "77": "lapd",
85 + "78": "ipswitch",
86 + "79": "rsrb",
87 + "80": "atmlogical",
88 + "81": "ds0",
89 + "82": "ds0bundle",
90 + "83": "bsc",
91 + "84": "async",
92 + "85": "cnr",
93 + "86": "iso88025dtr",
94 + "87": "eplrs",
95 + "88": "arap",
96 + "89": "propcnls",
97 + "90": "hostpad",
98 + "91": "termpad",
99 + "92": "framerelaympi",
100 + "93": "x213",
101 + "94": "adsl",
102 + "95": "radsl",
103 + "96": "sdsl",
104 + "97": "vdsl",
105 + "98": "iso88025crfpint",
106 + "99": "myrinet",
107 + "100": "voiceem",
108 + "101": "voicefxo",
109 + "102": "voicefxs",
110 + "103": "voiceencap",
111 + "104": "voiceoverip",
112 + "105": "atmdxi",
113 + "106": "atmfuni",
114 + "107": "atmima",
115 + "108": "pppmultilinkbundle",
116 + "109": "ipovercdlc",
117 + "110": "ipoverclaw",
118 + "111": "stacktostack",
119 + "112": "virtualipaddress",
120 + "113": "mpc",
121 + "114": "ipoveratm",
122 + "115": "iso88025fiber",
123 + "116": "tdlc",
124 + "117": "gigabitethernet",
125 + "118": "hdlc",
126 + "119": "lapf",
127 + "120": "v37",
128 + "121": "x25mlp",
129 + "122": "x25huntgroup",
130 + "123": "transphdlc",
131 + "124": "interleave",
132 + "125": "fast",
133 + "126": "ip",
134 + "127": "docscablemaclayer",
135 + "128": "docscabledownstream",
136 + "129": "docscableupstream",
137 + "130": "a12mppswitch",
138 + "131": "tunnel",
139 + "132": "coffee",
140 + "133": "ces",
141 + "134": "atmsubinterface",
142 + "135": "l2vlan",
143 + "136": "l3ipvlan",
144 + "137": "l3ipxvlan",
145 + "138": "digitalpowerline",
146 + "139": "mediamailoverip",
147 + "140": "dtm",
148 + "141": "dcn",
149 + "142": "ipforward",
150 + "143": "msdsl",
151 + "144": "ieee1394",
152 + "145": "gsn",
153 + "146": "dvbrccmaclayer",
154 + "147": "dvbrccdownstream",
155 + "148": "dvbrccupstream",
156 + "149": "atmvirtual",
157 + "150": "mplstunnel",
158 + "151": "srp",
159 + "152": "voiceoveratm",
160 + "153": "voiceoverframerelay",
161 + "154": "idsl",
162 + "155": "compositelink",
163 + "156": "ss7siglink",
164 + "157": "propwirelessp2p",
165 + "158": "frforward",
166 + "159": "rfc1483",
167 + "160": "usb",
168 + "161": "ieee8023adlag",
169 + "162": "bgppolicyaccounting",
170 + "163": "frf16mfrbundle",
171 + "164": "h323gatekeeper",
172 + "165": "h323proxy",
173 + "166": "mpls",
174 + "167": "mfsiglink",
175 + "168": "hdsl2",
176 + "169": "shdsl",
177 + "170": "ds1fdl",
178 + "171": "pos",
179 + "172": "dvbasiin",
180 + "173": "dvbasiout",
181 + "174": "plc",
182 + "175": "nfas",
183 + "176": "tr008",
184 + "177": "gr303rdt",
185 + "178": "gr303idt",
186 + "179": "isup",
187 + "180": "propdocswirelessmaclayer",
188 + "181": "propdocswirelessdownstream",
189 + "182": "propdocswirelessupstream",
190 + "183": "hiperlan2",
191 + "184": "propbwap2mp",
192 + "185": "sonetoverheadchannel",
193 + "186": "digitalwrapperoverheadchannel",
194 + "187": "aal2",
195 + "188": "radiomac",
196 + "189": "atmradio",
197 + "190": "imt",
198 + "191": "mvl",
199 + "192": "reachdsl",
200 + "193": "frdlciendpt",
201 + "194": "atmvciendpt",
202 + "195": "opticalchannel",
203 + "196": "opticaltransport",
204 + "197": "propatm",
205 + "198": "voiceovercable",
206 + "199": "infiniband",
207 + "200": "telink",
208 + "201": "q2931",
209 + "202": "virtualtg",
210 + "203": "siptg",
211 + "204": "sipsig",
212 + "205": "docscableupstreamchannel",
213 + "206": "econet",
214 + "207": "pon155",
215 + "208": "pon622",
216 + "209": "bridge",
217 + "210": "linegroup",
218 + "211": "voiceemfgd",
219 + "212": "voicefgdeana",
220 + "213": "voicedid",
221 + "214": "mpegtransport",
222 + "215": "sixtofour",
223 + "216": "gtp",
224 + "217": "pdnetherloop1",
225 + "218": "pdnetherloop2",
226 + "219": "opticalchannelgroup",
227 + "220": "homepna",
228 + "221": "gfp",
229 + "222": "ciscoislvlan",
230 + "223": "actelismetaloop",
231 + "224": "fciplink",
232 + "225": "rpr",
233 + "226": "qam",
234 + "227": "lmp",
235 + "228": "cblvectastar",
236 + "229": "docscablemcmtsdownstream",
237 + "230": "adsl2",
238 + "231": "macseccontrolledif",
239 + "232": "macsecuncontrolledif",
240 + "233": "aviciopticalether",
241 + "234": "atmbond",
242 + "235": "voicefgdos",
243 + "236": "mocaversion1",
244 + "237": "ieee80216wman",
245 + "238": "adsl2plus",
246 + "239": "dvbrcsmaclayer",
247 + "240": "dvbtdm",
248 + "241": "dvbrcstdma",
249 + "242": "x86laps",
250 + "243": "wwanpp",
251 + "244": "wwanpp2",
252 + "245": "voiceebs",
253 + "246": "ifpwtype",
254 + "247": "ilan",
255 + "248": "pip",
256 + "249": "aluelp",
257 + "250": "gpon",
258 + "251": "vdsl2",
259 + "252": "capwapdot11profile",
260 + "253": "capwapdot11bss",
261 + "254": "capwapwtpvirtualradio",
262 + "255": "bits",
263 + "256": "docscableupstreamrfport",
264 + "257": "cabledownstreamrfport",
265 + "258": "vmwarevirtualnic",
266 + "259": "ieee802154",
267 + "260": "otnodu",
268 + "261": "otnotu",
269 + "262": "ifvfitype",
270 + "263": "g9981",
271 + "264": "g9982",
272 + "265": "g9983",
273 + "266": "aluepon",
274 + "267": "aluepononu",
275 + "268": "alueponphysicaluni",
276 + "269": "alueponlogicallink",
277 + "270": "alugpononu",
278 + "271": "alugponphysicaluni",
279 + "272": "vmwarenicteam",
280 + "277": "docsofdmdownstream",
281 + "278": "docsofdmaupstream",
282 + "279": "gfast",
283 + "280": "sdci",
284 + "281": "xboxwireless",
285 + "282": "fastdsl",
286 + "283": "docscablescte55d1fwdoob",
287 + "284": "docscablescte55d1retoob",
288 + "285": "docscablescte55d2dsoob",
289 + "286": "docscablescte55d2usoob",
290 + "287": "docscablendf",
291 + "288": "docscablendr",
292 + "289": "ptm",
293 + "290": "ghn",
294 + "291": "otnotsi",
295 + "292": "otnotuc",
296 + "293": "otnoduc",
297 + "294": "otnotsig",
298 + "295": "microwavecarriertermination",
299 + "296": "microwaveradiolinkterminal",
300 + "297": "ieee8021axdrni",
301 + "298": "ax25",
302 + "299": "ieee19061nanocom",
303 + "300": "cpri",
304 + "301": "omni",
305 + "302": "roe",
306 + "303": "p2poverlan",
307 + "304": "docscablescte25d1fwdoob",
308 + "305": "docscablescte25d1retoob",
309 + "306": "docscablescte25d2macoob",
310 +}
src/go/plugin/go.d/collector/snmp_topology/topology_interface_normalization.go new
+23
@@ -0,0 +1,23 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 +)
9 +
10 +func normalizeInterfaceType(value string) string {
11 + value = canonicalSNMPEnumValue(value)
12 + value = strings.TrimSpace(value)
13 + if value == "" {
14 + return ""
15 + }
16 + if name, ok := ianaIfTypeByNumber[value]; ok {
17 + return name
18 + }
19 + if _, err := strconv.Atoi(value); err == nil {
20 + return "type-" + value
21 + }
22 + return strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(value))
23 +}
src/go/plugin/go.d/collector/snmp_topology/topology_interface_status.go new
+65
@@ -0,0 +1,65 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +func normalizeInterfaceAdminStatus(value string) string {
6 + value = canonicalSNMPEnumValue(value)
7 + switch value {
8 + case "1":
9 + return "up"
10 + case "2":
11 + return "down"
12 + case "3":
13 + return "testing"
14 + case "up", "down", "testing":
15 + return value
16 + default:
17 + return ""
18 + }
19 +}
20 +
21 +func normalizeInterfaceOperStatus(value string) string {
22 + value = canonicalSNMPEnumValue(value)
23 + switch value {
24 + case "1":
25 + return "up"
26 + case "2":
27 + return "down"
28 + case "3":
29 + return "testing"
30 + case "4":
31 + return "unknown"
32 + case "5":
33 + return "dormant"
34 + case "6":
35 + return "notPresent"
36 + case "7":
37 + return "lowerLayerDown"
38 + case "up", "down", "testing", "unknown", "dormant":
39 + return value
40 + case "notpresent":
41 + return "notPresent"
42 + case "not_present":
43 + return "notPresent"
44 + case "lowerlayerdown":
45 + return "lowerLayerDown"
46 + case "lower_layer_down":
47 + return "lowerLayerDown"
48 + default:
49 + return ""
50 + }
51 +}
52 +
53 +func normalizeInterfaceDuplex(value string) string {
54 + value = canonicalSNMPEnumValue(value)
55 + switch value {
56 + case "1", "unknown":
57 + return "unknown"
58 + case "2", "half", "halfduplex", "half_duplex":
59 + return "half"
60 + case "3", "full", "fullduplex", "full_duplex":
61 + return "full"
62 + default:
63 + return ""
64 + }
65 +}
src/go/plugin/go.d/collector/snmp_topology/topology_ip_normalization.go new
+67
@@ -0,0 +1,67 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "net"
7 + "strings"
8 +)
9 +
10 +func normalizeIPAddress(value string) string {
11 + value = strings.TrimSpace(value)
12 + if value == "" {
13 + return ""
14 + }
15 + if ip := net.ParseIP(value); ip != nil {
16 + return ip.String()
17 + }
18 +
19 + if bs, err := decodeHexString(value); err == nil {
20 + if ip := parseIPFromDecodedBytes(bs); ip != nil {
21 + return ip.String()
22 + }
23 + }
24 +
25 + return ""
26 +}
27 +
28 +func parseIPFromDecodedBytes(bs []byte) net.IP {
29 + if len(bs) == net.IPv4len || len(bs) == net.IPv6len {
30 + ip := net.IP(bs)
31 + if ip.To16() != nil {
32 + return ip
33 + }
34 + }
35 +
36 + ascii := decodePrintableASCII(bs)
37 + if ascii == "" {
38 + return nil
39 + }
40 +
41 + if ip := net.ParseIP(ascii); ip != nil {
42 + return ip
43 + }
44 + return nil
45 +}
46 +
47 +func decodePrintableASCII(bs []byte) string {
48 + if len(bs) == 0 {
49 + return ""
50 + }
51 +
52 + for _, b := range bs {
53 + if b == 0 {
54 + continue
55 + }
56 + if b < 32 || b > 126 {
57 + return ""
58 + }
59 + }
60 +
61 + s := strings.TrimRight(string(bs), "\x00")
62 + s = strings.TrimSpace(s)
63 + if s == "" {
64 + return ""
65 + }
66 + return s
67 +}
src/go/plugin/go.d/collector/snmp_topology/topology_lldp_capabilities.go new
+64
@@ -0,0 +1,64 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +func decodeLLDPCapabilities(value string) []string {
6 + bs, err := decodeHexString(value)
7 + if err != nil {
8 + return nil
9 + }
10 +
11 + names := []string{
12 + "other",
13 + "repeater",
14 + "bridge",
15 + "wlanAccessPoint",
16 + "router",
17 + "telephone",
18 + "docsisCableDevice",
19 + "stationOnly",
20 + "cVlanComponent",
21 + "sVlanComponent",
22 + "twoPortMacRelay",
23 + }
24 +
25 + caps := make([]string, 0, len(names))
26 + for bit, name := range names {
27 + if bitSet(bs, bit) {
28 + caps = append(caps, name)
29 + }
30 + }
31 + return caps
32 +}
33 +
34 +func inferCategoryFromCapabilities(caps []string) string {
35 + has := make(map[string]bool, len(caps))
36 + for _, c := range caps {
37 + has[c] = true
38 + }
39 + switch {
40 + case has["router"]:
41 + return "router"
42 + case has["wlanAccessPoint"]:
43 + return "access point"
44 + case has["telephone"]:
45 + return "voip"
46 + case has["bridge"]:
47 + return "switch"
48 + case has["repeater"]:
49 + return "switch"
50 + case has["docsisCableDevice"]:
51 + return "network device"
52 + default:
53 + return ""
54 + }
55 +}
56 +
57 +func bitSet(bs []byte, bit int) bool {
58 + idx := bit / 8
59 + if idx < 0 || idx >= len(bs) {
60 + return false
61 + }
62 + mask := byte(1 << uint(7-(bit%8)))
63 + return bs[idx]&mask != 0
64 +}
src/go/plugin/go.d/collector/snmp_topology/topology_lldp_subtypes.go new
+23
@@ -0,0 +1,23 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +var lldpChassisIDSubtypeMap = map[string]string{
6 + "1": "chassisComponent",
7 + "2": "interfaceAlias",
8 + "3": "portComponent",
9 + "4": "macAddress",
10 + "5": "networkAddress",
11 + "6": "interfaceName",
12 + "7": "local",
13 +}
14 +
15 +var lldpPortIDSubtypeMap = map[string]string{
16 + "1": "interfaceAlias",
17 + "2": "portComponent",
18 + "3": "macAddress",
19 + "4": "networkAddress",
20 + "5": "interfaceName",
21 + "6": "agentCircuitId",
22 + "7": "local",
23 +}
src/go/plugin/go.d/collector/snmp_topology/topology_local_actor.go new
+27
@@ -0,0 +1,27 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
6 +
7 +func augmentLocalActorFromCache(data *topologyData, local topologyDevice) {
8 + if data == nil || len(data.Actors) == 0 {
9 + return
10 + }
11 +
12 + for i := range data.Actors {
13 + actor := &data.Actors[i]
14 + if !topologyengine.IsDeviceActorType(actor.ActorType) {
15 + continue
16 + }
17 + if !matchLocalTopologyActor(actor.Match, local) {
18 + continue
19 + }
20 +
21 + attrs := populateLocalActorAttributes(actor.Attributes, local)
22 + actor.Attributes = pruneNilAttributes(attrs)
23 + applyLocalActorLabels(actor, local)
24 + enrichLocalActorChartReferences(actor, local.InterfaceCharts)
25 + return
26 + }
27 +}
src/go/plugin/go.d/collector/snmp_topology/topology_local_actor_attrs.go new
+87
@@ -0,0 +1,87 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +func populateLocalActorAttributes(attrs map[string]any, local topologyDevice) map[string]any {
8 + if attrs == nil {
9 + attrs = make(map[string]any)
10 + }
11 + if len(local.ManagementAddresses) > 0 {
12 + attrs["management_addresses"] = local.ManagementAddresses
13 + }
14 + if len(local.Capabilities) > 0 {
15 + attrs["capabilities"] = local.Capabilities
16 + }
17 + if len(local.CapabilitiesSupported) > 0 {
18 + attrs["capabilities_supported"] = local.CapabilitiesSupported
19 + }
20 + if len(local.CapabilitiesEnabled) > 0 {
21 + attrs["capabilities_enabled"] = local.CapabilitiesEnabled
22 + }
23 + if sysDescr := strings.TrimSpace(local.SysDescr); sysDescr != "" {
24 + attrs["sys_descr"] = sysDescr
25 + }
26 + if sysContact := strings.TrimSpace(local.SysContact); sysContact != "" {
27 + attrs["sys_contact"] = sysContact
28 + }
29 + if sysLocation := strings.TrimSpace(local.SysLocation); sysLocation != "" {
30 + attrs["sys_location"] = sysLocation
31 + }
32 + if local.SysUptime > 0 {
33 + attrs["sys_uptime"] = local.SysUptime
34 + }
35 + if vendor := strings.TrimSpace(local.Vendor); vendor != "" {
36 + attrs["vendor"] = vendor
37 + attrs["vendor_source"] = "snmp"
38 + attrs["vendor_confidence"] = "high"
39 + }
40 + if model := strings.TrimSpace(local.Model); model != "" {
41 + attrs["model"] = model
42 + }
43 + if serial := strings.TrimSpace(local.SerialNumber); serial != "" {
44 + attrs["serial_number"] = serial
45 + }
46 + if software := strings.TrimSpace(local.SoftwareVersion); software != "" {
47 + attrs["software_version"] = software
48 + }
49 + if firmware := strings.TrimSpace(local.FirmwareVersion); firmware != "" {
50 + attrs["firmware_version"] = firmware
51 + }
52 + if hardware := strings.TrimSpace(local.HardwareVersion); hardware != "" {
53 + attrs["hardware_version"] = hardware
54 + }
55 + if managementIP := normalizeIPAddress(local.ManagementIP); managementIP != "" {
56 + attrs["management_ip"] = managementIP
57 + }
58 + if netdataHostID := strings.TrimSpace(local.NetdataHostID); netdataHostID != "" {
59 + attrs["netdata_host_id"] = netdataHostID
60 + }
61 + if chartIDPrefix := strings.TrimSpace(local.ChartIDPrefix); chartIDPrefix != "" {
62 + attrs["chart_id_prefix"] = chartIDPrefix
63 + }
64 + if chartContextPrefix := strings.TrimSpace(local.ChartContextPrefix); chartContextPrefix != "" {
65 + attrs["chart_context_prefix"] = chartContextPrefix
66 + }
67 + if len(local.DeviceCharts) > 0 {
68 + attrs["device_charts"] = mapStringStringToAny(local.DeviceCharts)
69 + }
70 + return attrs
71 +}
72 +
73 +func applyLocalActorLabels(actor *topologyActor, local topologyDevice) {
74 + if actor == nil {
75 + return
76 + }
77 + if actor.Labels == nil {
78 + actor.Labels = make(map[string]string)
79 + }
80 + for key, value := range local.Labels {
81 + value = strings.TrimSpace(value)
82 + if value == "" {
83 + continue
84 + }
85 + actor.Labels[key] = value
86 + }
87 +}
src/go/plugin/go.d/collector/snmp_topology/topology_local_actor_charts.go new
+116
@@ -0,0 +1,116 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "fmt"
7 + "strings"
8 +)
9 +
10 +func enrichLocalActorChartReferences(actor *topologyActor, interfaceCharts map[string]topologyInterfaceChartRef) {
11 + if actor == nil || len(interfaceCharts) == 0 {
12 + return
13 + }
14 +
15 + lookup := topologyInterfaceChartLookup(interfaceCharts)
16 + if len(lookup) == 0 {
17 + return
18 + }
19 +
20 + if statuses, ok := actor.Attributes["if_statuses"]; ok && statuses != nil {
21 + actor.Attributes["if_statuses"] = enrichTopologyInterfaceStatusesWithChartRefs(statuses, lookup)
22 + }
23 + if actor.Tables != nil {
24 + if portRows, ok := actor.Tables["ports"]; ok && len(portRows) > 0 {
25 + enrichTopologyTableRowsWithChartRefs(portRows, lookup)
26 + }
27 + }
28 +}
29 +
30 +func topologyInterfaceChartLookup(interfaceCharts map[string]topologyInterfaceChartRef) map[string]topologyInterfaceChartRef {
31 + lookup := make(map[string]topologyInterfaceChartRef, len(interfaceCharts))
32 + for ifName, ref := range interfaceCharts {
33 + ifName = strings.ToLower(strings.TrimSpace(ifName))
34 + if ifName == "" {
35 + continue
36 + }
37 + if strings.TrimSpace(ref.ChartIDSuffix) == "" {
38 + ref.ChartIDSuffix = ifName
39 + }
40 + ref.AvailableMetrics = deduplicateSortedStrings(ref.AvailableMetrics)
41 + lookup[ifName] = ref
42 + }
43 + return lookup
44 +}
45 +
46 +func enrichTopologyInterfaceStatusesWithChartRefs(
47 + statuses any,
48 + lookup map[string]topologyInterfaceChartRef,
49 +) any {
50 + if len(lookup) == 0 || statuses == nil {
51 + return statuses
52 + }
53 +
54 + switch typed := statuses.(type) {
55 + case []map[string]any:
56 + for _, status := range typed {
57 + ifName := strings.ToLower(strings.TrimSpace(fmt.Sprint(status["if_name"])))
58 + if ifName == "" {
59 + continue
60 + }
61 + ref, ok := lookup[ifName]
62 + if !ok {
63 + continue
64 + }
65 + status["chart_id_suffix"] = ref.ChartIDSuffix
66 + if len(ref.AvailableMetrics) > 0 {
67 + status["available_metrics"] = ref.AvailableMetrics
68 + }
69 + }
70 + return typed
71 + case []any:
72 + for i := range typed {
73 + status, ok := typed[i].(map[string]any)
74 + if !ok || status == nil {
75 + continue
76 + }
77 + ifName := strings.ToLower(strings.TrimSpace(fmt.Sprint(status["if_name"])))
78 + if ifName == "" {
79 + continue
80 + }
81 + ref, ok := lookup[ifName]
82 + if !ok {
83 + continue
84 + }
85 + status["chart_id_suffix"] = ref.ChartIDSuffix
86 + if len(ref.AvailableMetrics) > 0 {
87 + status["available_metrics"] = ref.AvailableMetrics
88 + }
89 + typed[i] = status
90 + }
91 + return typed
92 + default:
93 + return statuses
94 + }
95 +}
96 +
97 +func enrichTopologyTableRowsWithChartRefs(rows []map[string]any, lookup map[string]topologyInterfaceChartRef) {
98 + if len(lookup) == 0 || len(rows) == 0 {
99 + return
100 + }
101 +
102 + for _, row := range rows {
103 + name := strings.ToLower(strings.TrimSpace(fmt.Sprint(row["name"])))
104 + if name == "" {
105 + continue
106 + }
107 + ref, ok := lookup[name]
108 + if !ok {
109 + continue
110 + }
111 + row["chart_id_suffix"] = ref.ChartIDSuffix
112 + if len(ref.AvailableMetrics) > 0 {
113 + row["available_metrics"] = ref.AvailableMetrics
114 + }
115 + }
116 +}
src/go/plugin/go.d/collector/snmp_topology/topology_local_actor_charts_test.go new
+67
@@ -0,0 +1,67 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestEnrichLocalActorChartReferencesAddsStatusAndPortRows(t *testing.T) {
12 + actor := &topologyActor{
13 + Attributes: map[string]any{
14 + "if_statuses": []map[string]any{
15 + {"if_name": "Gi0/1"},
16 + {"if_name": "Gi0/2"},
17 + },
18 + },
19 + Tables: map[string][]map[string]any{
20 + "ports": {
21 + {"name": "Gi0/1"},
22 + {"name": "Gi0/3"},
23 + },
24 + },
25 + }
26 +
27 + enrichLocalActorChartReferences(actor, map[string]topologyInterfaceChartRef{
28 + "Gi0/1": {
29 + ChartIDSuffix: "gi0_1",
30 + AvailableMetrics: []string{"errors", "traffic", "traffic"},
31 + },
32 + "gi0/2": {
33 + AvailableMetrics: []string{"drops"},
34 + },
35 + })
36 +
37 + statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
38 + require.True(t, ok)
39 + require.Equal(t, "gi0_1", statuses[0]["chart_id_suffix"])
40 + require.Equal(t, []string{"errors", "traffic"}, statuses[0]["available_metrics"])
41 + require.Equal(t, "gi0/2", statuses[1]["chart_id_suffix"])
42 + require.Equal(t, []string{"drops"}, statuses[1]["available_metrics"])
43 +
44 + require.Equal(t, "gi0_1", actor.Tables["ports"][0]["chart_id_suffix"])
45 + require.Equal(t, []string{"errors", "traffic"}, actor.Tables["ports"][0]["available_metrics"])
46 + require.NotContains(t, actor.Tables["ports"][1], "chart_id_suffix")
47 +}
48 +
49 +func TestEnrichTopologyInterfaceStatusesWithChartRefsSupportsAnySlices(t *testing.T) {
50 + statuses := []any{
51 + map[string]any{"if_name": "Gi0/10"},
52 + "not-a-map",
53 + }
54 +
55 + enriched := enrichTopologyInterfaceStatusesWithChartRefs(statuses, map[string]topologyInterfaceChartRef{
56 + "gi0/10": {
57 + ChartIDSuffix: "gi0_10",
58 + AvailableMetrics: []string{"traffic"},
59 + },
60 + }).([]any)
61 +
62 + status, ok := enriched[0].(map[string]any)
63 + require.True(t, ok)
64 + require.Equal(t, "gi0_10", status["chart_id_suffix"])
65 + require.Equal(t, []string{"traffic"}, status["available_metrics"])
66 + require.Equal(t, "not-a-map", enriched[1])
67 +}
src/go/plugin/go.d/collector/snmp_topology/topology_local_actor_match.go new
+36
@@ -0,0 +1,36 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strings"
7 +
8 + "github.com/netdata/netdata/go/plugins/pkg/topology"
9 +)
10 +
11 +func matchLocalTopologyActor(match topology.Match, local topologyDevice) bool {
12 + localChassisID := strings.TrimSpace(local.ChassisID)
13 + if localChassisID != "" {
14 + for _, chassisID := range match.ChassisIDs {
15 + if strings.EqualFold(strings.TrimSpace(chassisID), localChassisID) {
16 + return true
17 + }
18 + }
19 + }
20 +
21 + localSysName := strings.TrimSpace(local.SysName)
22 + if localSysName != "" && strings.EqualFold(strings.TrimSpace(match.SysName), localSysName) {
23 + return true
24 + }
25 +
26 + localIP := normalizeIPAddress(local.ManagementIP)
27 + if localIP != "" {
28 + for _, ip := range match.IPAddresses {
29 + if normalizeIPAddress(ip) == localIP {
30 + return true
31 + }
32 + }
33 + }
34 +
35 + return false
36 +}
src/go/plugin/go.d/collector/snmp_topology/topology_management_address.go new
+125
@@ -0,0 +1,125 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "net"
7 + "sort"
8 + "strings"
9 +)
10 +
11 +func normalizeAddressType(rawType, addr string) string {
12 + if ip := net.ParseIP(addr); ip != nil {
13 + if ip.To4() != nil {
14 + return "ipv4"
15 + }
16 + return "ipv6"
17 + }
18 +
19 + switch rawType {
20 + case "1":
21 + return "ipv4"
22 + case "2":
23 + return "ipv6"
24 + }
25 + return rawType
26 +}
27 +
28 +func managementAddressTypeFromIP(ip string) string {
29 + parsed := net.ParseIP(strings.TrimSpace(ip))
30 + if parsed == nil {
31 + return ""
32 + }
33 + if parsed.To4() != nil {
34 + return "ipv4"
35 + }
36 + return "ipv6"
37 +}
38 +
39 +func appendManagementAddress(addrs []topologyManagementAddress, addr topologyManagementAddress) []topologyManagementAddress {
40 + if addr.Address == "" {
41 + return addrs
42 + }
43 + for _, existing := range addrs {
44 + if existing.Address == addr.Address && existing.AddressType == addr.AddressType && existing.Source == addr.Source {
45 + return addrs
46 + }
47 + }
48 + return append(addrs, addr)
49 +}
50 +
51 +func appendCdpManagementAddresses(entry *cdpRemote, current []topologyManagementAddress) []topologyManagementAddress {
52 + addrs := current
53 + if entry.primaryMgmtAddr != "" {
54 + addr, addrType := normalizeManagementAddress(entry.primaryMgmtAddr, entry.primaryMgmtAddrType)
55 + if addr != "" {
56 + addrs = appendManagementAddress(addrs, topologyManagementAddress{
57 + Address: addr,
58 + AddressType: addrType,
59 + Source: "cdp_primary_mgmt",
60 + })
61 + }
62 + }
63 + if entry.secondaryMgmtAddr != "" {
64 + addr, addrType := normalizeManagementAddress(entry.secondaryMgmtAddr, entry.secondaryMgmtAddrType)
65 + if addr != "" {
66 + addrs = appendManagementAddress(addrs, topologyManagementAddress{
67 + Address: addr,
68 + AddressType: addrType,
69 + Source: "cdp_secondary_mgmt",
70 + })
71 + }
72 + }
73 + if entry.address != "" {
74 + addr, addrType := normalizeManagementAddress(entry.address, entry.addressType)
75 + if addr != "" {
76 + addrs = appendManagementAddress(addrs, topologyManagementAddress{
77 + Address: addr,
78 + AddressType: addrType,
79 + Source: "cdp_cache_address",
80 + })
81 + }
82 + }
83 + return addrs
84 +}
85 +
86 +func pickManagementIP(addrs []topologyManagementAddress) string {
87 + if len(addrs) == 0 {
88 + return ""
89 + }
90 +
91 + ipSet := make(map[string]struct{}, len(addrs))
92 + ipValues := make([]string, 0, len(addrs))
93 + rawSet := make(map[string]struct{}, len(addrs))
94 + rawValues := make([]string, 0, len(addrs))
95 +
96 + for _, addr := range addrs {
97 + value := strings.TrimSpace(addr.Address)
98 + if value == "" {
99 + continue
100 + }
101 + if ip := normalizeIPAddress(value); ip != "" {
102 + if _, exists := ipSet[ip]; exists {
103 + continue
104 + }
105 + ipSet[ip] = struct{}{}
106 + ipValues = append(ipValues, ip)
107 + continue
108 + }
109 + if _, exists := rawSet[value]; exists {
110 + continue
111 + }
112 + rawSet[value] = struct{}{}
113 + rawValues = append(rawValues, value)
114 + }
115 +
116 + if len(ipValues) > 0 {
117 + sort.Strings(ipValues)
118 + return ipValues[0]
119 + }
120 + if len(rawValues) > 0 {
121 + sort.Strings(rawValues)
122 + return rawValues[0]
123 + }
124 + return ""
125 +}
src/go/plugin/go.d/collector/snmp_topology/topology_management_address_normalization.go new
+54
@@ -0,0 +1,54 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "encoding/hex"
7 + "fmt"
8 + "net"
9 + "strconv"
10 + "strings"
11 +)
12 +
13 +func reconstructLldpRemMgmtAddrHex(tags map[string]string) string {
14 + lengthStr := strings.TrimSpace(tags[tagLldpRemMgmtAddrLen])
15 + length, err := strconv.Atoi(lengthStr)
16 + if err != nil || length <= 0 || length > net.IPv6len {
17 + return ""
18 + }
19 +
20 + addr := make([]byte, 0, length)
21 + for i := 1; i <= length; i++ {
22 + tag := fmt.Sprintf("%s%d", tagLldpRemMgmtAddrOctetPref, i)
23 + v := strings.TrimSpace(tags[tag])
24 + if v == "" {
25 + return ""
26 + }
27 + octet, err := strconv.Atoi(v)
28 + if err != nil || octet < 0 || octet > 255 {
29 + return ""
30 + }
31 + addr = append(addr, byte(octet))
32 + }
33 +
34 + return hex.EncodeToString(addr)
35 +}
36 +
37 +func normalizeManagementAddress(rawAddr, rawType string) (string, string) {
38 + rawAddr = strings.TrimSpace(rawAddr)
39 + if rawAddr == "" {
40 + return "", normalizeAddressType(rawType, "")
41 + }
42 +
43 + if ip := net.ParseIP(rawAddr); ip != nil {
44 + return ip.String(), normalizeAddressType(rawType, ip.String())
45 + }
46 +
47 + if bs, err := decodeHexString(rawAddr); err == nil {
48 + if ip := parseIPFromDecodedBytes(bs); ip != nil {
49 + return ip.String(), normalizeAddressType(rawType, ip.String())
50 + }
51 + }
52 +
53 + return rawAddr, normalizeAddressType(rawType, rawAddr)
54 +}
src/go/plugin/go.d/collector/snmp_topology/topology_management_helpers.go new
+92
@@ -0,0 +1,92 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strings"
7 +)
8 +
9 +func normalizeTopologyDevice(dev topologyDevice) topologyDevice {
10 + if dev.ChartIDPrefix == "" {
11 + dev.ChartIDPrefix = topologyProfileChartIDPrefix
12 + }
13 + if dev.ChartContextPrefix == "" {
14 + dev.ChartContextPrefix = topologyProfileChartContextPrefix
15 + }
16 + if dev.ManagementIP == "" && len(dev.ManagementAddresses) > 0 {
17 + if ip := pickManagementIP(dev.ManagementAddresses); ip != "" {
18 + dev.ManagementIP = ip
19 + }
20 + }
21 + if len(dev.Capabilities) == 0 {
22 + if len(dev.CapabilitiesEnabled) > 0 {
23 + dev.Capabilities = dev.CapabilitiesEnabled
24 + } else if len(dev.CapabilitiesSupported) > 0 {
25 + dev.Capabilities = dev.CapabilitiesSupported
26 + }
27 + }
28 + if dev.Labels == nil {
29 + dev.Labels = make(map[string]string)
30 + }
31 + if strings.TrimSpace(dev.Labels["type"]) == "" && len(dev.Capabilities) > 0 {
32 + dev.Labels["type"] = inferCategoryFromCapabilities(dev.Capabilities)
33 + }
34 + if dev.ChassisID == "" && dev.ManagementIP != "" {
35 + dev.ChassisID = dev.ManagementIP
36 + dev.ChassisIDType = "management_ip"
37 + }
38 + if dev.ChassisID != "" && dev.ChassisIDType == "" {
39 + dev.ChassisIDType = "unknown"
40 + }
41 + if value := topologyMetadataValue(dev.Labels, topologyMetadataAliasSysDescr); value != "" && dev.SysDescr == "" {
42 + dev.SysDescr = value
43 + }
44 + if value := topologyMetadataValue(dev.Labels, topologyMetadataAliasSysContact); value != "" && dev.SysContact == "" {
45 + dev.SysContact = value
46 + }
47 + if value := topologyMetadataValue(dev.Labels, topologyMetadataAliasSysLocation); value != "" && dev.SysLocation == "" {
48 + dev.SysLocation = value
49 + }
50 + if value := topologyMetadataValue(dev.Labels, topologyMetadataAliasVendor); value != "" && dev.Vendor == "" {
51 + dev.Vendor = value
52 + }
53 + if value := topologyMetadataValue(dev.Labels, topologyMetadataAliasModel); value != "" && dev.Model == "" {
54 + dev.Model = value
55 + }
56 + if dev.SysUptime <= 0 {
57 + if value := topologyMetadataValue(dev.Labels, topologyMetadataAliasSysUptime); value != "" {
58 + dev.SysUptime = parsePositiveInt64(value)
59 + }
60 + }
61 + if value := topologyMetadataValue(dev.Labels, topologyMetadataAliasSerial); value != "" && dev.SerialNumber == "" {
62 + dev.SerialNumber = value
63 + setTopologyMetadataLabelIfMissing(dev.Labels, "serial_number", value)
64 + }
65 + if value := topologyMetadataValue(dev.Labels, topologyMetadataAliasSoftware); value != "" && dev.SoftwareVersion == "" {
66 + dev.SoftwareVersion = value
67 + setTopologyMetadataLabelIfMissing(dev.Labels, "software_version", value)
68 + }
69 + if value := topologyMetadataValue(dev.Labels, topologyMetadataAliasFirmware); value != "" && dev.FirmwareVersion == "" {
70 + dev.FirmwareVersion = value
71 + setTopologyMetadataLabelIfMissing(dev.Labels, "firmware_version", value)
72 + }
73 + if value := topologyMetadataValue(dev.Labels, topologyMetadataAliasHardware); value != "" && dev.HardwareVersion == "" {
74 + dev.HardwareVersion = value
75 + setTopologyMetadataLabelIfMissing(dev.Labels, "hardware_version", value)
76 + }
77 + return dev
78 +}
79 +
80 +func topologyDeviceKey(dev topologyDevice) string {
81 + if dev.ChassisID == "" {
82 + return ""
83 + }
84 + return dev.ChassisIDType + ":" + dev.ChassisID
85 +}
86 +
87 +func normalizeLLDPSubtype(value string, mapping map[string]string) string {
88 + if v, ok := mapping[value]; ok {
89 + return v
90 + }
91 + return value
92 +}
src/go/plugin/go.d/collector/snmp_topology/topology_management_helpers_test.go new
+36
@@ -0,0 +1,36 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestNormalizeManagementAddress_DecodesHexAndASCIIIPs(t *testing.T) {
12 + addr, addrType := normalizeManagementAddress("0A14043C", "1")
13 + require.Equal(t, "10.20.4.60", addr)
14 + require.Equal(t, "ipv4", addrType)
15 +
16 + addr, addrType = normalizeManagementAddress("31302E32302E342E323035", "")
17 + require.Equal(t, "10.20.4.205", addr)
18 + require.Equal(t, "ipv4", addrType)
19 +}
20 +
21 +func TestNormalizeHexHelpers_ClassifyTokensDeterministically(t *testing.T) {
22 + require.Equal(t, "00:11:22:33:44:55", normalizeMAC("hex-string: 00 11 22 33 44 55"))
23 + require.Equal(t, "10.20.4.60", normalizeIPAddress("0A14043C"))
24 + require.Equal(t, "10.20.4.205", normalizeHexToken("31302E32302E342E323035"))
25 + require.Equal(t, "001122334455", normalizeHexIdentifier("00:11:22:33:44:55"))
26 +}
27 +
28 +func TestReconstructLldpRemMgmtAddrHex_FromOctets(t *testing.T) {
29 + require.Equal(t, "0a14043c", reconstructLldpRemMgmtAddrHex(map[string]string{
30 + tagLldpRemMgmtAddrLen: "4",
31 + tagLldpRemMgmtAddrOctetPref + "1": "10",
32 + tagLldpRemMgmtAddrOctetPref + "2": "20",
33 + tagLldpRemMgmtAddrOctetPref + "3": "4",
34 + tagLldpRemMgmtAddrOctetPref + "4": "60",
35 + }))
36 +}
src/go/plugin/go.d/collector/snmp_topology/topology_match_keys.go new
+62
@@ -0,0 +1,62 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "fmt"
7 + "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/topology"
10 +)
11 +
12 +func canonicalMatchKey(match topology.Match) string {
13 + if key := canonicalPrimaryMACListKey(match); key != "" {
14 + return "mac:" + key
15 + }
16 + if key := canonicalHardwareListKey(match.ChassisIDs); key != "" {
17 + return "chassis:" + key
18 + }
19 + if key := canonicalIPListKey(match.IPAddresses); key != "" {
20 + return "ip:" + key
21 + }
22 + if key := canonicalStringListKey(match.Hostnames); key != "" {
23 + return "hostname:" + key
24 + }
25 + if key := canonicalStringListKey(match.DNSNames); key != "" {
26 + return "dns:" + key
27 + }
28 + if sysName := strings.ToLower(strings.TrimSpace(match.SysName)); sysName != "" {
29 + return "sysname:" + sysName
30 + }
31 + if match.SysObjectID != "" {
32 + return "sysobjectid:" + match.SysObjectID
33 + }
34 + return ""
35 +}
36 +
37 +func topologyLinkSortKey(link topology.Link) string {
38 + return strings.Join([]string{
39 + link.Protocol,
40 + link.Direction,
41 + canonicalMatchKey(link.Src.Match),
42 + canonicalMatchKey(link.Dst.Match),
43 + attrKey(link.Src.Attributes, "if_index"),
44 + attrKey(link.Src.Attributes, "if_name"),
45 + attrKey(link.Src.Attributes, "port_id"),
46 + attrKey(link.Dst.Attributes, "if_index"),
47 + attrKey(link.Dst.Attributes, "if_name"),
48 + attrKey(link.Dst.Attributes, "port_id"),
49 + link.State,
50 + }, "|")
51 +}
52 +
53 +func attrKey(attrs map[string]any, key string) string {
54 + if len(attrs) == 0 {
55 + return ""
56 + }
57 + v, ok := attrs[key]
58 + if !ok || v == nil {
59 + return ""
60 + }
61 + return fmt.Sprint(v)
62 +}
src/go/plugin/go.d/collector/snmp_topology/topology_match_keys_identity.go new
+69
@@ -0,0 +1,69 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/topology"
10 +)
11 +
12 +func topologyMatchIdentityKeys(match topology.Match) []string {
13 + seen := make(map[string]struct{}, 8)
14 + add := func(kind, value string) {
15 + value = strings.TrimSpace(value)
16 + if value == "" {
17 + return
18 + }
19 + seen[kind+":"+value] = struct{}{}
20 + }
21 +
22 + for _, value := range match.ChassisIDs {
23 + value = strings.TrimSpace(value)
24 + if value == "" {
25 + continue
26 + }
27 + if mac := normalizeMAC(value); mac != "" {
28 + add("hw", mac)
29 + continue
30 + }
31 + if ip := normalizeIPAddress(value); ip != "" {
32 + add("ip", ip)
33 + continue
34 + }
35 + add("chassis", strings.ToLower(value))
36 + }
37 + for _, value := range match.MacAddresses {
38 + if mac := normalizeMAC(value); mac != "" {
39 + add("hw", mac)
40 + }
41 + }
42 + for _, value := range match.IPAddresses {
43 + if ip := normalizeIPAddress(value); ip != "" {
44 + add("ip", ip)
45 + continue
46 + }
47 + add("ipraw", strings.ToLower(strings.TrimSpace(value)))
48 + }
49 + for _, value := range match.Hostnames {
50 + add("hostname", strings.ToLower(strings.TrimSpace(value)))
51 + }
52 + for _, value := range match.DNSNames {
53 + add("dns", strings.ToLower(strings.TrimSpace(value)))
54 + }
55 + if sysName := strings.TrimSpace(match.SysName); sysName != "" {
56 + add("sysname", strings.ToLower(sysName))
57 + }
58 +
59 + if len(seen) == 0 {
60 + return nil
61 + }
62 +
63 + keys := make([]string, 0, len(seen))
64 + for key := range seen {
65 + keys = append(keys, key)
66 + }
67 + sort.Strings(keys)
68 + return keys
69 +}
src/go/plugin/go.d/collector/snmp_topology/topology_match_keys_lists.go new
+138
@@ -0,0 +1,138 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/pkg/topology"
10 +)
11 +
12 +func canonicalPrimaryMACListKey(match topology.Match) string {
13 + seen := make(map[string]struct{}, len(match.MacAddresses)+len(match.ChassisIDs))
14 + for _, value := range match.MacAddresses {
15 + if mac := normalizeMAC(value); mac != "" {
16 + seen[mac] = struct{}{}
17 + }
18 + }
19 + for _, value := range match.ChassisIDs {
20 + if mac := normalizeMAC(value); mac != "" {
21 + seen[mac] = struct{}{}
22 + }
23 + }
24 + if len(seen) == 0 {
25 + return ""
26 + }
27 + values := make([]string, 0, len(seen))
28 + for value := range seen {
29 + values = append(values, value)
30 + }
31 + sort.Strings(values)
32 + return strings.Join(values, ",")
33 +}
34 +
35 +func canonicalHardwareListKey(values []string) string {
36 + if len(values) == 0 {
37 + return ""
38 + }
39 + out := make([]string, 0, len(values))
40 + for _, value := range values {
41 + value = strings.TrimSpace(value)
42 + if value == "" {
43 + continue
44 + }
45 + if mac := normalizeMAC(value); mac != "" {
46 + out = append(out, mac)
47 + continue
48 + }
49 + if ip := normalizeIPAddress(value); ip != "" {
50 + out = append(out, ip)
51 + continue
52 + }
53 + out = append(out, strings.ToLower(value))
54 + }
55 + if len(out) == 0 {
56 + return ""
57 + }
58 + sort.Strings(out)
59 + out = uniqueStrings(out)
60 + return strings.Join(out, ",")
61 +}
62 +
63 +func canonicalMACListKey(values []string) string {
64 + if len(values) == 0 {
65 + return ""
66 + }
67 + out := make([]string, 0, len(values))
68 + for _, value := range values {
69 + if mac := normalizeMAC(value); mac != "" {
70 + out = append(out, mac)
71 + }
72 + }
73 + if len(out) == 0 {
74 + return ""
75 + }
76 + sort.Strings(out)
77 + out = uniqueStrings(out)
78 + return strings.Join(out, ",")
79 +}
80 +
81 +func canonicalIPListKey(values []string) string {
82 + if len(values) == 0 {
83 + return ""
84 + }
85 + out := make([]string, 0, len(values))
86 + for _, value := range values {
87 + value = strings.TrimSpace(value)
88 + if value == "" {
89 + continue
90 + }
91 + if ip := normalizeIPAddress(value); ip != "" {
92 + out = append(out, ip)
93 + continue
94 + }
95 + out = append(out, strings.ToLower(value))
96 + }
97 + if len(out) == 0 {
98 + return ""
99 + }
100 + sort.Strings(out)
101 + out = uniqueStrings(out)
102 + return strings.Join(out, ",")
103 +}
104 +
105 +func canonicalStringListKey(values []string) string {
106 + if len(values) == 0 {
107 + return ""
108 + }
109 + out := make([]string, 0, len(values))
110 + for _, value := range values {
111 + value = strings.ToLower(strings.TrimSpace(value))
112 + if value == "" {
113 + continue
114 + }
115 + out = append(out, value)
116 + }
117 + if len(out) == 0 {
118 + return ""
119 + }
120 + sort.Strings(out)
121 + out = uniqueStrings(out)
122 + return strings.Join(out, ",")
123 +}
124 +
125 +func uniqueStrings(values []string) []string {
126 + if len(values) <= 1 {
127 + return values
128 + }
129 + out := values[:0]
130 + var prev string
131 + for i, value := range values {
132 + if i == 0 || value != prev {
133 + out = append(out, value)
134 + prev = value
135 + }
136 + }
137 + return out
138 +}
src/go/plugin/go.d/collector/snmp_topology/topology_match_keys_test.go new
+24
@@ -0,0 +1,24 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestCanonicalKeyHelpers_DeduplicateAndNormalizeDeterministically(t *testing.T) {
12 + require.Equal(t,
13 + "10.20.4.60,alpha",
14 + canonicalIPListKey([]string{"alpha", "10.20.4.60", "0A14043C", "ALPHA"}),
15 + )
16 + require.Equal(t,
17 + "00:11:22:33:44:55,10.20.4.60,chassis-a",
18 + canonicalHardwareListKey([]string{"chassis-a", "00:11:22:33:44:55", "0A14043C", "001122334455"}),
19 + )
20 + require.Equal(t,
21 + "edge-a,edge-b",
22 + canonicalStringListKey([]string{"edge-b", " Edge-A ", "edge-a"}),
23 + )
24 +}
src/go/plugin/go.d/collector/snmp_topology/topology_metadata_aliases.go new
+37
@@ -0,0 +1,37 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +var (
6 + topologyMetadataAliasSysDescr = []string{
7 + "description", "sys_descr", "sys_description",
8 + }
9 + topologyMetadataAliasSysContact = []string{
10 + "contact", "sys_contact",
11 + }
12 + topologyMetadataAliasSysLocation = []string{
13 + "location", "sys_location",
14 + }
15 + topologyMetadataAliasVendor = []string{
16 + "vendor", "manufacturer",
17 + }
18 + topologyMetadataAliasModel = []string{
19 + "model", "device_model",
20 + }
21 + topologyMetadataAliasSysUptime = []string{
22 + "sys_uptime", "sysuptime", "uptime",
23 + }
24 + topologyMetadataAliasSerial = []string{
25 + "serial_number", "serial", "serial_num", "serial_no", "serialnumber",
26 + }
27 + topologyMetadataAliasFirmware = []string{
28 + "firmware_version", "firmware", "firmware_rev", "firmware_revision",
29 + }
30 + topologyMetadataAliasSoftware = []string{
31 + "software_version", "software", "software_rev", "software_revision",
32 + "sw_version", "sw_rev", "version", "os_version",
33 + }
34 + topologyMetadataAliasHardware = []string{
35 + "hardware_version", "hardware", "hardware_rev", "hw_version", "hw_rev",
36 + }
37 +)
src/go/plugin/go.d/collector/snmp_topology/topology_metadata_helpers.go new
+102
@@ -0,0 +1,102 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func pruneNilAttributes(attrs map[string]any) map[string]any {
11 + for k, v := range attrs {
12 + switch vv := v.(type) {
13 + case string:
14 + if vv == "" {
15 + delete(attrs, k)
16 + }
17 + case []string:
18 + if len(vv) == 0 {
19 + delete(attrs, k)
20 + }
21 + case []topologyManagementAddress:
22 + if len(vv) == 0 {
23 + delete(attrs, k)
24 + }
25 + case nil:
26 + delete(attrs, k)
27 + }
28 + }
29 + if len(attrs) == 0 {
30 + return nil
31 + }
32 + return attrs
33 +}
34 +
35 +func mapStringStringToAny(in map[string]string) map[string]any {
36 + if len(in) == 0 {
37 + return nil
38 + }
39 + out := make(map[string]any, len(in))
40 + for key, value := range in {
41 + key = strings.TrimSpace(key)
42 + value = strings.TrimSpace(value)
43 + if key == "" || value == "" {
44 + continue
45 + }
46 + out[key] = value
47 + }
48 + if len(out) == 0 {
49 + return nil
50 + }
51 + return out
52 +}
53 +
54 +func cloneTopologyLabels(in map[string]string) map[string]string {
55 + if len(in) == 0 {
56 + return nil
57 + }
58 + out := make(map[string]string, len(in))
59 + for key, value := range in {
60 + key = strings.TrimSpace(key)
61 + value = strings.TrimSpace(value)
62 + if key == "" || value == "" {
63 + continue
64 + }
65 + out[key] = value
66 + }
67 + if len(out) == 0 {
68 + return nil
69 + }
70 + return out
71 +}
72 +
73 +func ensureLabels(labels map[string]string) map[string]string {
74 + if labels == nil {
75 + return make(map[string]string)
76 + }
77 + return labels
78 +}
79 +
80 +func deduplicateSortedStrings(values []string) []string {
81 + if len(values) == 0 {
82 + return nil
83 + }
84 + out := make([]string, 0, len(values))
85 + seen := make(map[string]struct{}, len(values))
86 + for _, value := range values {
87 + value = strings.TrimSpace(value)
88 + if value == "" {
89 + continue
90 + }
91 + if _, ok := seen[value]; ok {
92 + continue
93 + }
94 + seen[value] = struct{}{}
95 + out = append(out, value)
96 + }
97 + sort.Strings(out)
98 + if len(out) == 0 {
99 + return nil
100 + }
101 + return out
102 +}
src/go/plugin/go.d/collector/snmp_topology/topology_metadata_values.go new
+70
@@ -0,0 +1,70 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func topologyCanonicalMetadataKey(key string) string {
11 + key = strings.ToLower(strings.TrimSpace(key))
12 + if key == "" {
13 + return ""
14 + }
15 + key = strings.NewReplacer("-", "_", ".", "_", " ", "_").Replace(key)
16 + for strings.Contains(key, "__") {
17 + key = strings.ReplaceAll(key, "__", "_")
18 + }
19 + return strings.Trim(key, "_")
20 +}
21 +
22 +func topologyMetadataValue(labels map[string]string, aliases []string) string {
23 + if len(labels) == 0 || len(aliases) == 0 {
24 + return ""
25 + }
26 + byKey := make(map[string]string, len(labels))
27 + keys := make([]string, 0, len(labels))
28 + for key := range labels {
29 + keys = append(keys, key)
30 + }
31 + sort.Strings(keys)
32 + for _, key := range keys {
33 + value := labels[key]
34 + value = strings.TrimSpace(value)
35 + if value == "" {
36 + continue
37 + }
38 + canonical := topologyCanonicalMetadataKey(key)
39 + if canonical == "" {
40 + continue
41 + }
42 + if _, exists := byKey[canonical]; !exists {
43 + byKey[canonical] = value
44 + }
45 + }
46 + for _, alias := range aliases {
47 + alias = topologyCanonicalMetadataKey(alias)
48 + if alias == "" {
49 + continue
50 + }
51 + if value := strings.TrimSpace(byKey[alias]); value != "" {
52 + return value
53 + }
54 + }
55 + return ""
56 +}
57 +
58 +func setTopologyMetadataLabelIfMissing(labels map[string]string, key, value string) {
59 + if labels == nil {
60 + return
61 + }
62 + key = strings.TrimSpace(key)
63 + value = strings.TrimSpace(value)
64 + if key == "" || value == "" {
65 + return
66 + }
67 + if existing := strings.TrimSpace(labels[key]); existing == "" {
68 + labels[key] = value
69 + }
70 +}
src/go/plugin/go.d/collector/snmp_topology/topology_metadata_values_test.go new
+45
@@ -0,0 +1,45 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestTopologyMetadataValue_CanonicalizesAliasKeys(t *testing.T) {
12 + labels := map[string]string{
13 + "Serial Number": "SN-123",
14 + "software.version": "17.9.4",
15 + "sys-location": "dc1",
16 + }
17 +
18 + require.Equal(t, "SN-123", topologyMetadataValue(labels, []string{"serial_number"}))
19 + require.Equal(t, "17.9.4", topologyMetadataValue(labels, []string{"software_version"}))
20 + require.Equal(t, "dc1", topologyMetadataValue(labels, []string{"sys_location"}))
21 +}
22 +
23 +func TestSetTopologyMetadataLabelIfMissing_PreservesExistingValue(t *testing.T) {
24 + labels := map[string]string{"serial_number": "SN-123"}
25 +
26 + setTopologyMetadataLabelIfMissing(labels, "serial_number", "SN-999")
27 + setTopologyMetadataLabelIfMissing(labels, "firmware_version", "1.2.3")
28 +
29 + require.Equal(t, "SN-123", labels["serial_number"])
30 + require.Equal(t, "1.2.3", labels["firmware_version"])
31 +}
32 +
33 +func TestTopologyMetadataValue_DeterministicAcrossCanonicalKeyCollisions(t *testing.T) {
34 + first := map[string]string{
35 + "serial_number": "SN-200",
36 + "serial-number": "SN-100",
37 + }
38 + second := map[string]string{
39 + "serial-number": "SN-100",
40 + "serial_number": "SN-200",
41 + }
42 +
43 + require.Equal(t, "SN-100", topologyMetadataValue(first, []string{"serial_number"}))
44 + require.Equal(t, "SN-100", topologyMetadataValue(second, []string{"serial_number"}))
45 +}
src/go/plugin/go.d/collector/snmp_topology/topology_metrics_test.go new
+48
@@ -0,0 +1,48 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 + "time"
8 +
9 + "github.com/stretchr/testify/assert"
10 +)
11 +
12 +func TestCollectTopologyMetrics(t *testing.T) {
13 + cache := newTopologyCache()
14 + cache.lastUpdate = time.Now()
15 + cache.localDevice = topologyDevice{
16 + ManagementIP: "192.0.2.1",
17 + ChassisID: "aa:bb:cc:dd:ee:ff",
18 + ChassisIDType: "macAddress",
19 + }
20 + cache.lldpLocPorts["1"] = &lldpLocPort{
21 + portNum: "1",
22 + portID: "Gi0/1",
23 + portIDSubtype: "interfaceName",
24 + }
25 + cache.lldpRemotes["1:1"] = &lldpRemote{
26 + localPortNum: "1",
27 + remIndex: "1",
28 + chassisID: "11:22:33:44:55:66",
29 + chassisIDSubtype: "macAddress",
30 + portID: "Gi0/2",
31 + portIDSubtype: "interfaceName",
32 + }
33 +
34 + snmpTopologyRegistry.register(cache)
35 + defer snmpTopologyRegistry.unregister(cache)
36 +
37 + c := New()
38 +
39 + mx := make(map[string]int64)
40 + c.collectTopologyMetrics(mx)
41 +
42 + // The topology engine pipeline processes raw cache data into actors and links.
43 + // With minimal test data (one local + one LLDP remote), the engine produces
44 + // at least the local device and the remote device as actors.
45 + assert.GreaterOrEqual(t, mx["snmp_topology_devices_total"], int64(1))
46 + assert.GreaterOrEqual(t, mx["snmp_topology_links_total"], int64(0))
47 + assert.True(t, c.topologyChartsAdded)
48 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_device_identity.go new
+42
@@ -0,0 +1,42 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +func ensureTopologyObservationDeviceID(device topologyDevice, baseBridgeAddress string) string {
8 + if mac := topologyPrimaryIdentityMAC(device.ChassisID, baseBridgeAddress); mac != "" {
9 + return "macAddress:" + mac
10 + }
11 + if key := strings.TrimSpace(topologyDeviceKey(device)); key != "" {
12 + return key
13 + }
14 + if sysName := strings.TrimSpace(device.SysName); sysName != "" {
15 + return "sysname:" + strings.ToLower(sysName)
16 + }
17 + if ip := normalizeIPAddress(device.ManagementIP); ip != "" {
18 + return "management_ip:" + ip
19 + }
20 + if managementIP := strings.TrimSpace(device.ManagementIP); managementIP != "" {
21 + return "management_addr:" + strings.ToLower(managementIP)
22 + }
23 + if jobID := strings.TrimSpace(device.AgentJobID); jobID != "" {
24 + return "agent_job:" + strings.ToLower(jobID)
25 + }
26 + if hostID := strings.TrimSpace(device.NetdataHostID); hostID != "" {
27 + return "agent:" + strings.ToLower(hostID)
28 + }
29 + if agentID := strings.TrimSpace(device.AgentID); agentID != "" {
30 + return "agent:" + strings.ToLower(agentID)
31 + }
32 + return "local-device"
33 +}
34 +
35 +func topologyPrimaryIdentityMAC(chassisID, baseBridgeAddress string) string {
36 + for _, candidate := range []string{chassisID, baseBridgeAddress} {
37 + if mac := normalizeMAC(candidate); mac != "" && mac != "00:00:00:00:00:00" {
38 + return mac
39 + }
40 + }
41 + return ""
42 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_device_identity_test.go new
+19
@@ -0,0 +1,19 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestEnsureTopologyObservationDeviceID_PrefersAgentScopedFallbacks(t *testing.T) {
12 + require.Equal(t, "agent_job:job-1", ensureTopologyObservationDeviceID(topologyDevice{AgentJobID: "Job-1"}, ""))
13 + require.Equal(t, "agent:11111111-1111-1111-1111-111111111111", ensureTopologyObservationDeviceID(topologyDevice{
14 + NetdataHostID: "11111111-1111-1111-1111-111111111111",
15 + }, ""))
16 + require.Equal(t, "agent:22222222-2222-2222-2222-222222222222", ensureTopologyObservationDeviceID(topologyDevice{
17 + AgentID: "22222222-2222-2222-2222-222222222222",
18 + }, ""))
19 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_identity.go new
+150
@@ -0,0 +1,150 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "fmt"
7 + "strings"
8 +
9 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
10 +)
11 +
12 +type topologyObservationIdentityResolver struct {
13 + hostToID map[string]string
14 + chassisToID map[string]string
15 + macToID map[string]string
16 + ipToID map[string]string
17 + fallbackSeq int
18 +}
19 +
20 +func newTopologyObservationIdentityResolver(local topologyengine.L2Observation) *topologyObservationIdentityResolver {
21 + resolver := &topologyObservationIdentityResolver{
22 + hostToID: make(map[string]string),
23 + chassisToID: make(map[string]string),
24 + macToID: make(map[string]string),
25 + ipToID: make(map[string]string),
26 + }
27 + resolver.register(local.DeviceID, []string{local.Hostname}, local.ChassisID, local.ManagementIP)
28 + return resolver
29 +}
30 +
31 +func (r *topologyObservationIdentityResolver) resolve(hostAliases []string, chassisID, chassisType, managementIP string) string {
32 + if mac := canonicalObservationMAC(chassisID); mac != "" {
33 + if id := r.macToID[mac]; id != "" {
34 + r.register(id, hostAliases, chassisID, managementIP)
35 + return id
36 + }
37 +
38 + candidate := normalizeTopologyDevice(topologyDevice{
39 + ChassisID: mac,
40 + ChassisIDType: "macAddress",
41 + SysName: firstNonEmpty(hostAliases...),
42 + ManagementIP: normalizeIPAddress(managementIP),
43 + })
44 + id := strings.TrimSpace(ensureTopologyObservationDeviceID(candidate, ""))
45 + if id == "" || id == "local-device" {
46 + r.fallbackSeq++
47 + id = fmt.Sprintf("remote-device-%d", r.fallbackSeq)
48 + }
49 + r.register(id, hostAliases, mac, managementIP)
50 + return id
51 + }
52 +
53 + for _, host := range hostAliases {
54 + if id := r.hostToID[canonicalObservationHost(host)]; id != "" {
55 + r.register(id, hostAliases, chassisID, managementIP)
56 + return id
57 + }
58 + }
59 + if id := r.chassisToID[canonicalObservationChassis(chassisID)]; id != "" {
60 + r.register(id, hostAliases, chassisID, managementIP)
61 + return id
62 + }
63 + if id := r.ipToID[canonicalObservationIP(managementIP)]; id != "" {
64 + r.register(id, hostAliases, chassisID, managementIP)
65 + return id
66 + }
67 +
68 + candidate := normalizeTopologyDevice(topologyDevice{
69 + ChassisID: strings.TrimSpace(chassisID),
70 + ChassisIDType: strings.TrimSpace(chassisType),
71 + SysName: firstNonEmpty(hostAliases...),
72 + ManagementIP: normalizeIPAddress(managementIP),
73 + })
74 + id := strings.TrimSpace(ensureTopologyObservationDeviceID(candidate, ""))
75 + if id == "" || id == "local-device" {
76 + r.fallbackSeq++
77 + id = fmt.Sprintf("remote-device-%d", r.fallbackSeq)
78 + }
79 + r.register(id, hostAliases, chassisID, managementIP)
80 + return id
81 +}
82 +
83 +func (r *topologyObservationIdentityResolver) register(id string, hostAliases []string, chassisID, managementIP string) {
84 + id = strings.TrimSpace(id)
85 + if id == "" {
86 + return
87 + }
88 + for _, host := range hostAliases {
89 + if key := canonicalObservationHost(host); key != "" {
90 + if _, exists := r.hostToID[key]; !exists {
91 + r.hostToID[key] = id
92 + }
93 + }
94 + }
95 + if key := canonicalObservationChassis(chassisID); key != "" {
96 + if _, exists := r.chassisToID[key]; !exists {
97 + r.chassisToID[key] = id
98 + }
99 + }
100 + if mac := canonicalObservationMAC(chassisID); mac != "" {
101 + if _, exists := r.macToID[mac]; !exists {
102 + r.macToID[mac] = id
103 + }
104 + }
105 + if key := canonicalObservationIP(managementIP); key != "" {
106 + if _, exists := r.ipToID[key]; !exists {
107 + r.ipToID[key] = id
108 + }
109 + }
110 +}
111 +
112 +func canonicalObservationHost(value string) string {
113 + return strings.ToLower(strings.TrimSpace(value))
114 +}
115 +
116 +func canonicalObservationChassis(value string) string {
117 + value = strings.TrimSpace(value)
118 + if value == "" {
119 + return ""
120 + }
121 + if mac := normalizeMAC(value); mac != "" {
122 + return mac
123 + }
124 + return strings.ToLower(value)
125 +}
126 +
127 +func canonicalObservationMAC(value string) string {
128 + if mac := normalizeMAC(value); mac != "" {
129 + return mac
130 + }
131 + return ""
132 +}
133 +
134 +func canonicalObservationIP(value string) string {
135 + value = normalizeIPAddress(value)
136 + if value != "" {
137 + return strings.ToLower(value)
138 + }
139 + return strings.ToLower(strings.TrimSpace(value))
140 +}
141 +
142 +func firstNonEmpty(values ...string) string {
143 + for _, value := range values {
144 + value = strings.TrimSpace(value)
145 + if value != "" {
146 + return value
147 + }
148 + }
149 + return ""
150 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_identity_test.go new
+62
@@ -0,0 +1,62 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestTopologyObservationIdentityCanonicalHelpers(t *testing.T) {
13 + require.Equal(t, "edge-a", canonicalObservationHost(" Edge-A "))
14 + require.Equal(t, "00:11:22:33:44:55", canonicalObservationChassis("001122334455"))
15 + require.Equal(t, "chassis-a", canonicalObservationChassis("Chassis-A"))
16 + require.Equal(t, "00:11:22:33:44:55", canonicalObservationMAC("00-11-22-33-44-55"))
17 + require.Equal(t, "10.20.4.60", canonicalObservationIP("0A14043C"))
18 + require.Equal(t, "", canonicalObservationIP(""))
19 +}
20 +
21 +func TestTopologyObservationIdentityResolver_AssignsDeterministicFallbackIDs(t *testing.T) {
22 + resolver := newTopologyObservationIdentityResolver(topologyengine.L2Observation{
23 + DeviceID: "local-device",
24 + Hostname: "sw-a",
25 + ChassisID: "00:11:22:33:44:55",
26 + ManagementIP: "10.0.0.1",
27 + })
28 +
29 + first := resolver.resolve(nil, "", "", "")
30 + second := resolver.resolve(nil, "", "", "")
31 +
32 + require.Equal(t, "remote-device-1", first)
33 + require.Equal(t, "remote-device-2", second)
34 +}
35 +
36 +func TestTopologyObservationIdentityResolver_ReusesFallbackIdentityAcrossCanonicalSignals(t *testing.T) {
37 + resolver := newTopologyObservationIdentityResolver(topologyengine.L2Observation{
38 + DeviceID: "local-device",
39 + Hostname: "sw-a",
40 + ChassisID: "00:11:22:33:44:55",
41 + ManagementIP: "10.0.0.1",
42 + })
43 +
44 + id := resolver.resolve([]string{"Edge-A"}, "chassis-a", "local", "10.20.4.60")
45 +
46 + require.Equal(t, id, resolver.resolve([]string{" edge-a "}, "", "", ""))
47 + require.Equal(t, id, resolver.resolve(nil, "CHASSIS-A", "local", ""))
48 + require.Equal(t, id, resolver.resolve(nil, "", "", "10.20.4.60"))
49 +}
50 +
51 +func TestTopologyObservationIdentityResolver_RegistersAliasesWhenExistingIdentityIsReused(t *testing.T) {
52 + resolver := newTopologyObservationIdentityResolver(topologyengine.L2Observation{
53 + DeviceID: "local-device",
54 + Hostname: "sw-a",
55 + ChassisID: "00:11:22:33:44:55",
56 + ManagementIP: "10.0.0.1",
57 + })
58 +
59 + id := resolver.resolve(nil, "", "", "10.20.4.60")
60 + require.Equal(t, id, resolver.resolve([]string{"Edge-A"}, "", "", "10.20.4.60"))
61 + require.Equal(t, id, resolver.resolve([]string{" edge-a "}, "", "", ""))
62 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_local.go new
+44
@@ -0,0 +1,44 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strings"
7 +
8 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
9 +)
10 +
11 +func (c *topologyCache) buildEngineObservation(local topologyDevice) topologyengine.L2Observation {
12 + localManagementIP := normalizeIPAddress(local.ManagementIP)
13 + if localManagementIP == "" {
14 + localManagementIP = pickManagementIP(local.ManagementAddresses)
15 + }
16 +
17 + baseBridgeAddress := c.resolveLocalBaseBridgeAddress(localManagementIP)
18 + if baseBridgeAddress != "" && normalizeMAC(local.ChassisID) == "" {
19 + local.ChassisID = baseBridgeAddress
20 + local.ChassisIDType = "macAddress"
21 + }
22 +
23 + observation := topologyengine.L2Observation{
24 + DeviceID: ensureTopologyObservationDeviceID(local, baseBridgeAddress),
25 + Hostname: strings.TrimSpace(local.SysName),
26 + ManagementIP: localManagementIP,
27 + SysObjectID: strings.TrimSpace(local.SysObjectID),
28 + ChassisID: strings.TrimSpace(local.ChassisID),
29 + BaseBridgeAddress: baseBridgeAddress,
30 + }
31 + if observation.BaseBridgeAddress == "" {
32 + observation.BaseBridgeAddress = stpBridgeAddressToMAC(observation.ChassisID)
33 + }
34 +
35 + c.appendObservedInterfaces(&observation)
36 + c.appendObservedBridgePorts(&observation)
37 + c.appendObservedFDBEntries(&observation)
38 + c.appendObservedSTPPorts(&observation)
39 + c.appendObservedARPNDEntries(&observation)
40 + c.appendObservedLLDPRemotes(&observation)
41 + c.appendObservedCDPRemotes(&observation)
42 +
43 + return observation
44 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_local_forwarding.go new
+120
@@ -0,0 +1,120 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strconv"
8 + "strings"
9 +
10 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
11 +)
12 +
13 +func (c *topologyCache) appendObservedFDBEntries(observation *topologyengine.L2Observation) {
14 + if observation == nil {
15 + return
16 + }
17 +
18 + keys := make([]string, 0, len(c.fdbEntries))
19 + for key := range c.fdbEntries {
20 + keys = append(keys, key)
21 + }
22 + sort.Strings(keys)
23 +
24 + for _, key := range keys {
25 + entry := c.fdbEntries[key]
26 + if entry == nil || strings.TrimSpace(entry.mac) == "" {
27 + continue
28 + }
29 + ifIndex := parseIndex(c.bridgePortToIf[strings.TrimSpace(entry.bridgePort)])
30 + vlanID := strings.TrimSpace(entry.vlanID)
31 + if vlanID == "" && strings.TrimSpace(entry.fdbID) != "" {
32 + vlanID = strings.TrimSpace(c.fdbIDToVlanID[strings.TrimSpace(entry.fdbID)])
33 + }
34 + observation.FDBEntries = append(observation.FDBEntries, topologyengine.FDBObservation{
35 + MAC: strings.TrimSpace(entry.mac),
36 + BridgePort: strings.TrimSpace(entry.bridgePort),
37 + IfIndex: ifIndex,
38 + Status: strings.TrimSpace(entry.status),
39 + VLANID: vlanID,
40 + VLANName: strings.TrimSpace(entry.vlanName),
41 + })
42 + }
43 +}
44 +
45 +func (c *topologyCache) appendObservedSTPPorts(observation *topologyengine.L2Observation) {
46 + if observation == nil {
47 + return
48 + }
49 +
50 + keys := make([]string, 0, len(c.stpPorts))
51 + for key := range c.stpPorts {
52 + keys = append(keys, key)
53 + }
54 + sort.Strings(keys)
55 +
56 + for _, key := range keys {
57 + entry := c.stpPorts[key]
58 + if entry == nil {
59 + continue
60 + }
61 + port := strings.TrimSpace(entry.port)
62 + if port == "" {
63 + continue
64 + }
65 + ifIndex := parseIndex(c.bridgePortToIf[port])
66 + ifName := ""
67 + if ifIndex > 0 {
68 + ifName = strings.TrimSpace(c.ifNamesByIndex[strconv.Itoa(ifIndex)])
69 + }
70 + observation.STPPorts = append(observation.STPPorts, topologyengine.STPPortObservation{
71 + Port: port,
72 + IfIndex: ifIndex,
73 + IfName: ifName,
74 + VLANID: strings.TrimSpace(entry.vlanID),
75 + VLANName: strings.TrimSpace(entry.vlanName),
76 + State: strings.TrimSpace(entry.state),
77 + Enable: strings.TrimSpace(entry.enable),
78 + PathCost: strings.TrimSpace(entry.pathCost),
79 + DesignatedRoot: strings.TrimSpace(entry.designatedRoot),
80 + DesignatedBridge: strings.TrimSpace(entry.designatedBridge),
81 + DesignatedPort: strings.TrimSpace(entry.designatedPort),
82 + })
83 + }
84 +}
85 +
86 +func (c *topologyCache) appendObservedARPNDEntries(observation *topologyengine.L2Observation) {
87 + if observation == nil {
88 + return
89 + }
90 +
91 + keys := make([]string, 0, len(c.arpEntries))
92 + for key := range c.arpEntries {
93 + keys = append(keys, key)
94 + }
95 + sort.Strings(keys)
96 +
97 + for _, key := range keys {
98 + entry := c.arpEntries[key]
99 + if entry == nil {
100 + continue
101 + }
102 + mac := strings.TrimSpace(entry.mac)
103 + if normalizeMAC(mac) == "" {
104 + continue // incomplete ARP entry — no MAC means we can't place it on the L2 topology
105 + }
106 + ifName := strings.TrimSpace(entry.ifName)
107 + if ifName == "" && strings.TrimSpace(entry.ifIndex) != "" {
108 + ifName = strings.TrimSpace(c.ifNamesByIndex[entry.ifIndex])
109 + }
110 + observation.ARPNDEntries = append(observation.ARPNDEntries, topologyengine.ARPNDObservation{
111 + Protocol: "arp",
112 + IfIndex: parseIndex(entry.ifIndex),
113 + IfName: ifName,
114 + IP: strings.TrimSpace(entry.ip),
115 + MAC: strings.TrimSpace(entry.mac),
116 + State: strings.TrimSpace(entry.state),
117 + AddrType: strings.TrimSpace(entry.addrType),
118 + })
119 + }
120 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_local_identity.go new
+101
@@ -0,0 +1,101 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func (c *topologyCache) resolveLocalBaseBridgeAddress(localManagementIP string) string {
11 + baseBridgeAddress := strings.TrimSpace(c.stpBaseBridgeAddress)
12 + if baseBridgeAddress == "" {
13 + baseBridgeAddress = c.deriveLocalBridgeMACFromFDBSelfEntries()
14 + }
15 + if baseBridgeAddress == "" {
16 + baseBridgeAddress = c.deriveLocalBridgeMACFromInterfacePhysAddress(localManagementIP)
17 + }
18 + return baseBridgeAddress
19 +}
20 +
21 +func (c *topologyCache) deriveLocalBridgeMACFromFDBSelfEntries() string {
22 + if len(c.fdbEntries) == 0 {
23 + return ""
24 + }
25 +
26 + keys := make([]string, 0, len(c.fdbEntries))
27 + for key := range c.fdbEntries {
28 + keys = append(keys, key)
29 + }
30 + sort.Strings(keys)
31 +
32 + for _, key := range keys {
33 + entry := c.fdbEntries[key]
34 + if entry == nil || !isFDBSelfStatus(entry.status) {
35 + continue
36 + }
37 + mac := normalizeMAC(entry.mac)
38 + if mac == "" || mac == "00:00:00:00:00:00" {
39 + continue
40 + }
41 + return mac
42 + }
43 +
44 + return ""
45 +}
46 +
47 +func (c *topologyCache) deriveLocalBridgeMACFromInterfacePhysAddress(localManagementIP string) string {
48 + if len(c.ifStatusByIndex) == 0 {
49 + return ""
50 + }
51 +
52 + localManagementIP = normalizeIPAddress(localManagementIP)
53 + if localManagementIP != "" {
54 + ifIndex := strings.TrimSpace(c.ifIndexByIP[localManagementIP])
55 + if ifIndex != "" {
56 + if status, ok := c.ifStatusByIndex[ifIndex]; ok {
57 + if mac := normalizeMAC(status.mac); mac != "" && mac != "00:00:00:00:00:00" {
58 + return mac
59 + }
60 + }
61 + }
62 + }
63 +
64 + keys := make([]string, 0, len(c.ifStatusByIndex))
65 + for key := range c.ifStatusByIndex {
66 + keys = append(keys, key)
67 + }
68 + sort.Slice(keys, func(i, j int) bool {
69 + left := parseIndex(keys[i])
70 + right := parseIndex(keys[j])
71 + if left > 0 && right > 0 && left != right {
72 + return left < right
73 + }
74 + if left > 0 && right <= 0 {
75 + return true
76 + }
77 + if left <= 0 && right > 0 {
78 + return false
79 + }
80 + return keys[i] < keys[j]
81 + })
82 +
83 + for _, key := range keys {
84 + mac := normalizeMAC(c.ifStatusByIndex[key].mac)
85 + if mac == "" || mac == "00:00:00:00:00:00" {
86 + continue
87 + }
88 + return mac
89 + }
90 +
91 + return ""
92 +}
93 +
94 +func isFDBSelfStatus(value string) bool {
95 + switch strings.ToLower(strings.TrimSpace(value)) {
96 + case "4", "self", "dot1d_tp_fdb_status_self", "dot1dtpfdbstatusself", "dot1q_tp_fdb_status_self", "dot1qtpfdbstatusself":
97 + return true
98 + default:
99 + return false
100 + }
101 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_local_interfaces.go new
+82
@@ -0,0 +1,82 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +
9 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
10 +)
11 +
12 +func (c *topologyCache) appendObservedInterfaces(observation *topologyengine.L2Observation) {
13 + if observation == nil {
14 + return
15 + }
16 +
17 + ifaceKeys := make(map[string]struct{}, len(c.ifNamesByIndex)+len(c.ifStatusByIndex))
18 + for key := range c.ifNamesByIndex {
19 + ifaceKeys[key] = struct{}{}
20 + }
21 + for key := range c.ifStatusByIndex {
22 + ifaceKeys[key] = struct{}{}
23 + }
24 +
25 + ifaceKeyList := make([]string, 0, len(ifaceKeys))
26 + for key := range ifaceKeys {
27 + ifaceKeyList = append(ifaceKeyList, key)
28 + }
29 + sort.Strings(ifaceKeyList)
30 +
31 + for _, ifIndex := range ifaceKeyList {
32 + idx := parseIndex(ifIndex)
33 + if idx <= 0 {
34 + continue
35 + }
36 + ifName := strings.TrimSpace(c.ifNamesByIndex[ifIndex])
37 + if ifName == "" {
38 + ifName = ifIndex
39 + }
40 + status := c.ifStatusByIndex[ifIndex]
41 + ifDescr := strings.TrimSpace(status.ifDescr)
42 + if ifDescr == "" {
43 + ifDescr = ifName
44 + }
45 + observation.Interfaces = append(observation.Interfaces, topologyengine.ObservedInterface{
46 + IfIndex: idx,
47 + IfName: ifName,
48 + IfDescr: ifDescr,
49 + IfAlias: strings.TrimSpace(status.ifAlias),
50 + MAC: strings.TrimSpace(status.mac),
51 + SpeedBps: status.speedBps,
52 + LastChange: status.lastChange,
53 + Duplex: strings.TrimSpace(status.duplex),
54 + InterfaceType: strings.TrimSpace(status.ifType),
55 + AdminStatus: strings.TrimSpace(status.admin),
56 + OperStatus: strings.TrimSpace(status.oper),
57 + })
58 + }
59 +}
60 +
61 +func (c *topologyCache) appendObservedBridgePorts(observation *topologyengine.L2Observation) {
62 + if observation == nil {
63 + return
64 + }
65 +
66 + keys := make([]string, 0, len(c.bridgePortToIf))
67 + for key := range c.bridgePortToIf {
68 + keys = append(keys, key)
69 + }
70 + sort.Strings(keys)
71 +
72 + for _, basePort := range keys {
73 + ifIndex := parseIndex(c.bridgePortToIf[basePort])
74 + if ifIndex <= 0 {
75 + continue
76 + }
77 + observation.BridgePorts = append(observation.BridgePorts, topologyengine.BridgePortObservation{
78 + BasePort: strings.TrimSpace(basePort),
79 + IfIndex: ifIndex,
80 + })
81 + }
82 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_local_neighbors.go new
+106
@@ -0,0 +1,106 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +
9 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
10 +)
11 +
12 +func (c *topologyCache) appendObservedLLDPRemotes(observation *topologyengine.L2Observation) {
13 + if observation == nil {
14 + return
15 + }
16 +
17 + keys := make([]string, 0, len(c.lldpRemotes))
18 + for key := range c.lldpRemotes {
19 + keys = append(keys, key)
20 + }
21 + sort.Strings(keys)
22 +
23 + for _, key := range keys {
24 + remote := c.lldpRemotes[key]
25 + if remote == nil {
26 + continue
27 + }
28 +
29 + managementIP := normalizeIPAddress(remote.managementAddr)
30 + if managementIP == "" {
31 + managementIP = pickManagementIP(remote.managementAddrs)
32 + }
33 +
34 + localPort := c.lldpLocPorts[remote.localPortNum]
35 + localPortID := ""
36 + localPortIDSubtype := ""
37 + localPortDesc := ""
38 + if localPort != nil {
39 + localPortID = strings.TrimSpace(localPort.portID)
40 + localPortIDSubtype = strings.TrimSpace(localPort.portIDSubtype)
41 + localPortDesc = strings.TrimSpace(localPort.portDesc)
42 + }
43 +
44 + observation.LLDPRemotes = append(observation.LLDPRemotes, topologyengine.LLDPRemoteObservation{
45 + LocalPortNum: strings.TrimSpace(remote.localPortNum),
46 + RemoteIndex: strings.TrimSpace(remote.remIndex),
47 + LocalPortID: localPortID,
48 + LocalPortIDSubtype: localPortIDSubtype,
49 + LocalPortDesc: localPortDesc,
50 + ChassisID: strings.TrimSpace(remote.chassisID),
51 + SysName: strings.TrimSpace(remote.sysName),
52 + PortID: strings.TrimSpace(remote.portID),
53 + PortIDSubtype: strings.TrimSpace(remote.portIDSubtype),
54 + PortDesc: strings.TrimSpace(remote.portDesc),
55 + ManagementIP: managementIP,
56 + })
57 + }
58 +}
59 +
60 +func (c *topologyCache) appendObservedCDPRemotes(observation *topologyengine.L2Observation) {
61 + if observation == nil {
62 + return
63 + }
64 +
65 + keys := make([]string, 0, len(c.cdpRemotes))
66 + for key := range c.cdpRemotes {
67 + keys = append(keys, key)
68 + }
69 + sort.Strings(keys)
70 +
71 + for _, key := range keys {
72 + remote := c.cdpRemotes[key]
73 + if remote == nil {
74 + continue
75 + }
76 +
77 + deviceID := strings.TrimSpace(remote.deviceID)
78 + sysName := strings.TrimSpace(remote.sysName)
79 + if deviceID == "" {
80 + deviceID = sysName
81 + }
82 + if deviceID == "" {
83 + continue
84 + }
85 +
86 + ifName := strings.TrimSpace(remote.ifName)
87 + if ifName == "" && strings.TrimSpace(remote.ifIndex) != "" {
88 + ifName = strings.TrimSpace(c.ifNamesByIndex[remote.ifIndex])
89 + }
90 +
91 + address := strings.TrimSpace(remote.address)
92 + if address == "" {
93 + address = pickManagementIP(remote.managementAddrs)
94 + }
95 +
96 + observation.CDPRemotes = append(observation.CDPRemotes, topologyengine.CDPRemoteObservation{
97 + LocalIfIndex: parseIndex(remote.ifIndex),
98 + LocalIfName: ifName,
99 + DeviceIndex: strings.TrimSpace(remote.deviceIndex),
100 + DeviceID: deviceID,
101 + SysName: sysName,
102 + DevicePort: strings.TrimSpace(remote.devicePort),
103 + Address: address,
104 + })
105 + }
106 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_remote.go new
+109
@@ -0,0 +1,109 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +
9 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
10 +)
11 +
12 +type topologyRemoteObservationBuilder struct {
13 + cache *topologyCache
14 + local topologyDevice
15 + localObservation topologyengine.L2Observation
16 + localManagementIP string
17 + localSysName string
18 + localGlobalID string
19 +
20 + resolver *topologyObservationIdentityResolver
21 + remoteObservations map[string]*topologyengine.L2Observation
22 + remoteOrder []string
23 + remoteManagementByID map[string]string
24 + remoteChassisByID map[string]string
25 +}
26 +
27 +func newTopologyRemoteObservationBuilder(cache *topologyCache, local topologyDevice, localObservation topologyengine.L2Observation) *topologyRemoteObservationBuilder {
28 + localManagementIP := normalizeIPAddress(local.ManagementIP)
29 + if localManagementIP == "" {
30 + localManagementIP = pickManagementIP(local.ManagementAddresses)
31 + }
32 +
33 + localGlobalID := strings.TrimSpace(localObservation.Hostname)
34 + if localGlobalID == "" {
35 + localGlobalID = localObservation.DeviceID
36 + }
37 +
38 + return &topologyRemoteObservationBuilder{
39 + cache: cache,
40 + local: local,
41 + localObservation: localObservation,
42 + localManagementIP: localManagementIP,
43 + localSysName: strings.TrimSpace(local.SysName),
44 + localGlobalID: localGlobalID,
45 + resolver: newTopologyObservationIdentityResolver(localObservation),
46 + remoteObservations: make(map[string]*topologyengine.L2Observation),
47 + remoteOrder: make([]string, 0, len(cache.lldpRemotes)+len(cache.cdpRemotes)),
48 + remoteManagementByID: make(map[string]string),
49 + remoteChassisByID: make(map[string]string),
50 + }
51 +}
52 +
53 +func (c *topologyCache) buildEngineObservations(local topologyDevice) ([]topologyengine.L2Observation, string) {
54 + localObservation := c.buildEngineObservation(local)
55 + localObservation.DeviceID = strings.TrimSpace(localObservation.DeviceID)
56 + if localObservation.DeviceID == "" {
57 + return nil, ""
58 + }
59 +
60 + builder := newTopologyRemoteObservationBuilder(c, local, localObservation)
61 + builder.collectLLDPRemoteObservations()
62 + builder.collectCDPRemoteObservations()
63 +
64 + return builder.observations(), localObservation.DeviceID
65 +}
66 +
67 +func (b *topologyRemoteObservationBuilder) observations() []topologyengine.L2Observation {
68 + observations := make([]topologyengine.L2Observation, 0, 1+len(b.remoteObservations))
69 + observations = append(observations, b.localObservation)
70 +
71 + sort.Strings(b.remoteOrder)
72 + for _, key := range b.remoteOrder {
73 + entry := b.remoteObservations[key]
74 + if entry == nil {
75 + continue
76 + }
77 + if entry.ManagementIP == "" {
78 + entry.ManagementIP = b.remoteManagementByID[entry.DeviceID]
79 + }
80 + if entry.ChassisID == "" {
81 + entry.ChassisID = b.remoteChassisByID[entry.DeviceID]
82 + }
83 + if len(entry.LLDPRemotes) == 0 && len(entry.CDPRemotes) == 0 {
84 + continue
85 + }
86 + if entry.Hostname == "" {
87 + entry.Hostname = entry.DeviceID
88 + }
89 + observations = append(observations, *entry)
90 + }
91 +
92 + return observations
93 +}
94 +
95 +func selectTopologyRemoteHostname(current, candidate, deviceID string) string {
96 + current = strings.TrimSpace(current)
97 + candidate = strings.TrimSpace(candidate)
98 + deviceID = strings.TrimSpace(deviceID)
99 + if candidate == "" {
100 + if current != "" {
101 + return current
102 + }
103 + return deviceID
104 + }
105 + if current == "" || current == deviceID {
106 + return candidate
107 + }
108 + return current
109 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_remote_cdp.go new
+74
@@ -0,0 +1,74 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +
9 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
10 +)
11 +
12 +func (b *topologyRemoteObservationBuilder) collectCDPRemoteObservations() {
13 + keys := make([]string, 0, len(b.cache.cdpRemotes))
14 + for key := range b.cache.cdpRemotes {
15 + keys = append(keys, key)
16 + }
17 + sort.Strings(keys)
18 +
19 + for _, key := range keys {
20 + remote := b.cache.cdpRemotes[key]
21 + if remote == nil {
22 + continue
23 + }
24 +
25 + remoteDeviceToken := strings.TrimSpace(remote.deviceID)
26 + remoteSysName := strings.TrimSpace(remote.sysName)
27 + remoteManagementIP := normalizeIPAddress(remote.address)
28 + if remoteManagementIP == "" {
29 + remoteManagementIP = pickManagementIP(remote.managementAddrs)
30 + }
31 + if remoteManagementIP == "" && remoteDeviceToken == "" && remoteSysName == "" {
32 + continue
33 + }
34 +
35 + remoteDeviceID := b.resolver.resolve(
36 + []string{remoteDeviceToken, remoteSysName},
37 + "",
38 + "",
39 + remoteManagementIP,
40 + )
41 + if remoteDeviceID == "" || remoteDeviceID == b.localObservation.DeviceID {
42 + continue
43 + }
44 + b.updateRemoteIdentity(remoteDeviceID, remoteManagementIP, "")
45 +
46 + remoteIfName := strings.TrimSpace(remote.devicePort)
47 + localIfName := strings.TrimSpace(remote.ifName)
48 + if localIfName == "" && strings.TrimSpace(remote.ifIndex) != "" {
49 + localIfName = strings.TrimSpace(b.cache.ifNamesByIndex[remote.ifIndex])
50 + }
51 + if remoteIfName == "" || localIfName == "" {
52 + continue
53 + }
54 +
55 + remoteObservation := b.ensureRemoteObservation(
56 + "cdp",
57 + remoteDeviceID,
58 + firstNonEmpty(remoteSysName, remoteDeviceToken, remoteDeviceID),
59 + remoteManagementIP,
60 + "",
61 + )
62 + if remoteObservation == nil {
63 + continue
64 + }
65 +
66 + remoteObservation.CDPRemotes = append(remoteObservation.CDPRemotes, topologyengine.CDPRemoteObservation{
67 + LocalIfName: remoteIfName,
68 + DeviceID: b.localGlobalID,
69 + SysName: b.localSysName,
70 + DevicePort: localIfName,
71 + Address: b.localManagementIP,
72 + })
73 + }
74 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_remote_identity.go new
+55
@@ -0,0 +1,55 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strings"
7 +
8 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
9 +)
10 +
11 +func (b *topologyRemoteObservationBuilder) updateRemoteIdentity(deviceID, managementIP, chassisID string) {
12 + deviceID = strings.TrimSpace(deviceID)
13 + if deviceID == "" {
14 + return
15 + }
16 + if managementIP = canonicalObservationIP(managementIP); managementIP != "" {
17 + if _, ok := b.remoteManagementByID[deviceID]; !ok {
18 + b.remoteManagementByID[deviceID] = managementIP
19 + }
20 + }
21 + if chassisID = strings.TrimSpace(chassisID); chassisID != "" {
22 + if _, ok := b.remoteChassisByID[deviceID]; !ok {
23 + b.remoteChassisByID[deviceID] = chassisID
24 + }
25 + }
26 +}
27 +
28 +func (b *topologyRemoteObservationBuilder) ensureRemoteObservation(protocol, deviceID, hostname, managementIP, chassisID string) *topologyengine.L2Observation {
29 + deviceID = strings.TrimSpace(deviceID)
30 + if deviceID == "" {
31 + return nil
32 + }
33 +
34 + key := protocol + "|" + deviceID
35 + entry := b.remoteObservations[key]
36 + if entry == nil {
37 + entry = &topologyengine.L2Observation{
38 + DeviceID: deviceID,
39 + Inferred: true,
40 + }
41 + b.remoteObservations[key] = entry
42 + b.remoteOrder = append(b.remoteOrder, key)
43 + }
44 +
45 + entry.Hostname = selectTopologyRemoteHostname(entry.Hostname, hostname, deviceID)
46 + b.updateRemoteIdentity(deviceID, managementIP, chassisID)
47 + if entry.ManagementIP == "" {
48 + entry.ManagementIP = b.remoteManagementByID[deviceID]
49 + }
50 + if entry.ChassisID == "" {
51 + entry.ChassisID = b.remoteChassisByID[deviceID]
52 + }
53 + b.resolver.register(deviceID, []string{entry.Hostname}, entry.ChassisID, entry.ManagementIP)
54 + return entry
55 +}
src/go/plugin/go.d/collector/snmp_topology/topology_observation_remote_lldp.go new
+85
@@ -0,0 +1,85 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +
9 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
10 +)
11 +
12 +func (b *topologyRemoteObservationBuilder) collectLLDPRemoteObservations() {
13 + keys := make([]string, 0, len(b.cache.lldpRemotes))
14 + for key := range b.cache.lldpRemotes {
15 + keys = append(keys, key)
16 + }
17 + sort.Strings(keys)
18 +
19 + for _, key := range keys {
20 + remote := b.cache.lldpRemotes[key]
21 + if remote == nil {
22 + continue
23 + }
24 +
25 + remoteSysName := strings.TrimSpace(remote.sysName)
26 + remoteChassisID := strings.TrimSpace(remote.chassisID)
27 + remoteManagementIP := normalizeIPAddress(remote.managementAddr)
28 + if remoteManagementIP == "" {
29 + remoteManagementIP = pickManagementIP(remote.managementAddrs)
30 + }
31 +
32 + remoteDeviceID := b.resolver.resolve(
33 + []string{remoteSysName},
34 + remoteChassisID,
35 + strings.TrimSpace(remote.chassisIDSubtype),
36 + remoteManagementIP,
37 + )
38 + if remoteDeviceID == "" || remoteDeviceID == b.localObservation.DeviceID {
39 + continue
40 + }
41 + b.updateRemoteIdentity(remoteDeviceID, remoteManagementIP, remoteChassisID)
42 +
43 + remoteObservation := b.ensureRemoteObservation(
44 + "lldp",
45 + remoteDeviceID,
46 + firstNonEmpty(remoteSysName, remoteDeviceID),
47 + remoteManagementIP,
48 + remoteChassisID,
49 + )
50 + if remoteObservation == nil {
51 + continue
52 + }
53 +
54 + localPort := b.cache.lldpLocPorts[remote.localPortNum]
55 + localPortID := ""
56 + localPortIDSubtype := ""
57 + localPortDesc := ""
58 + if localPort != nil {
59 + localPortID = strings.TrimSpace(localPort.portID)
60 + localPortIDSubtype = strings.TrimSpace(localPort.portIDSubtype)
61 + localPortDesc = strings.TrimSpace(localPort.portDesc)
62 + }
63 +
64 + if strings.TrimSpace(remote.portID) == "" &&
65 + strings.TrimSpace(remote.portDesc) == "" &&
66 + localPortID == "" &&
67 + localPortDesc == "" {
68 + continue
69 + }
70 +
71 + remoteObservation.LLDPRemotes = append(remoteObservation.LLDPRemotes, topologyengine.LLDPRemoteObservation{
72 + LocalPortNum: strings.TrimSpace(remote.remIndex),
73 + RemoteIndex: strings.TrimSpace(remote.localPortNum),
74 + LocalPortID: strings.TrimSpace(remote.portID),
75 + LocalPortIDSubtype: strings.TrimSpace(remote.portIDSubtype),
76 + LocalPortDesc: strings.TrimSpace(remote.portDesc),
77 + ChassisID: strings.TrimSpace(b.local.ChassisID),
78 + SysName: b.localSysName,
79 + PortID: localPortID,
80 + PortIDSubtype: localPortIDSubtype,
81 + PortDesc: localPortDesc,
82 + ManagementIP: b.localManagementIP,
83 + })
84 + }
85 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_cleanup.go new
+130
@@ -0,0 +1,130 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +func eliminateNonIPInferredActors(data *topologyData) int {
8 + if data == nil || len(data.Actors) == 0 {
9 + return 0
10 + }
11 +
12 + removedIDs := make(map[string]struct{})
13 + keptActors := make([]topologyActor, 0, len(data.Actors))
14 + for _, actor := range data.Actors {
15 + if topologyActorIsInferred(actor) && len(normalizedMatchIPs(actor.Match)) == 0 {
16 + removedIDs[actor.ActorID] = struct{}{}
17 + continue
18 + }
19 + keptActors = append(keptActors, actor)
20 + }
21 +
22 + if len(removedIDs) == 0 {
23 + return 0
24 + }
25 +
26 + data.Actors = keptActors
27 + links := make([]topologyLink, 0, len(data.Links))
28 + for _, link := range data.Links {
29 + if _, removed := removedIDs[link.SrcActorID]; removed {
30 + continue
31 + }
32 + if _, removed := removedIDs[link.DstActorID]; removed {
33 + continue
34 + }
35 + links = append(links, link)
36 + }
37 + data.Links = links
38 + return len(removedIDs)
39 +}
40 +
41 +func pruneSparseSegments(data *topologyData, threshold int) int {
42 + if data == nil || len(data.Actors) == 0 {
43 + return 0
44 + }
45 +
46 + removedTotal := 0
47 + for {
48 + segmentSet := make(map[string]struct{})
49 + for _, actor := range data.Actors {
50 + if strings.EqualFold(strings.TrimSpace(actor.ActorType), "segment") {
51 + segmentSet[actor.ActorID] = struct{}{}
52 + }
53 + }
54 + if len(segmentSet) == 0 {
55 + return removedTotal
56 + }
57 +
58 + neighborSet := make(map[string]map[string]struct{}, len(segmentSet))
59 + for segmentID := range segmentSet {
60 + neighborSet[segmentID] = make(map[string]struct{})
61 + }
62 + for _, link := range data.Links {
63 + if _, ok := segmentSet[link.SrcActorID]; ok {
64 + neighborSet[link.SrcActorID][link.DstActorID] = struct{}{}
65 + }
66 + if _, ok := segmentSet[link.DstActorID]; ok {
67 + neighborSet[link.DstActorID][link.SrcActorID] = struct{}{}
68 + }
69 + }
70 +
71 + removeSegments := make(map[string]struct{})
72 + for segmentID, neighbors := range neighborSet {
73 + if len(neighbors) <= threshold {
74 + removeSegments[segmentID] = struct{}{}
75 + }
76 + }
77 + if len(removeSegments) == 0 {
78 + return removedTotal
79 + }
80 + removedTotal += len(removeSegments)
81 +
82 + filteredActors := make([]topologyActor, 0, len(data.Actors)-len(removeSegments))
83 + for _, actor := range data.Actors {
84 + if _, drop := removeSegments[actor.ActorID]; drop {
85 + continue
86 + }
87 + filteredActors = append(filteredActors, actor)
88 + }
89 + data.Actors = filteredActors
90 +
91 + filteredLinks := make([]topologyLink, 0, len(data.Links))
92 + for _, link := range data.Links {
93 + if _, drop := removeSegments[link.SrcActorID]; drop {
94 + continue
95 + }
96 + if _, drop := removeSegments[link.DstActorID]; drop {
97 + continue
98 + }
99 + filteredLinks = append(filteredLinks, link)
100 + }
101 + data.Links = filteredLinks
102 + }
103 +}
104 +
105 +func filterDanglingLinks(data *topologyData) {
106 + if data == nil || len(data.Links) == 0 {
107 + return
108 + }
109 + actorSet := make(map[string]struct{}, len(data.Actors))
110 + for _, actor := range data.Actors {
111 + if id := strings.TrimSpace(actor.ActorID); id != "" {
112 + actorSet[id] = struct{}{}
113 + }
114 + }
115 + if len(actorSet) == 0 {
116 + data.Links = nil
117 + return
118 + }
119 + filtered := make([]topologyLink, 0, len(data.Links))
120 + for _, link := range data.Links {
121 + if _, ok := actorSet[strings.TrimSpace(link.SrcActorID)]; !ok {
122 + continue
123 + }
124 + if _, ok := actorSet[strings.TrimSpace(link.DstActorID)]; !ok {
125 + continue
126 + }
127 + filtered = append(filtered, link)
128 + }
129 + data.Links = filtered
130 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_cleanup_test.go new
+28
@@ -0,0 +1,28 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestFilterDanglingLinks_TrimActorIDsBeforeLookup(t *testing.T) {
12 + data := &topologyData{
13 + Actors: []topologyActor{
14 + {ActorID: "device-a"},
15 + {ActorID: "device-b"},
16 + },
17 + Links: []topologyLink{
18 + {SrcActorID: " device-a ", DstActorID: "\tdevice-b\n"},
19 + {SrcActorID: "device-a", DstActorID: "missing"},
20 + },
21 + }
22 +
23 + filterDanglingLinks(data)
24 +
25 + require.Len(t, data.Links, 1)
26 + require.Equal(t, " device-a ", data.Links[0].SrcActorID)
27 + require.Equal(t, "\tdevice-b\n", data.Links[0].DstActorID)
28 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_collapse.go new
+145
@@ -0,0 +1,145 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strings"
7 +)
8 +
9 +func collapseActorsByIP(data *topologyData) int {
10 + if data == nil || len(data.Actors) <= 1 {
11 + return 0
12 + }
13 +
14 + type dsu struct {
15 + parent []int
16 + }
17 + find := func(d *dsu, x int) int {
18 + for d.parent[x] != x {
19 + d.parent[x] = d.parent[d.parent[x]]
20 + x = d.parent[x]
21 + }
22 + return x
23 + }
24 + union := func(d *dsu, a, b int) {
25 + ra := find(d, a)
26 + rb := find(d, b)
27 + if ra == rb {
28 + return
29 + }
30 + if ra < rb {
31 + d.parent[rb] = ra
32 + return
33 + }
34 + d.parent[ra] = rb
35 + }
36 +
37 + d := &dsu{parent: make([]int, len(data.Actors))}
38 + for i := range d.parent {
39 + d.parent[i] = i
40 + }
41 +
42 + ipOwner := make(map[string]int)
43 + for idx, actor := range data.Actors {
44 + if strings.EqualFold(strings.TrimSpace(actor.ActorType), "segment") {
45 + continue
46 + }
47 + ips := normalizedMatchIPs(actor.Match)
48 + if len(ips) == 0 {
49 + continue
50 + }
51 + for _, ip := range ips {
52 + if owner, ok := ipOwner[ip]; ok {
53 + union(d, idx, owner)
54 + continue
55 + }
56 + ipOwner[ip] = idx
57 + }
58 + }
59 +
60 + groupMembers := make(map[int][]int)
61 + for idx := range data.Actors {
62 + root := find(d, idx)
63 + groupMembers[root] = append(groupMembers[root], idx)
64 + }
65 +
66 + replaceActorID := make(map[string]string)
67 + keep := make([]bool, len(data.Actors))
68 + for i := range keep {
69 + keep[i] = true
70 + }
71 +
72 + collapsed := 0
73 + for _, members := range groupMembers {
74 + if len(members) <= 1 {
75 + continue
76 + }
77 + rep := members[0]
78 + for _, idx := range members[1:] {
79 + if compareCollapseActorPriority(data.Actors[idx], data.Actors[rep]) < 0 {
80 + rep = idx
81 + }
82 + }
83 +
84 + repActor := data.Actors[rep]
85 + collapsedCount := 1
86 + for _, idx := range members {
87 + if idx == rep {
88 + continue
89 + }
90 + collapsedCount++
91 + collapsed++
92 + replaceActorID[data.Actors[idx].ActorID] = repActor.ActorID
93 + repActor.Match = mergeTopologyMatch(repActor.Match, data.Actors[idx].Match)
94 + repActor.Labels = mergeTopologyStringMap(repActor.Labels, data.Actors[idx].Labels)
95 + repActor.Attributes = mergeTopologyAnyMap(repActor.Attributes, data.Actors[idx].Attributes)
96 + keep[idx] = false
97 + }
98 + if repActor.Attributes == nil {
99 + repActor.Attributes = make(map[string]any)
100 + }
101 + if collapsedCount > 1 {
102 + repActor.Attributes["collapsed_by_ip"] = true
103 + repActor.Attributes["collapsed_count"] = collapsedCount
104 + }
105 + data.Actors[rep] = repActor
106 + }
107 +
108 + if collapsed == 0 {
109 + return 0
110 + }
111 +
112 + actors := make([]topologyActor, 0, len(data.Actors)-collapsed)
113 + for idx, actor := range data.Actors {
114 + if !keep[idx] {
115 + continue
116 + }
117 + actors = append(actors, actor)
118 + }
119 + data.Actors = actors
120 +
121 + links := make([]topologyLink, 0, len(data.Links))
122 + seen := make(map[string]struct{}, len(data.Links))
123 + for _, link := range data.Links {
124 + if replacement, ok := replaceActorID[link.SrcActorID]; ok && replacement != "" {
125 + link.SrcActorID = replacement
126 + }
127 + if replacement, ok := replaceActorID[link.DstActorID]; ok && replacement != "" {
128 + link.DstActorID = replacement
129 + }
130 + if strings.TrimSpace(link.SrcActorID) == "" || strings.TrimSpace(link.DstActorID) == "" {
131 + continue
132 + }
133 + if link.SrcActorID == link.DstActorID {
134 + continue
135 + }
136 + key := topologyLinkActorKey(link)
137 + if _, exists := seen[key]; exists {
138 + continue
139 + }
140 + seen[key] = struct{}{}
141 + links = append(links, link)
142 + }
143 + data.Links = links
144 + return collapsed
145 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_collapse_priority.go new
+33
@@ -0,0 +1,33 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strings"
7 +
8 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
9 +)
10 +
11 +func compareCollapseActorPriority(left, right topologyActor) int {
12 + if leftDevice, rightDevice := topologyengine.IsDeviceActorType(left.ActorType), topologyengine.IsDeviceActorType(right.ActorType); leftDevice != rightDevice {
13 + if leftDevice {
14 + return -1
15 + }
16 + return 1
17 + }
18 + if leftInferred, rightInferred := topologyActorIsInferred(left), topologyActorIsInferred(right); leftInferred != rightInferred {
19 + if !leftInferred {
20 + return -1
21 + }
22 + return 1
23 + }
24 + leftID := strings.ToLower(strings.TrimSpace(left.ActorID))
25 + rightID := strings.ToLower(strings.TrimSpace(right.ActorID))
26 + if (leftID == "") != (rightID == "") {
27 + if leftID != "" {
28 + return -1
29 + }
30 + return 1
31 + }
32 + return strings.Compare(leftID, rightID)
33 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_delta.go new
+78
@@ -0,0 +1,78 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "fmt"
7 + "strings"
8 +)
9 +
10 +func topologyLinkDeltaKey(link topologyLink) string {
11 + return strings.Join([]string{
12 + strings.ToLower(strings.TrimSpace(link.Protocol)),
13 + strings.ToLower(strings.TrimSpace(link.Direction)),
14 + strings.TrimSpace(link.SrcActorID),
15 + strings.TrimSpace(link.DstActorID),
16 + attrKey(link.Src.Attributes, "if_index"),
17 + attrKey(link.Src.Attributes, "if_name"),
18 + attrKey(link.Src.Attributes, "port_id"),
19 + attrKey(link.Dst.Attributes, "if_index"),
20 + attrKey(link.Dst.Attributes, "if_name"),
21 + attrKey(link.Dst.Attributes, "port_id"),
22 + fmt.Sprint(link.Metrics["bridge_domain"]),
23 + }, "|")
24 +}
25 +
26 +func markProbableDeltaLinks(strictData, probableData *topologyData) {
27 + if strictData == nil || probableData == nil {
28 + return
29 + }
30 +
31 + strictKeys := make(map[string]struct{}, len(strictData.Links))
32 + for _, link := range strictData.Links {
33 + strictKeys[topologyLinkDeltaKey(link)] = struct{}{}
34 + }
35 +
36 + for idx, link := range probableData.Links {
37 + key := topologyLinkDeltaKey(link)
38 + if _, exists := strictKeys[key]; exists {
39 + continue
40 + }
41 + link.State = "probable"
42 + if link.Metrics == nil {
43 + link.Metrics = make(map[string]any)
44 + }
45 + link.Metrics["inference"] = "probable"
46 + if topologyMetricValueString(link.Metrics, "confidence") == "" {
47 + link.Metrics["confidence"] = "low"
48 + }
49 + if topologyMetricValueString(link.Metrics, "attachment_mode") == "" {
50 + if strings.EqualFold(strings.TrimSpace(link.Protocol), "bridge") {
51 + link.Metrics["attachment_mode"] = "probable_bridge_anchor"
52 + } else {
53 + link.Metrics["attachment_mode"] = "probable_added"
54 + }
55 + }
56 + probableData.Links[idx] = link
57 + }
58 + recomputeTopologyLinkStats(probableData)
59 +}
60 +
61 +func topologyLinkActorKey(link topologyLink) string {
62 + return strings.Join([]string{
63 + link.Protocol,
64 + link.Direction,
65 + link.SrcActorID,
66 + link.DstActorID,
67 + attrKey(link.Src.Attributes, "if_index"),
68 + attrKey(link.Src.Attributes, "if_name"),
69 + attrKey(link.Src.Attributes, "port_id"),
70 + attrKey(link.Dst.Attributes, "if_index"),
71 + attrKey(link.Dst.Attributes, "if_name"),
72 + attrKey(link.Dst.Attributes, "port_id"),
73 + link.State,
74 + fmt.Sprint(link.Metrics["bridge_domain"]),
75 + fmt.Sprint(link.Metrics["attachment_mode"]),
76 + fmt.Sprint(link.Metrics["inference"]),
77 + }, "|")
78 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_focus.go new
+58
@@ -0,0 +1,58 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "sort"
6 +
7 +func applyTopologyDepthFocusFilter(data *topologyData, options topologyQueryOptions) {
8 + if data == nil || len(data.Actors) == 0 {
9 + return
10 + }
11 + options = normalizeTopologyQueryOptions(options)
12 + focusIPs := topologyManagedFocusSelectedIPs(options.ManagedDeviceFocus)
13 +
14 + beforeActors := len(data.Actors)
15 + beforeLinks := len(data.Links)
16 +
17 + if isTopologyManagedFocusAllDevices(options.ManagedDeviceFocus) {
18 + recordTopologyFocusAllDevicesStats(data, options)
19 + return
20 + }
21 +
22 + graph := buildTopologyFocusGraph(data)
23 + if len(graph.nonSegmentSet) == 0 || len(focusIPs) == 0 {
24 + recomputeTopologyLinkStats(data)
25 + return
26 + }
27 +
28 + roots := collectTopologyFocusRoots(graph, focusIPs)
29 + if len(roots) == 0 {
30 + recordTopologyFocusStats(data, options, beforeActors, beforeLinks)
31 + return
32 + }
33 +
34 + distance := traverseTopologyFocusDepth(graph, roots, options.Depth)
35 + includedNonSegment, includedActorsByDepth := collectTopologyFocusDepthSets(graph, distance, options.Depth)
36 + if len(includedNonSegment) == 0 {
37 + recomputeTopologyLinkStats(data)
38 + return
39 + }
40 +
41 + shortestPathActors, shortestPathPairs := topologyShortestPathUnion(data, roots)
42 + filterTopologyDataByFocus(data, includedActorsByDepth, shortestPathActors, shortestPathPairs)
43 +
44 + filterDanglingLinks(data)
45 + if options.EliminateNonIPInferred {
46 + pruneSparseSegments(data, 1)
47 + filterDanglingLinks(data)
48 + }
49 +
50 + sort.Slice(data.Actors, func(i, j int) bool {
51 + return canonicalMatchKey(data.Actors[i].Match) < canonicalMatchKey(data.Actors[j].Match)
52 + })
53 + sort.Slice(data.Links, func(i, j int) bool {
54 + return topologyLinkSortKey(data.Links[i]) < topologyLinkSortKey(data.Links[j])
55 + })
56 +
57 + recordTopologyFocusStats(data, options, beforeActors, beforeLinks)
58 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_focus_filter.go new
+54
@@ -0,0 +1,54 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +func filterTopologyDataByFocus(
8 + data *topologyData,
9 + includedActorsByDepth map[string]struct{},
10 + shortestPathActors map[string]struct{},
11 + shortestPathPairs map[string]struct{},
12 +) {
13 + includedActors := make(map[string]struct{}, len(includedActorsByDepth)+len(shortestPathActors))
14 + for actorID := range includedActorsByDepth {
15 + includedActors[actorID] = struct{}{}
16 + }
17 + for actorID := range shortestPathActors {
18 + includedActors[actorID] = struct{}{}
19 + }
20 +
21 + filteredLinks := make([]topologyLink, 0, len(data.Links))
22 + linkActors := make(map[string]struct{})
23 + for _, link := range data.Links {
24 + srcActorID := strings.TrimSpace(link.SrcActorID)
25 + dstActorID := strings.TrimSpace(link.DstActorID)
26 + if srcActorID == "" || dstActorID == "" {
27 + continue
28 + }
29 +
30 + _, srcInDepth := includedActorsByDepth[srcActorID]
31 + _, dstInDepth := includedActorsByDepth[dstActorID]
32 + _, inShortestPath := shortestPathPairs[topologyActorPairKey(srcActorID, dstActorID)]
33 + if !(srcInDepth && dstInDepth) && !inShortestPath {
34 + continue
35 + }
36 +
37 + filteredLinks = append(filteredLinks, link)
38 + linkActors[srcActorID] = struct{}{}
39 + linkActors[dstActorID] = struct{}{}
40 + }
41 + data.Links = filteredLinks
42 +
43 + for actorID := range linkActors {
44 + includedActors[actorID] = struct{}{}
45 + }
46 +
47 + filteredActors := make([]topologyActor, 0, len(data.Actors))
48 + for _, actor := range data.Actors {
49 + if _, ok := includedActors[actor.ActorID]; ok {
50 + filteredActors = append(filteredActors, actor)
51 + }
52 + }
53 + data.Actors = filteredActors
54 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_focus_graph.go new
+144
@@ -0,0 +1,144 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "strings"
6 +
7 +type topologyFocusGraph struct {
8 + actorByID map[string]topologyActor
9 + segmentSet map[string]struct{}
10 + nonSegmentSet map[string]struct{}
11 + nonSegmentAdj map[string]map[string]struct{}
12 + nodeSegments map[string]map[string]struct{}
13 + segmentNeighbors map[string]map[string]struct{}
14 +}
15 +
16 +func buildTopologyFocusGraph(data *topologyData) topologyFocusGraph {
17 + graph := topologyFocusGraph{
18 + actorByID: make(map[string]topologyActor, len(data.Actors)),
19 + segmentSet: make(map[string]struct{}),
20 + nonSegmentSet: make(map[string]struct{}),
21 + nonSegmentAdj: make(map[string]map[string]struct{}),
22 + nodeSegments: make(map[string]map[string]struct{}),
23 + segmentNeighbors: make(map[string]map[string]struct{}),
24 + }
25 +
26 + for _, actor := range data.Actors {
27 + id := strings.TrimSpace(actor.ActorID)
28 + if id == "" {
29 + continue
30 + }
31 + graph.actorByID[id] = actor
32 + if strings.EqualFold(strings.TrimSpace(actor.ActorType), "segment") {
33 + graph.segmentSet[id] = struct{}{}
34 + } else {
35 + graph.nonSegmentSet[id] = struct{}{}
36 + }
37 + }
38 +
39 + for actorID := range graph.nonSegmentSet {
40 + graph.nonSegmentAdj[actorID] = make(map[string]struct{})
41 + graph.nodeSegments[actorID] = make(map[string]struct{})
42 + }
43 + for segmentID := range graph.segmentSet {
44 + graph.segmentNeighbors[segmentID] = make(map[string]struct{})
45 + }
46 +
47 + for _, link := range data.Links {
48 + src := strings.TrimSpace(link.SrcActorID)
49 + dst := strings.TrimSpace(link.DstActorID)
50 + if src == "" || dst == "" || src == dst {
51 + continue
52 + }
53 + _, srcSegment := graph.segmentSet[src]
54 + _, dstSegment := graph.segmentSet[dst]
55 + _, srcNonSegment := graph.nonSegmentSet[src]
56 + _, dstNonSegment := graph.nonSegmentSet[dst]
57 +
58 + switch {
59 + case srcNonSegment && dstNonSegment:
60 + graph.nonSegmentAdj[src][dst] = struct{}{}
61 + graph.nonSegmentAdj[dst][src] = struct{}{}
62 + case srcSegment && dstNonSegment:
63 + graph.segmentNeighbors[src][dst] = struct{}{}
64 + graph.nodeSegments[dst][src] = struct{}{}
65 + case dstSegment && srcNonSegment:
66 + graph.segmentNeighbors[dst][src] = struct{}{}
67 + graph.nodeSegments[src][dst] = struct{}{}
68 + }
69 + }
70 +
71 + return graph
72 +}
73 +
74 +func traverseTopologyFocusDepth(graph topologyFocusGraph, roots map[string]struct{}, depth int) map[string]int {
75 + distance := make(map[string]int, len(graph.nonSegmentSet))
76 + queue := make([]string, 0, len(roots))
77 + for root := range roots {
78 + distance[root] = 0
79 + queue = append(queue, root)
80 + }
81 + segmentExpandedDepth := make(map[string]int)
82 +
83 + for head := 0; head < len(queue); head++ {
84 + current := queue[head]
85 + currentDepth := distance[current]
86 + if depth != topologyDepthAllInternal && currentDepth >= depth {
87 + continue
88 + }
89 +
90 + for neighbor := range graph.nonSegmentAdj[current] {
91 + if _, seen := distance[neighbor]; seen {
92 + continue
93 + }
94 + distance[neighbor] = currentDepth + 1
95 + queue = append(queue, neighbor)
96 + }
97 +
98 + for segmentID := range graph.nodeSegments[current] {
99 + if expandedAt, ok := segmentExpandedDepth[segmentID]; ok && expandedAt <= currentDepth {
100 + continue
101 + }
102 + segmentExpandedDepth[segmentID] = currentDepth
103 + for neighbor := range graph.segmentNeighbors[segmentID] {
104 + if _, seen := distance[neighbor]; seen {
105 + continue
106 + }
107 + distance[neighbor] = currentDepth + 1
108 + queue = append(queue, neighbor)
109 + }
110 + }
111 + }
112 +
113 + return distance
114 +}
115 +
116 +func collectTopologyFocusDepthSets(
117 + graph topologyFocusGraph,
118 + distance map[string]int,
119 + depth int,
120 +) (map[string]struct{}, map[string]struct{}) {
121 + includedNonSegment := make(map[string]struct{}, len(distance))
122 + for actorID, currentDepth := range distance {
123 + if depth == topologyDepthAllInternal || currentDepth <= depth {
124 + includedNonSegment[actorID] = struct{}{}
125 + }
126 + }
127 +
128 + includedActorsByDepth := make(map[string]struct{}, len(includedNonSegment)+len(graph.segmentSet))
129 + for actorID := range includedNonSegment {
130 + includedActorsByDepth[actorID] = struct{}{}
131 + }
132 + if depth == topologyDepthAllInternal || depth > 0 {
133 + for segmentID, neighbors := range graph.segmentNeighbors {
134 + for actorID := range neighbors {
135 + if _, ok := includedNonSegment[actorID]; ok {
136 + includedActorsByDepth[segmentID] = struct{}{}
137 + break
138 + }
139 + }
140 + }
141 + }
142 +
143 + return includedNonSegment, includedActorsByDepth
144 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_focus_graph_test.go new
+188
@@ -0,0 +1,188 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 + "time"
8 +
9 + "github.com/stretchr/testify/require"
10 +)
11 +
12 +func TestTopologyFocusGraphBuildAndDepthTraversal(t *testing.T) {
13 + now := time.Now().UTC()
14 + data := &topologyData{
15 + SchemaVersion: topologySchemaVersion,
16 + CollectedAt: now,
17 + Actors: []topologyActor{
18 + {
19 + ActorID: "device-a",
20 + ActorType: "device",
21 + Layer: "2",
22 + Source: "snmp",
23 + Match: topologyMatch{
24 + IPAddresses: []string{"10.0.0.1"},
25 + },
26 + },
27 + {
28 + ActorID: "device-b",
29 + ActorType: "device",
30 + Layer: "2",
31 + Source: "snmp",
32 + Match: topologyMatch{
33 + IPAddresses: []string{"10.0.0.2"},
34 + },
35 + },
36 + {
37 + ActorID: "segment-1",
38 + ActorType: "segment",
39 + Layer: "2",
40 + Source: "snmp",
41 + },
42 + {
43 + ActorID: "device-c",
44 + ActorType: "device",
45 + Layer: "2",
46 + Source: "snmp",
47 + Match: topologyMatch{
48 + IPAddresses: []string{"10.0.0.3"},
49 + },
50 + },
51 + },
52 + Links: []topologyLink{
53 + {
54 + Layer: "2",
55 + Protocol: "lldp",
56 + LinkType: "device",
57 + SrcActorID: "device-a",
58 + DstActorID: "device-b",
59 + },
60 + {
61 + Layer: "2",
62 + Protocol: "fdb",
63 + LinkType: "segment",
64 + SrcActorID: "device-b",
65 + DstActorID: "segment-1",
66 + },
67 + {
68 + Layer: "2",
69 + Protocol: "fdb",
70 + LinkType: "segment",
71 + SrcActorID: "segment-1",
72 + DstActorID: "device-c",
73 + },
74 + },
75 + }
76 +
77 + graph := buildTopologyFocusGraph(data)
78 + require.Contains(t, graph.nonSegmentSet, "device-a")
79 + require.Contains(t, graph.nonSegmentSet, "device-b")
80 + require.Contains(t, graph.nonSegmentSet, "device-c")
81 + require.Contains(t, graph.segmentSet, "segment-1")
82 + require.Contains(t, graph.nonSegmentAdj["device-a"], "device-b")
83 + require.Contains(t, graph.nonSegmentAdj["device-b"], "device-a")
84 + require.Contains(t, graph.nodeSegments["device-b"], "segment-1")
85 + require.Contains(t, graph.segmentNeighbors["segment-1"], "device-b")
86 + require.Contains(t, graph.segmentNeighbors["segment-1"], "device-c")
87 +
88 + roots := map[string]struct{}{"device-a": {}}
89 +
90 + distanceDepth1 := traverseTopologyFocusDepth(graph, roots, 1)
91 + require.Equal(t, map[string]int{
92 + "device-a": 0,
93 + "device-b": 1,
94 + }, distanceDepth1)
95 +
96 + includedNonSegmentDepth1, includedActorsDepth1 := collectTopologyFocusDepthSets(graph, distanceDepth1, 1)
97 + require.Equal(t, map[string]struct{}{
98 + "device-a": {},
99 + "device-b": {},
100 + }, includedNonSegmentDepth1)
101 + require.Equal(t, map[string]struct{}{
102 + "device-a": {},
103 + "device-b": {},
104 + "segment-1": {},
105 + }, includedActorsDepth1)
106 +
107 + distanceDepth2 := traverseTopologyFocusDepth(graph, roots, 2)
108 + require.Equal(t, map[string]int{
109 + "device-a": 0,
110 + "device-b": 1,
111 + "device-c": 2,
112 + }, distanceDepth2)
113 +
114 + includedNonSegmentDepth2, includedActorsDepth2 := collectTopologyFocusDepthSets(graph, distanceDepth2, 2)
115 + require.Equal(t, map[string]struct{}{
116 + "device-a": {},
117 + "device-b": {},
118 + "device-c": {},
119 + }, includedNonSegmentDepth2)
120 + require.Equal(t, map[string]struct{}{
121 + "device-a": {},
122 + "device-b": {},
123 + "device-c": {},
124 + "segment-1": {},
125 + }, includedActorsDepth2)
126 +}
127 +
128 +func TestTopologyActorHasIPMatchesMatchAndManagementAddresses(t *testing.T) {
129 + actor := topologyActor{
130 + Match: topologyMatch{
131 + IPAddresses: []string{"10.0.0.1"},
132 + },
133 + Attributes: map[string]any{
134 + "management_ip": "10.0.0.2",
135 + "management_addresses": []any{
136 + "10.0.0.3",
137 + "not-an-ip",
138 + },
139 + },
140 + }
141 +
142 + require.True(t, topologyActorHasIP(actor, "10.0.0.1"))
143 + require.True(t, topologyActorHasIP(actor, "10.0.0.2"))
144 + require.True(t, topologyActorHasIP(actor, "10.0.0.3"))
145 + require.False(t, topologyActorHasIP(actor, "10.0.0.9"))
146 + require.False(t, topologyActorHasIP(actor, "not-an-ip"))
147 +}
148 +
149 +func TestRecordTopologyFocusStatsNormalizesDepthAndFilteredCounts(t *testing.T) {
150 + data := &topologyData{
151 + Actors: []topologyActor{
152 + {ActorID: "device-a", ActorType: "device"},
153 + {ActorID: "device-b", ActorType: "device"},
154 + },
155 + Links: []topologyLink{
156 + {Protocol: "lldp", Direction: "bidirectional", SrcActorID: "device-a", DstActorID: "device-b"},
157 + },
158 + }
159 +
160 + recordTopologyFocusStats(data, topologyQueryOptions{
161 + ManagedDeviceFocus: "ip:10.0.0.1",
162 + Depth: topologyDepthAllInternal,
163 + }, 5, 4)
164 +
165 + require.Equal(t, "ip:10.0.0.1", data.Stats["managed_snmp_device_focus"])
166 + require.Equal(t, topologyDepthAll, data.Stats["depth"])
167 + require.Equal(t, 3, data.Stats["actors_focus_depth_filtered"])
168 + require.Equal(t, 3, data.Stats["links_focus_depth_filtered"])
169 + require.Equal(t, len(data.Links), intStatValue(data.Stats["links_total"]))
170 +}
171 +
172 +func TestRecordTopologyFocusAllDevicesStatsKeepsAllDepth(t *testing.T) {
173 + data := &topologyData{
174 + Links: []topologyLink{
175 + {Protocol: "lldp", Direction: "bidirectional", SrcActorID: "device-a", DstActorID: "device-b"},
176 + },
177 + }
178 +
179 + recordTopologyFocusAllDevicesStats(data, topologyQueryOptions{
180 + ManagedDeviceFocus: topologyManagedFocusAllDevices,
181 + })
182 +
183 + require.Equal(t, topologyManagedFocusAllDevices, data.Stats["managed_snmp_device_focus"])
184 + require.Equal(t, topologyDepthAll, data.Stats["depth"])
185 + require.Equal(t, 0, data.Stats["actors_focus_depth_filtered"])
186 + require.Equal(t, 0, data.Stats["links_focus_depth_filtered"])
187 + require.Equal(t, len(data.Links), intStatValue(data.Stats["links_total"]))
188 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_focus_paths.go new
+129
@@ -0,0 +1,129 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func topologyShortestPathUnion(
11 + data *topologyData,
12 + roots map[string]struct{},
13 +) (map[string]struct{}, map[string]struct{}) {
14 + includedActors := make(map[string]struct{})
15 + includedPairs := make(map[string]struct{})
16 + if data == nil || len(roots) < 2 {
17 + return includedActors, includedPairs
18 + }
19 +
20 + adjacency := make(map[string]map[string]struct{})
21 + for _, link := range data.Links {
22 + src := strings.TrimSpace(link.SrcActorID)
23 + dst := strings.TrimSpace(link.DstActorID)
24 + if src == "" || dst == "" || src == dst {
25 + continue
26 + }
27 + if _, ok := adjacency[src]; !ok {
28 + adjacency[src] = make(map[string]struct{})
29 + }
30 + if _, ok := adjacency[dst]; !ok {
31 + adjacency[dst] = make(map[string]struct{})
32 + }
33 + adjacency[src][dst] = struct{}{}
34 + adjacency[dst][src] = struct{}{}
35 + }
36 +
37 + rootIDs := make([]string, 0, len(roots))
38 + for actorID := range roots {
39 + rootIDs = append(rootIDs, actorID)
40 + }
41 + sort.Strings(rootIDs)
42 +
43 + for i := 0; i < len(rootIDs); i++ {
44 + source := rootIDs[i]
45 + if _, ok := adjacency[source]; !ok {
46 + continue
47 + }
48 +
49 + parents, distance := topologyShortestParents(adjacency, source)
50 + for j := i + 1; j < len(rootIDs); j++ {
51 + target := rootIDs[j]
52 + if _, ok := distance[target]; !ok {
53 + continue
54 + }
55 +
56 + visited := make(map[string]struct{})
57 + stack := []string{target}
58 + for len(stack) > 0 {
59 + node := stack[len(stack)-1]
60 + stack = stack[:len(stack)-1]
61 + if _, seen := visited[node]; seen {
62 + continue
63 + }
64 + visited[node] = struct{}{}
65 + includedActors[node] = struct{}{}
66 + if node == source {
67 + continue
68 + }
69 +
70 + for _, parent := range parents[node] {
71 + includedActors[parent] = struct{}{}
72 + includedPairs[topologyActorPairKey(node, parent)] = struct{}{}
73 + stack = append(stack, parent)
74 + }
75 + }
76 + }
77 + }
78 +
79 + return includedActors, includedPairs
80 +}
81 +
82 +func topologyShortestParents(
83 + adjacency map[string]map[string]struct{},
84 + source string,
85 +) (map[string][]string, map[string]int) {
86 + parents := make(map[string][]string)
87 + distance := map[string]int{source: 0}
88 + queue := []string{source}
89 +
90 + for head := 0; head < len(queue); head++ {
91 + current := queue[head]
92 + neighbors := make([]string, 0, len(adjacency[current]))
93 + for neighbor := range adjacency[current] {
94 + neighbors = append(neighbors, neighbor)
95 + }
96 + sort.Strings(neighbors)
97 + for _, neighbor := range neighbors {
98 + nextDepth := distance[current] + 1
99 + currentDepth, seen := distance[neighbor]
100 + if !seen {
101 + distance[neighbor] = nextDepth
102 + parents[neighbor] = []string{current}
103 + queue = append(queue, neighbor)
104 + continue
105 + }
106 + if nextDepth == currentDepth {
107 + parents[neighbor] = append(parents[neighbor], current)
108 + }
109 + }
110 + }
111 +
112 + for node := range parents {
113 + sort.Strings(parents[node])
114 + }
115 +
116 + return parents, distance
117 +}
118 +
119 +func topologyActorPairKey(left, right string) string {
120 + left = strings.TrimSpace(left)
121 + right = strings.TrimSpace(right)
122 + if left == "" || right == "" {
123 + return ""
124 + }
125 + if left > right {
126 + left, right = right, left
127 + }
128 + return left + "|" + right
129 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_focus_selection.go new
+122
@@ -0,0 +1,122 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "fmt"
7 + "slices"
8 + "strings"
9 +)
10 +
11 +func recordTopologyFocusAllDevicesStats(data *topologyData, options topologyQueryOptions) {
12 + if data == nil {
13 + return
14 + }
15 + if data.Stats == nil {
16 + data.Stats = make(map[string]any)
17 + }
18 + data.Stats["managed_snmp_device_focus"] = options.ManagedDeviceFocus
19 + data.Stats["depth"] = topologyDepthAll
20 + data.Stats["actors_focus_depth_filtered"] = 0
21 + data.Stats["links_focus_depth_filtered"] = 0
22 + recomputeTopologyLinkStats(data)
23 +}
24 +
25 +func recordTopologyFocusStats(data *topologyData, options topologyQueryOptions, beforeActors, beforeLinks int) {
26 + if data == nil {
27 + return
28 + }
29 + if data.Stats == nil {
30 + data.Stats = make(map[string]any)
31 + }
32 + data.Stats["managed_snmp_device_focus"] = options.ManagedDeviceFocus
33 + if options.Depth == topologyDepthAllInternal {
34 + data.Stats["depth"] = topologyDepthAll
35 + } else {
36 + data.Stats["depth"] = options.Depth
37 + }
38 + data.Stats["actors_focus_depth_filtered"] = beforeActors - len(data.Actors)
39 + data.Stats["links_focus_depth_filtered"] = beforeLinks - len(data.Links)
40 + recomputeTopologyLinkStats(data)
41 +}
42 +
43 +func topologyManagedFocusSelectedIP(value string) string {
44 + ips := topologyManagedFocusSelectedIPs(value)
45 + if len(ips) == 0 {
46 + return ""
47 + }
48 + return ips[0]
49 +}
50 +
51 +func topologyManagedFocusSelectedIPs(value string) []string {
52 + normalized := parseTopologyManagedFocuses(value)
53 + if len(normalized) == 1 && normalized[0] == topologyManagedFocusAllDevices {
54 + return nil
55 + }
56 +
57 + out := make([]string, 0, len(normalized))
58 + for _, focus := range normalized {
59 + if len(focus) <= len(topologyManagedFocusIPPrefix) {
60 + continue
61 + }
62 + if !strings.EqualFold(focus[:len(topologyManagedFocusIPPrefix)], topologyManagedFocusIPPrefix) {
63 + continue
64 + }
65 + ip := normalizeIPAddress(strings.TrimSpace(focus[len(topologyManagedFocusIPPrefix):]))
66 + if ip == "" {
67 + continue
68 + }
69 + out = append(out, ip)
70 + }
71 + return out
72 +}
73 +
74 +func collectTopologyFocusRoots(graph topologyFocusGraph, focusIPs []string) map[string]struct{} {
75 + roots := make(map[string]struct{})
76 + for actorID, actor := range graph.actorByID {
77 + if _, ok := graph.nonSegmentSet[actorID]; !ok {
78 + continue
79 + }
80 + if !isManagedSNMPDeviceActor(actor) {
81 + continue
82 + }
83 + for _, focusIP := range focusIPs {
84 + if !topologyActorHasIP(actor, focusIP) {
85 + continue
86 + }
87 + roots[actorID] = struct{}{}
88 + break
89 + }
90 + }
91 + return roots
92 +}
93 +
94 +func topologyActorHasIP(actor topologyActor, ip string) bool {
95 + ip = normalizeIPAddress(ip)
96 + if ip == "" {
97 + return false
98 + }
99 + if slices.Contains(normalizedMatchIPs(actor.Match), ip) {
100 + return true
101 + }
102 + if ip == normalizeIPAddress(topologyMetricValueString(actor.Attributes, "management_ip")) {
103 + return true
104 + }
105 + if raw, ok := actor.Attributes["management_addresses"]; ok {
106 + switch values := raw.(type) {
107 + case []string:
108 + for _, value := range values {
109 + if ip == normalizeIPAddress(value) {
110 + return true
111 + }
112 + }
113 + case []any:
114 + for _, value := range values {
115 + if ip == normalizeIPAddress(fmt.Sprint(value)) {
116 + return true
117 + }
118 + }
119 + }
120 + }
121 + return false
122 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_map_type.go new
+131
@@ -0,0 +1,131 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strings"
7 +
8 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
9 +)
10 +
11 +func applyMapTypePolicy(data *topologyData, mapType string) int {
12 + switch normalizeTopologyMapType(mapType) {
13 + case topologyMapTypeLLDPCDPManaged:
14 + return applyLLDPCDPManagedMapPolicy(data)
15 + case topologyMapTypeHighConfidenceInferred:
16 + return suppressUnlinkedInferredEndpoints(data)
17 + default:
18 + return 0
19 + }
20 +}
21 +
22 +func applyLLDPCDPManagedMapPolicy(data *topologyData) int {
23 + if data == nil || len(data.Actors) == 0 {
24 + return 0
25 + }
26 +
27 + managedIDs := make(map[string]struct{})
28 + for _, actor := range data.Actors {
29 + if !isManagedSNMPDeviceActor(actor) {
30 + continue
31 + }
32 + managedIDs[actor.ActorID] = struct{}{}
33 + }
34 +
35 + keepLink := func(link topologyLink) bool {
36 + protocol := strings.ToLower(strings.TrimSpace(link.Protocol))
37 + return protocol == "lldp" || protocol == "cdp"
38 + }
39 +
40 + keptLinks := make([]topologyLink, 0, len(data.Links))
41 + linkedIDs := make(map[string]struct{}, len(managedIDs))
42 + for managedID := range managedIDs {
43 + linkedIDs[managedID] = struct{}{}
44 + }
45 + for _, link := range data.Links {
46 + if !keepLink(link) {
47 + continue
48 + }
49 + keptLinks = append(keptLinks, link)
50 + if strings.TrimSpace(link.SrcActorID) != "" {
51 + linkedIDs[link.SrcActorID] = struct{}{}
52 + }
53 + if strings.TrimSpace(link.DstActorID) != "" {
54 + linkedIDs[link.DstActorID] = struct{}{}
55 + }
56 + }
57 + data.Links = keptLinks
58 +
59 + keptActors := make([]topologyActor, 0, len(data.Actors))
60 + removed := 0
61 + for _, actor := range data.Actors {
62 + if _, ok := linkedIDs[actor.ActorID]; ok {
63 + keptActors = append(keptActors, actor)
64 + continue
65 + }
66 + removed++
67 + }
68 + data.Actors = keptActors
69 + return removed
70 +}
71 +
72 +func suppressUnlinkedInferredEndpoints(data *topologyData) int {
73 + if data == nil || len(data.Actors) == 0 {
74 + return 0
75 + }
76 +
77 + linked := make(map[string]struct{}, len(data.Links)*2)
78 + for _, link := range data.Links {
79 + if strings.TrimSpace(link.SrcActorID) != "" {
80 + linked[link.SrcActorID] = struct{}{}
81 + }
82 + if strings.TrimSpace(link.DstActorID) != "" {
83 + linked[link.DstActorID] = struct{}{}
84 + }
85 + }
86 +
87 + removed := 0
88 + removedIDs := make(map[string]struct{})
89 + kept := make([]topologyActor, 0, len(data.Actors))
90 + for _, actor := range data.Actors {
91 + if !strings.EqualFold(strings.TrimSpace(actor.ActorType), "endpoint") {
92 + kept = append(kept, actor)
93 + continue
94 + }
95 + if _, ok := linked[actor.ActorID]; ok {
96 + kept = append(kept, actor)
97 + continue
98 + }
99 + removed++
100 + removedIDs[actor.ActorID] = struct{}{}
101 + }
102 + if removed == 0 {
103 + return 0
104 + }
105 + data.Actors = kept
106 + if len(data.Links) == 0 {
107 + return removed
108 + }
109 + filtered := make([]topologyLink, 0, len(data.Links))
110 + for _, link := range data.Links {
111 + if _, drop := removedIDs[link.SrcActorID]; drop {
112 + continue
113 + }
114 + if _, drop := removedIDs[link.DstActorID]; drop {
115 + continue
116 + }
117 + filtered = append(filtered, link)
118 + }
119 + data.Links = filtered
120 + return removed
121 +}
122 +
123 +func isManagedSNMPDeviceActor(actor topologyActor) bool {
124 + if !topologyengine.IsDeviceActorType(actor.ActorType) {
125 + return false
126 + }
127 + if strings.ToLower(strings.TrimSpace(actor.Source)) != "snmp" {
128 + return false
129 + }
130 + return !topologyActorIsInferred(actor)
131 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_merge.go new
+82
@@ -0,0 +1,82 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func mergeTopologyMatch(base, other topologyMatch) topologyMatch {
11 + base.ChassisIDs = appendUniqueTopologyStrings(base.ChassisIDs, other.ChassisIDs...)
12 + base.MacAddresses = appendUniqueTopologyStrings(base.MacAddresses, other.MacAddresses...)
13 + base.IPAddresses = appendUniqueTopologyStrings(base.IPAddresses, other.IPAddresses...)
14 + base.Hostnames = appendUniqueTopologyStrings(base.Hostnames, other.Hostnames...)
15 + base.DNSNames = appendUniqueTopologyStrings(base.DNSNames, other.DNSNames...)
16 + if strings.TrimSpace(base.SysName) == "" {
17 + base.SysName = strings.TrimSpace(other.SysName)
18 + }
19 + if strings.TrimSpace(base.SysObjectID) == "" {
20 + base.SysObjectID = strings.TrimSpace(other.SysObjectID)
21 + }
22 + return base
23 +}
24 +
25 +func mergeTopologyStringMap(base, other map[string]string) map[string]string {
26 + if len(other) == 0 {
27 + return base
28 + }
29 + if base == nil {
30 + base = make(map[string]string, len(other))
31 + }
32 + for key, value := range other {
33 + key = strings.TrimSpace(key)
34 + value = strings.TrimSpace(value)
35 + if key == "" || value == "" {
36 + continue
37 + }
38 + if _, exists := base[key]; exists {
39 + continue
40 + }
41 + base[key] = value
42 + }
43 + return base
44 +}
45 +
46 +func mergeTopologyAnyMap(base, other map[string]any) map[string]any {
47 + if len(other) == 0 {
48 + return base
49 + }
50 + if base == nil {
51 + base = make(map[string]any, len(other))
52 + }
53 + for key, value := range other {
54 + key = strings.TrimSpace(key)
55 + if key == "" {
56 + continue
57 + }
58 + if _, exists := base[key]; exists {
59 + continue
60 + }
61 + base[key] = value
62 + }
63 + return base
64 +}
65 +
66 +func appendUniqueTopologyStrings(base []string, values ...string) []string {
67 + seen := make(map[string]struct{}, len(base)+len(values))
68 + out := make([]string, 0, len(base)+len(values))
69 + for _, value := range append(base, values...) {
70 + value = strings.TrimSpace(value)
71 + if value == "" {
72 + continue
73 + }
74 + if _, exists := seen[value]; exists {
75 + continue
76 + }
77 + seen[value] = struct{}{}
78 + out = append(out, value)
79 + }
80 + sort.Strings(out)
81 + return out
82 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_merge_test.go new
+77
@@ -0,0 +1,77 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestAppendUniqueTopologyStringsSortsAndDeduplicates(t *testing.T) {
12 + values := appendUniqueTopologyStrings([]string{" b ", "a"}, "a", "", " c ")
13 + require.Equal(t, []string{"a", "b", "c"}, values)
14 +}
15 +
16 +func TestMergeTopologyStringMapKeepsExistingAndIgnoresBlankEntries(t *testing.T) {
17 + base := map[string]string{
18 + "existing": "keep",
19 + }
20 +
21 + merged := mergeTopologyStringMap(base, map[string]string{
22 + "existing": "replace",
23 + " new ": " value ",
24 + "blank": " ",
25 + "": "ignored",
26 + })
27 +
28 + require.Equal(t, map[string]string{
29 + "existing": "keep",
30 + "new": "value",
31 + }, merged)
32 +}
33 +
34 +func TestMergeTopologyAnyMapKeepsExistingAndAddsMissingKeys(t *testing.T) {
35 + base := map[string]any{
36 + "existing": "keep",
37 + }
38 +
39 + merged := mergeTopologyAnyMap(base, map[string]any{
40 + "existing": "replace",
41 + " new ": 42,
42 + "": "ignored",
43 + })
44 +
45 + require.Equal(t, "keep", merged["existing"])
46 + require.Equal(t, 42, merged["new"])
47 + require.NotContains(t, merged, "")
48 +}
49 +
50 +func TestTopologyLinkActorKeyIncludesStateAndAttachmentMode(t *testing.T) {
51 + base := topologyLink{
52 + Protocol: "bridge",
53 + Direction: "bidirectional",
54 + SrcActorID: "device:a",
55 + DstActorID: "endpoint:b",
56 + Src: topologyLinkEndpoint{Attributes: map[string]any{
57 + "if_name": "Gi0/1",
58 + }},
59 + Dst: topologyLinkEndpoint{Attributes: map[string]any{
60 + "if_name": "Gi0/2",
61 + }},
62 + Metrics: map[string]any{
63 + "bridge_domain": "vlan-200",
64 + "attachment_mode": "probable_bridge_anchor",
65 + "inference": "probable",
66 + },
67 + State: "probable",
68 + }
69 +
70 + strict := base
71 + strict.State = ""
72 + strict.Metrics = map[string]any{
73 + "bridge_domain": "vlan-200",
74 + }
75 +
76 + require.NotEqual(t, topologyLinkActorKey(base), topologyLinkActorKey(strict))
77 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_policies.go new
+59
@@ -0,0 +1,59 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "sort"
6 +
7 +func applySNMPTopologyOutputPolicies(data *topologyData, options topologyQueryOptions) {
8 + if data == nil {
9 + return
10 + }
11 + mapType := normalizeTopologyMapType(options.MapType)
12 + if mapType == "" {
13 + mapType = topologyMapTypeAllDevicesLowConfidence
14 + }
15 + options.MapType = mapType
16 +
17 + collapsed := 0
18 + if options.CollapseActorsByIP {
19 + collapsed = collapseActorsByIP(data)
20 + }
21 +
22 + removedNonIP := 0
23 + if options.EliminateNonIPInferred {
24 + removedNonIP = eliminateNonIPInferredActors(data)
25 + }
26 +
27 + filterDanglingLinks(data)
28 + removedByMapType := applyMapTypePolicy(data, options.MapType)
29 + filterDanglingLinks(data)
30 +
31 + removedSparseSegments := 0
32 + if options.EliminateNonIPInferred {
33 + removedSparseSegments = pruneSparseSegments(data, 1)
34 + }
35 + filterDanglingLinks(data)
36 +
37 + sort.Slice(data.Actors, func(i, j int) bool {
38 + return canonicalMatchKey(data.Actors[i].Match) < canonicalMatchKey(data.Actors[j].Match)
39 + })
40 + sort.Slice(data.Links, func(i, j int) bool {
41 + return topologyLinkSortKey(data.Links[i]) < topologyLinkSortKey(data.Links[j])
42 + })
43 +
44 + if data.Stats == nil {
45 + data.Stats = make(map[string]any)
46 + }
47 + data.Stats["actors_collapsed_by_ip"] = collapsed
48 + data.Stats["actors_non_ip_inferred_suppressed"] = removedNonIP
49 + data.Stats["actors_map_type_suppressed"] = removedByMapType
50 + data.Stats["segments_sparse_suppressed"] = removedSparseSegments
51 + data.Stats["map_type"] = options.MapType
52 + if strategy := normalizeTopologyInferenceStrategy(options.InferenceStrategy); strategy != "" {
53 + data.Stats["inference_strategy"] = strategy
54 + }
55 + if removedSparseSegments > 0 {
56 + data.Stats["segments_suppressed"] = intStatValue(data.Stats["segments_suppressed"]) + removedSparseSegments
57 + }
58 + recomputeTopologyLinkStats(data)
59 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_stats.go new
+29
@@ -0,0 +1,29 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strings"
7 +)
8 +
9 +func recomputeTopologyLinkStats(data *topologyData) {
10 + if data == nil {
11 + return
12 + }
13 + if data.Stats == nil {
14 + data.Stats = make(map[string]any)
15 + }
16 + data.Stats["actors_total"] = len(data.Actors)
17 + data.Stats["links_total"] = len(data.Links)
18 +
19 + probable := 0
20 + for _, link := range data.Links {
21 + state := strings.ToLower(strings.TrimSpace(link.State))
22 + inference := strings.ToLower(topologyMetricValueString(link.Metrics, "inference"))
23 + attachment := strings.ToLower(topologyMetricValueString(link.Metrics, "attachment_mode"))
24 + if state == "probable" || inference == "probable" || strings.HasPrefix(attachment, "probable_") {
25 + probable++
26 + }
27 + }
28 + data.Stats["links_probable"] = probable
29 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_stats_helpers.go new
+85
@@ -0,0 +1,85 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "fmt"
7 + "sort"
8 + "strconv"
9 + "strings"
10 +)
11 +
12 +func normalizedMatchIPs(match topologyMatch) []string {
13 + if len(match.IPAddresses) == 0 {
14 + return nil
15 + }
16 + out := make([]string, 0, len(match.IPAddresses))
17 + seen := make(map[string]struct{}, len(match.IPAddresses))
18 + for _, value := range match.IPAddresses {
19 + ip := normalizeIPAddress(value)
20 + if ip == "" {
21 + continue
22 + }
23 + if _, ok := seen[ip]; ok {
24 + continue
25 + }
26 + seen[ip] = struct{}{}
27 + out = append(out, ip)
28 + }
29 + sort.Strings(out)
30 + return out
31 +}
32 +
33 +func topologyActorIsInferred(actor topologyActor) bool {
34 + if strings.EqualFold(strings.TrimSpace(actor.ActorType), "endpoint") {
35 + return true
36 + }
37 + if boolStatValue(actor.Attributes["inferred"]) {
38 + return true
39 + }
40 + if boolStatValue(actor.Labels["inferred"]) {
41 + return true
42 + }
43 + return false
44 +}
45 +
46 +func boolStatValue(value any) bool {
47 + switch typed := value.(type) {
48 + case bool:
49 + return typed
50 + case string:
51 + switch strings.ToLower(strings.TrimSpace(typed)) {
52 + case "1", "true", "yes", "on":
53 + return true
54 + }
55 + }
56 + return false
57 +}
58 +
59 +func intStatValue(value any) int {
60 + switch typed := value.(type) {
61 + case int:
62 + return typed
63 + case int64:
64 + return int(typed)
65 + case float64:
66 + return int(typed)
67 + case string:
68 + n, err := strconv.Atoi(strings.TrimSpace(typed))
69 + if err == nil {
70 + return n
71 + }
72 + }
73 + return 0
74 +}
75 +
76 +func topologyMetricValueString(metrics map[string]any, key string) string {
77 + if metrics == nil {
78 + return ""
79 + }
80 + value, ok := metrics[key]
81 + if !ok || value == nil {
82 + return ""
83 + }
84 + return strings.TrimSpace(fmt.Sprint(value))
85 +}
src/go/plugin/go.d/collector/snmp_topology/topology_output_stats_test.go new
+26
@@ -0,0 +1,26 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestTopologyOutputStatHelpers_ClassifyActorsAndValues(t *testing.T) {
12 + require.True(t, topologyActorIsInferred(topologyActor{ActorType: "endpoint"}))
13 + require.True(t, topologyActorIsInferred(topologyActor{Labels: map[string]string{"inferred": "yes"}}))
14 + require.True(t, topologyActorIsInferred(topologyActor{Attributes: map[string]any{"inferred": true}}))
15 + require.False(t, topologyActorIsInferred(topologyActor{ActorType: "device"}))
16 +
17 + require.True(t, boolStatValue("true"))
18 + require.True(t, boolStatValue(" yes "))
19 + require.False(t, boolStatValue("0"))
20 +
21 + require.Equal(t, 7, intStatValue("7"))
22 + require.Equal(t, 5, intStatValue(int64(5)))
23 + require.Equal(t, 0, intStatValue("nan"))
24 +
25 + require.Equal(t, "value", topologyMetricValueString(map[string]any{"key": " value "}, "key"))
26 +}
src/go/plugin/go.d/collector/snmp_topology/topology_profiles.go new
+15
@@ -0,0 +1,15 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +// Topology profile selection is declarative: vendor/root profiles extend these
6 +// mixins directly, and snmp_topology relies on the normal FindProfiles path.
7 +
8 +const (
9 + topologyLldpProfileName = "_std-topology-lldp-mib.yaml"
10 + cdpProfileName = "_std-cdp-mib.yaml"
11 + fdbArpProfileName = "_std-topology-fdb-arp-mib.yaml"
12 + qBridgeProfileName = "_std-topology-q-bridge-mib.yaml"
13 + stpProfileName = "_std-topology-stp-mib.yaml"
14 + vtpProfileName = "_std-topology-cisco-vtp-mib.yaml"
15 +)
src/go/plugin/go.d/collector/snmp_topology/topology_profiles_test.go new
+89
@@ -0,0 +1,89 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
9 + "github.com/stretchr/testify/assert"
10 + "github.com/stretchr/testify/require"
11 +)
12 +
13 +func TestFindProfiles_UsesDeclarativeTopologyExtensions(t *testing.T) {
14 + t.Parallel()
15 +
16 + tests := []struct {
17 + name string
18 + sysObjectID string
19 + sysDescr string
20 + extensions []string
21 + }{
22 + {
23 + name: "Cisco",
24 + sysObjectID: "1.3.6.1.4.1.9.1.1",
25 + extensions: []string{topologyLldpProfileName, cdpProfileName, fdbArpProfileName, qBridgeProfileName, stpProfileName, vtpProfileName},
26 + },
27 + {
28 + name: "Cisco Small Business",
29 + sysObjectID: "1.3.6.1.4.1.9.6.1.94.24.5",
30 + extensions: []string{topologyLldpProfileName, cdpProfileName, fdbArpProfileName, qBridgeProfileName, stpProfileName},
31 + },
32 + {
33 + name: "Aruba",
34 + sysObjectID: "1.3.6.1.4.1.47196.4.1.1.1.50",
35 + sysDescr: "Aruba JL635A 8325 GL.10.04.2000",
36 + extensions: []string{topologyLldpProfileName, fdbArpProfileName, qBridgeProfileName, stpProfileName},
37 + },
38 + {
39 + name: "Arista",
40 + sysObjectID: "1.3.6.1.4.1.30065.1.3011.7050.1958.128",
41 + sysDescr: "Arista Networks EOS version 4.15.3F running on an Arista Networks DCS-7050TX-128",
42 + extensions: []string{topologyLldpProfileName, fdbArpProfileName, qBridgeProfileName, stpProfileName},
43 + },
44 + {
45 + name: "Juniper",
46 + sysObjectID: "1.3.6.1.4.1.2636.1.1.1.2.39",
47 + sysDescr: "Juniper SRX240B gsm-fw",
48 + extensions: []string{topologyLldpProfileName, fdbArpProfileName, qBridgeProfileName, stpProfileName},
49 + },
50 + {
51 + name: "MikroTik",
52 + sysObjectID: "1.3.6.1.4.1.14988.1",
53 + sysDescr: "RouterOS CRS326-24G-2S+",
54 + extensions: []string{topologyLldpProfileName, fdbArpProfileName, qBridgeProfileName, stpProfileName},
55 + },
56 + {
57 + name: "Zyxel",
58 + sysObjectID: "1.3.6.1.4.1.890.1.15",
59 + extensions: []string{topologyLldpProfileName, fdbArpProfileName, qBridgeProfileName, stpProfileName},
60 + },
61 + {
62 + name: "D-Link",
63 + sysObjectID: "1.3.6.1.4.1.171.10.137.1.1",
64 + extensions: []string{topologyLldpProfileName, fdbArpProfileName, qBridgeProfileName, stpProfileName},
65 + },
66 + }
67 +
68 + for _, tt := range tests {
69 + t.Run(tt.name, func(t *testing.T) {
70 + t.Parallel()
71 +
72 + profiles := ddsnmp.FindProfiles(tt.sysObjectID, tt.sysDescr, nil)
73 + require.NotEmpty(t, profiles)
74 +
75 + var found bool
76 + for _, prof := range profiles {
77 + if prof == nil || !prof.HasExtension(topologyLldpProfileName) {
78 + continue
79 + }
80 + for _, ext := range tt.extensions {
81 + assert.Truef(t, prof.HasExtension(ext), "expected extension %q for %s", ext, tt.name)
82 + }
83 + found = true
84 + }
85 +
86 + assert.Truef(t, found, "no topology-enabled profile matched %s", tt.name)
87 + })
88 + }
89 +}
src/go/plugin/go.d/collector/snmp_topology/topology_registry.go new
+106
@@ -0,0 +1,106 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sync"
7 +)
8 +
9 +type topologyQueryOptions struct {
10 + CollapseActorsByIP bool
11 + EliminateNonIPInferred bool
12 + MapType string
13 + InferenceStrategy string
14 + ManagedDeviceFocus string
15 + Depth int
16 + ResolveDNSName func(ip string) string
17 +}
18 +
19 +type topologyRegistry struct {
20 + mu sync.RWMutex
21 + caches map[*topologyCache]struct{}
22 +}
23 +
24 +type topologyManagedFocusTarget struct {
25 + Value string
26 + Name string
27 +}
28 +
29 +func newTopologyRegistry() *topologyRegistry {
30 + return &topologyRegistry{
31 + caches: make(map[*topologyCache]struct{}),
32 + }
33 +}
34 +
35 +var snmpTopologyRegistry = newTopologyRegistry()
36 +
37 +func (r *topologyRegistry) register(cache *topologyCache) {
38 + if r == nil || cache == nil {
39 + return
40 + }
41 + r.mu.Lock()
42 + r.caches[cache] = struct{}{}
43 + r.mu.Unlock()
44 +}
45 +
46 +func (r *topologyRegistry) unregister(cache *topologyCache) {
47 + if r == nil || cache == nil {
48 + return
49 + }
50 + r.mu.Lock()
51 + delete(r.caches, cache)
52 + r.mu.Unlock()
53 +}
54 +
55 +func (r *topologyRegistry) snapshot() (topologyData, bool) {
56 + return r.snapshotWithOptions(topologyQueryOptions{
57 + CollapseActorsByIP: true,
58 + EliminateNonIPInferred: true,
59 + MapType: topologyMapTypeLLDPCDPManaged,
60 + InferenceStrategy: topologyInferenceStrategyFDBMinimumKnowledge,
61 + ManagedDeviceFocus: topologyManagedFocusAllDevices,
62 + Depth: topologyDepthAllInternal,
63 + ResolveDNSName: resolveTopologyReverseDNSName, // live resolver — warms the cache
64 + })
65 +}
66 +
67 +func (r *topologyRegistry) snapshotWithOptions(options topologyQueryOptions) (topologyData, bool) {
68 + if r == nil {
69 + return topologyData{}, false
70 + }
71 + options = normalizeTopologyQueryOptions(options)
72 +
73 + aggregate, ok := aggregateTopologyObservationSnapshots(r.observationSnapshots())
74 + if !ok {
75 + return topologyData{}, false
76 + }
77 +
78 + return buildSNMPTopologySnapshot(aggregate, options)
79 +}
80 +
81 +func normalizeTopologyQueryOptions(options topologyQueryOptions) topologyQueryOptions {
82 + options.MapType = normalizeTopologyMapType(options.MapType)
83 + if options.MapType == "" {
84 + options.MapType = topologyMapTypeLLDPCDPManaged
85 + }
86 + options.InferenceStrategy = normalizeTopologyInferenceStrategy(options.InferenceStrategy)
87 + if options.InferenceStrategy == "" {
88 + options.InferenceStrategy = topologyInferenceStrategyFDBMinimumKnowledge
89 + }
90 + options.ManagedDeviceFocus = formatTopologyManagedFocuses(parseTopologyManagedFocuses(options.ManagedDeviceFocus))
91 + if options.Depth != topologyDepthAllInternal {
92 + if options.Depth < topologyDepthMin {
93 + options.Depth = topologyDepthMin
94 + } else if options.Depth > topologyDepthMax {
95 + options.Depth = topologyDepthMax
96 + }
97 + }
98 + return options
99 +}
100 +
101 +func (r *topologyRegistry) managedDeviceFocusTargets() []topologyManagedFocusTarget {
102 + if r == nil {
103 + return nil
104 + }
105 + return buildTopologyManagedFocusTargets(r.observationSnapshots())
106 +}
src/go/plugin/go.d/collector/snmp_topology/topology_registry_build.go new
+118
@@ -0,0 +1,118 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "time"
7 +
8 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
9 +)
10 +
11 +func buildSNMPTopologySnapshot(aggregate topologyObservationAggregate, options topologyQueryOptions) (topologyData, bool) {
12 + if len(aggregate.l2Observations) == 0 {
13 + return topologyData{}, false
14 + }
15 +
16 + if options.MapType != topologyMapTypeAllDevicesLowConfidence {
17 + return buildSingleMapTopologySnapshot(aggregate, options)
18 + }
19 +
20 + return buildProbableTopologySnapshot(aggregate, options)
21 +}
22 +
23 +func buildSingleMapTopologySnapshot(aggregate topologyObservationAggregate, options topologyQueryOptions) (topologyData, bool) {
24 + data, ok := buildSNMPL2TopologyData(
25 + aggregate.l2Observations,
26 + aggregate.agentID,
27 + aggregate.localDeviceID,
28 + aggregate.collectedAt,
29 + options,
30 + )
31 + if !ok {
32 + return topologyData{}, false
33 + }
34 + augmentTopologySnapshotLocals(&data, aggregate.snapshots)
35 + applySNMPTopologyOutputPolicies(&data, options)
36 + applyTopologyDepthFocusFilter(&data, options)
37 + return data, true
38 +}
39 +
40 +func buildProbableTopologySnapshot(aggregate topologyObservationAggregate, options topologyQueryOptions) (topologyData, bool) {
41 + strictOptions := options
42 + strictOptions.MapType = topologyMapTypeHighConfidenceInferred
43 + strictData, strictOK := buildSNMPL2TopologyData(
44 + aggregate.l2Observations,
45 + aggregate.agentID,
46 + aggregate.localDeviceID,
47 + aggregate.collectedAt,
48 + strictOptions,
49 + )
50 + if !strictOK {
51 + return topologyData{}, false
52 + }
53 + augmentTopologySnapshotLocals(&strictData, aggregate.snapshots)
54 + applySNMPTopologyOutputPolicies(&strictData, strictOptions)
55 +
56 + probableOptions := options
57 + probableOptions.MapType = topologyMapTypeAllDevicesLowConfidence
58 + probableData, probableOK := buildSNMPL2TopologyData(
59 + aggregate.l2Observations,
60 + aggregate.agentID,
61 + aggregate.localDeviceID,
62 + aggregate.collectedAt,
63 + probableOptions,
64 + )
65 + if !probableOK {
66 + return topologyData{}, false
67 + }
68 + augmentTopologySnapshotLocals(&probableData, aggregate.snapshots)
69 + applySNMPTopologyOutputPolicies(&probableData, probableOptions)
70 + markProbableDeltaLinks(&strictData, &probableData)
71 + applyTopologyDepthFocusFilter(&probableData, options)
72 + return probableData, true
73 +}
74 +
75 +func augmentTopologySnapshotLocals(data *topologyData, snapshots []topologyObservationSnapshot) {
76 + for _, snapshot := range snapshots {
77 + augmentLocalActorFromCache(data, snapshot.localDevice)
78 + }
79 +}
80 +
81 +func buildSNMPL2TopologyData(
82 + observations []topologyengine.L2Observation,
83 + agentID string,
84 + localDeviceID string,
85 + collectedAt time.Time,
86 + options topologyQueryOptions,
87 +) (topologyData, bool) {
88 + if len(observations) == 0 {
89 + return topologyData{}, false
90 + }
91 +
92 + result, err := topologyengine.BuildL2ResultFromObservations(observations, topologyengine.DiscoverOptions{
93 + EnableLLDP: true,
94 + EnableCDP: true,
95 + EnableBridge: true,
96 + EnableARP: true,
97 + EnableSTP: true,
98 + })
99 + if err != nil {
100 + return topologyData{}, false
101 + }
102 +
103 + data := topologyengine.ToTopologyData(result, topologyengine.TopologyDataOptions{
104 + SchemaVersion: topologySchemaVersion,
105 + Source: "snmp",
106 + Layer: "2",
107 + View: "summary",
108 + AgentID: agentID,
109 + LocalDeviceID: localDeviceID,
110 + CollectedAt: collectedAt,
111 + ResolveDNSName: options.ResolveDNSName,
112 + CollapseActorsByIP: options.CollapseActorsByIP,
113 + EliminateNonIPInferred: options.EliminateNonIPInferred,
114 + ProbabilisticConnectivity: isTopologyMapTypeProbable(options.MapType),
115 + InferenceStrategy: options.InferenceStrategy,
116 + })
117 + return data, true
118 +}
src/go/plugin/go.d/collector/snmp_topology/topology_registry_focus_targets.go new
+63
@@ -0,0 +1,63 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strings"
8 +)
9 +
10 +func buildTopologyManagedFocusTargets(snapshots []topologyObservationSnapshot) []topologyManagedFocusTarget {
11 + if len(snapshots) == 0 {
12 + return nil
13 + }
14 +
15 + targetByValue := make(map[string]topologyManagedFocusTarget)
16 + for _, snapshot := range snapshots {
17 + managementIP := normalizeIPAddress(snapshot.localDevice.ManagementIP)
18 + if managementIP == "" && len(snapshot.l2Observations) > 0 {
19 + managementIP = normalizeIPAddress(snapshot.l2Observations[0].ManagementIP)
20 + }
21 + if managementIP == "" {
22 + continue
23 + }
24 + value := topologyManagedFocusIPPrefix + managementIP
25 +
26 + displayName := strings.TrimSpace(snapshot.localDevice.SysName)
27 + if displayName == "" && len(snapshot.l2Observations) > 0 {
28 + displayName = strings.TrimSpace(snapshot.l2Observations[0].Hostname)
29 + }
30 + if displayName == "" {
31 + displayName = managementIP
32 + }
33 + label := displayName
34 + if !strings.EqualFold(displayName, managementIP) {
35 + label = displayName + " (" + managementIP + ")"
36 + }
37 +
38 + existing, exists := targetByValue[value]
39 + if !exists || label < existing.Name {
40 + targetByValue[value] = topologyManagedFocusTarget{
41 + Value: value,
42 + Name: label,
43 + }
44 + }
45 + }
46 + if len(targetByValue) == 0 {
47 + return nil
48 + }
49 +
50 + out := make([]topologyManagedFocusTarget, 0, len(targetByValue))
51 + for _, target := range targetByValue {
52 + out = append(out, target)
53 + }
54 + sort.Slice(out, func(i, j int) bool {
55 + leftName := strings.ToLower(strings.TrimSpace(out[i].Name))
56 + rightName := strings.ToLower(strings.TrimSpace(out[j].Name))
57 + if leftName != rightName {
58 + return leftName < rightName
59 + }
60 + return out[i].Value < out[j].Value
61 + })
62 + return out
63 +}
src/go/plugin/go.d/collector/snmp_topology/topology_registry_observations.go new
+99
@@ -0,0 +1,99 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 +
8 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
9 +)
10 +
11 +func (r *topologyRegistry) activeCaches() []*topologyCache {
12 + if r == nil {
13 + return nil
14 + }
15 +
16 + r.mu.RLock()
17 + caches := make([]*topologyCache, 0, len(r.caches))
18 + for cache := range r.caches {
19 + caches = append(caches, cache)
20 + }
21 + r.mu.RUnlock()
22 + return caches
23 +}
24 +
25 +func (r *topologyRegistry) observationSnapshots() []topologyObservationSnapshot {
26 + caches := r.activeCaches()
27 + if len(caches) == 0 {
28 + return nil
29 + }
30 +
31 + snapshots := make([]topologyObservationSnapshot, 0, len(caches))
32 + for _, cache := range caches {
33 + snapshot, ok := cache.snapshotEngineObservations()
34 + if !ok {
35 + continue
36 + }
37 + snapshots = append(snapshots, snapshot)
38 + }
39 + if len(snapshots) == 0 {
40 + return nil
41 + }
42 +
43 + sortTopologyObservationSnapshots(snapshots)
44 + return snapshots
45 +}
46 +
47 +func sortTopologyObservationSnapshots(snapshots []topologyObservationSnapshot) {
48 + sort.Slice(snapshots, func(i, j int) bool {
49 + if snapshots[i].localDeviceID != snapshots[j].localDeviceID {
50 + return snapshots[i].localDeviceID < snapshots[j].localDeviceID
51 + }
52 + leftMgmt, leftHost := topologyObservationSnapshotIdentity(snapshots[i])
53 + rightMgmt, rightHost := topologyObservationSnapshotIdentity(snapshots[j])
54 + if leftMgmt != rightMgmt {
55 + return leftMgmt < rightMgmt
56 + }
57 + if leftHost != rightHost {
58 + return leftHost < rightHost
59 + }
60 + return snapshots[i].collectedAt.Before(snapshots[j].collectedAt)
61 + })
62 +}
63 +
64 +func topologyObservationSnapshotIdentity(snapshot topologyObservationSnapshot) (managementIP, hostname string) {
65 + if len(snapshot.l2Observations) == 0 {
66 + return "", ""
67 + }
68 + return snapshot.l2Observations[0].ManagementIP, snapshot.l2Observations[0].Hostname
69 +}
70 +
71 +func aggregateTopologyObservationSnapshots(snapshots []topologyObservationSnapshot) (topologyObservationAggregate, bool) {
72 + if len(snapshots) == 0 {
73 + return topologyObservationAggregate{}, false
74 + }
75 +
76 + totalObservations := 0
77 + for _, snapshot := range snapshots {
78 + totalObservations += len(snapshot.l2Observations)
79 + }
80 +
81 + aggregate := topologyObservationAggregate{
82 + snapshots: snapshots,
83 + l2Observations: make([]topologyengine.L2Observation, 0, totalObservations),
84 + }
85 + for _, snapshot := range snapshots {
86 + aggregate.l2Observations = append(aggregate.l2Observations, snapshot.l2Observations...)
87 + if aggregate.localDeviceID == "" {
88 + aggregate.localDeviceID = snapshot.localDeviceID
89 + }
90 + if aggregate.agentID == "" && snapshot.agentID != "" {
91 + aggregate.agentID = snapshot.agentID
92 + }
93 + if snapshot.collectedAt.After(aggregate.collectedAt) {
94 + aggregate.collectedAt = snapshot.collectedAt
95 + }
96 + }
97 +
98 + return aggregate, len(aggregate.l2Observations) > 0
99 +}
src/go/plugin/go.d/collector/snmp_topology/topology_registry_snapshot.go new
+60
@@ -0,0 +1,60 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strings"
7 + "time"
8 +
9 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
10 +)
11 +
12 +type topologyObservationSnapshot struct {
13 + l2Observations []topologyengine.L2Observation
14 + localDevice topologyDevice
15 + localDeviceID string
16 + agentID string
17 + collectedAt time.Time
18 +}
19 +
20 +type topologyObservationAggregate struct {
21 + snapshots []topologyObservationSnapshot
22 + l2Observations []topologyengine.L2Observation
23 + localDeviceID string
24 + agentID string
25 + collectedAt time.Time
26 +}
27 +
28 +func (c *topologyCache) snapshotEngineObservations() (topologyObservationSnapshot, bool) {
29 + if c == nil {
30 + return topologyObservationSnapshot{}, false
31 + }
32 +
33 + c.mu.RLock()
34 + defer c.mu.RUnlock()
35 +
36 + if !c.hasFreshSnapshotAt(time.Now()) {
37 + return topologyObservationSnapshot{}, false
38 + }
39 +
40 + local := normalizeTopologyDevice(c.localDevice)
41 + localObservation := c.buildEngineObservation(local)
42 + localObservation.DeviceID = strings.TrimSpace(localObservation.DeviceID)
43 + if localObservation.DeviceID == "" {
44 + return topologyObservationSnapshot{}, false
45 + }
46 + if normalizeMAC(local.ChassisID) == "" {
47 + if mac := normalizeMAC(localObservation.BaseBridgeAddress); mac != "" {
48 + local.ChassisID = mac
49 + local.ChassisIDType = "macAddress"
50 + }
51 + }
52 +
53 + return topologyObservationSnapshot{
54 + l2Observations: []topologyengine.L2Observation{localObservation},
55 + localDevice: local,
56 + localDeviceID: localObservation.DeviceID,
57 + agentID: strings.TrimSpace(c.agentID),
58 + collectedAt: c.lastUpdate,
59 + }, true
60 +}
src/go/plugin/go.d/collector/snmp_topology/topology_registry_test.go new
+931
@@ -0,0 +1,931 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 + "time"
8 +
9 + "github.com/stretchr/testify/assert"
10 + "github.com/stretchr/testify/require"
11 +)
12 +
13 +func TestTopologyRegistry_SnapshotAggregatesAcrossCaches(t *testing.T) {
14 + registry := newTopologyRegistry()
15 +
16 + cacheA := newTopologyCache()
17 + cacheA.updateTime = time.Now()
18 + cacheA.lastUpdate = cacheA.updateTime
19 + cacheA.agentID = "agent-test"
20 + cacheA.localDevice = topologyDevice{
21 + ChassisID: "00:11:22:33:44:55",
22 + ChassisIDType: "macAddress",
23 + SysName: "sw-a",
24 + ManagementIP: "10.0.0.1",
25 + }
26 + cacheA.lldpLocPorts["1"] = &lldpLocPort{
27 + portNum: "1",
28 + portID: "Gi0/1",
29 + portIDSubtype: "interfaceName",
30 + }
31 + cacheA.lldpRemotes["1:1"] = &lldpRemote{
32 + localPortNum: "1",
33 + remIndex: "1",
34 + chassisID: "aa:bb:cc:dd:ee:ff",
35 + chassisIDSubtype: "macAddress",
36 + portID: "Gi0/2",
37 + portIDSubtype: "interfaceName",
38 + sysName: "sw-b",
39 + managementAddr: "10.0.0.2",
40 + }
41 +
42 + cacheB := newTopologyCache()
43 + cacheB.updateTime = time.Now().Add(time.Second)
44 + cacheB.lastUpdate = cacheB.updateTime
45 + cacheB.agentID = "agent-test"
46 + cacheB.localDevice = topologyDevice{
47 + ChassisID: "aa:bb:cc:dd:ee:ff",
48 + ChassisIDType: "macAddress",
49 + SysName: "sw-b",
50 + ManagementIP: "10.0.0.2",
51 + }
52 + cacheB.lldpLocPorts["1"] = &lldpLocPort{
53 + portNum: "1",
54 + portID: "Gi0/2",
55 + portIDSubtype: "interfaceName",
56 + }
57 + cacheB.lldpRemotes["1:1"] = &lldpRemote{
58 + localPortNum: "1",
59 + remIndex: "1",
60 + chassisID: "00:11:22:33:44:55",
61 + chassisIDSubtype: "macAddress",
62 + portID: "Gi0/1",
63 + portIDSubtype: "interfaceName",
64 + sysName: "sw-a",
65 + managementAddr: "10.0.0.1",
66 + }
67 +
68 + registry.register(cacheA)
69 + registry.register(cacheB)
70 +
71 + data, ok := registry.snapshot()
72 + require.True(t, ok)
73 + require.Equal(t, "2", data.Layer)
74 + require.Equal(t, "snmp", data.Source)
75 + require.Equal(t, "summary", data.View)
76 +
77 + require.GreaterOrEqual(t, data.Stats["devices_total"].(int), 2)
78 + require.GreaterOrEqual(t, data.Stats["links_total"].(int), 1)
79 + require.GreaterOrEqual(t, data.Stats["links_lldp"].(int), 1)
80 +}
81 +
82 +func TestTopologyRegistry_SnapshotSingleCacheKeepsLLDPUnidirectional(t *testing.T) {
83 + registry := newTopologyRegistry()
84 +
85 + cache := newTopologyCache()
86 + cache.updateTime = time.Now()
87 + cache.lastUpdate = cache.updateTime
88 + cache.agentID = "agent-test"
89 + cache.localDevice = topologyDevice{
90 + ChassisID: "00:11:22:33:44:55",
91 + ChassisIDType: "macAddress",
92 + SysName: "sw-a",
93 + ManagementIP: "10.0.0.1",
94 + }
95 + cache.lldpLocPorts["1"] = &lldpLocPort{
96 + portNum: "1",
97 + portID: "Gi0/1",
98 + portIDSubtype: "interfaceName",
99 + }
100 + cache.lldpRemotes["1:1"] = &lldpRemote{
101 + localPortNum: "1",
102 + remIndex: "1",
103 + chassisID: "aa:bb:cc:dd:ee:ff",
104 + chassisIDSubtype: "macAddress",
105 + portID: "Gi0/2",
106 + portIDSubtype: "interfaceName",
107 + sysName: "sw-b",
108 + managementAddr: "10.0.0.2",
109 + }
110 +
111 + registry.register(cache)
112 +
113 + data, ok := registry.snapshot()
114 + require.True(t, ok)
115 + require.Len(t, data.Links, 1)
116 + require.Equal(t, "lldp", data.Links[0].Protocol)
117 + require.Equal(t, "unidirectional", data.Links[0].Direction)
118 + _, hasPairConsistency := data.Links[0].Metrics["pair_consistent"]
119 + require.False(t, hasPairConsistency)
120 + require.Equal(t, 1, data.Stats["links_unidirectional"].(int))
121 + require.Equal(t, 0, data.Stats["links_bidirectional"].(int))
122 +}
123 +
124 +func TestCompareCollapseActorPriorityPrefersNonEmptyActorID(t *testing.T) {
125 + left := topologyActor{
126 + ActorID: "",
127 + ActorType: "device",
128 + Layer: "2",
129 + Source: "snmp",
130 + }
131 + right := topologyActor{
132 + ActorID: "device-1",
133 + ActorType: "device",
134 + Layer: "2",
135 + Source: "snmp",
136 + }
137 +
138 + assert.Greater(t, compareCollapseActorPriority(left, right), 0)
139 + assert.Less(t, compareCollapseActorPriority(right, left), 0)
140 +}
141 +
142 +func TestTopologyRegistry_SnapshotWithOptions_LLDPManagedKeepsRequestedMapType(t *testing.T) {
143 + registry := newTopologyRegistry()
144 + registry.register(newTestTopologyCacheLLDP(
145 + "agent-test",
146 + time.Now().UTC(),
147 + "00:11:22:33:44:55",
148 + "sw-a",
149 + "10.0.0.1",
150 + "Gi0/1",
151 + "aa:bb:cc:dd:ee:ff",
152 + "sw-b",
153 + "10.0.0.2",
154 + "Gi0/2",
155 + ))
156 +
157 + data, ok := registry.snapshotWithOptions(topologyQueryOptions{
158 + CollapseActorsByIP: true,
159 + EliminateNonIPInferred: true,
160 + MapType: topologyMapTypeLLDPCDPManaged,
161 + ManagedDeviceFocus: topologyManagedFocusAllDevices,
162 + Depth: topologyDepthAllInternal,
163 + })
164 + require.True(t, ok)
165 + require.Equal(t, topologyMapTypeLLDPCDPManaged, data.Stats["map_type"])
166 + require.Equal(t, topologyInferenceStrategyFDBMinimumKnowledge, data.Stats["inference_strategy"])
167 +}
168 +
169 +func TestTopologyRegistry_SnapshotWithOptions_CollapseByIPPreservesEngineManagedOverlapPruning(t *testing.T) {
170 + registry := newTopologyRegistry()
171 +
172 + cache := newTopologyCache()
173 + cache.updateTime = time.Now().UTC()
174 + cache.lastUpdate = cache.updateTime
175 + cache.agentID = "agent-test"
176 + cache.localDevice = topologyDevice{
177 + ChassisID: "aa:aa:aa:aa:aa:aa",
178 + ChassisIDType: "macAddress",
179 + SysName: "switch-a",
180 + ManagementIP: "10.0.0.1",
181 + }
182 + cache.lldpLocPorts["1"] = &lldpLocPort{
183 + portNum: "1",
184 + portID: "Gi0/1",
185 + portIDSubtype: "interfaceName",
186 + }
187 + cache.lldpRemotes["1:1"] = &lldpRemote{
188 + localPortNum: "1",
189 + remIndex: "1",
190 + chassisID: "9c:6b:00:7b:98:c6",
191 + chassisIDSubtype: "macAddress",
192 + portID: "9c:6b:00:7b:98:c7",
193 + portIDSubtype: "macAddress",
194 + sysName: "nova",
195 + managementAddr: "172.22.0.1",
196 + }
197 + cache.ifNamesByIndex["1"] = "Gi0/1"
198 + cache.ifNamesByIndex["2"] = "Gi0/2"
199 + cache.bridgePortToIf["2"] = "2"
200 + cache.fdbEntries["9c:6b:00:7b:98:c7|2||"] = &fdbEntry{
201 + mac: "9c:6b:00:7b:98:c7",
202 + bridgePort: "2",
203 + }
204 + cache.arpEntries["2|10.20.4.22|9c:6b:00:7b:98:c7"] = &arpEntry{
205 + ifIndex: "2",
206 + ifName: "Gi0/2",
207 + ip: "10.20.4.22",
208 + mac: "9c:6b:00:7b:98:c7",
209 + }
210 + registry.register(cache)
211 +
212 + withoutCollapse, ok := registry.snapshotWithOptions(topologyQueryOptions{
213 + MapType: topologyMapTypeAllDevicesLowConfidence,
214 + ManagedDeviceFocus: topologyManagedFocusAllDevices,
215 + Depth: topologyDepthAllInternal,
216 + })
217 + require.True(t, ok)
218 + require.NotNil(t, findActorByMAC(withoutCollapse, "9c:6b:00:7b:98:c7"))
219 +
220 + withCollapse, ok := registry.snapshotWithOptions(topologyQueryOptions{
221 + CollapseActorsByIP: true,
222 + MapType: topologyMapTypeAllDevicesLowConfidence,
223 + ManagedDeviceFocus: topologyManagedFocusAllDevices,
224 + Depth: topologyDepthAllInternal,
225 + })
226 + require.True(t, ok)
227 + require.NotNil(t, findActorByMAC(withCollapse, "9c:6b:00:7b:98:c6"))
228 + require.Nil(t, findActorByMAC(withCollapse, "9c:6b:00:7b:98:c7"))
229 + require.Equal(t, 1, withCollapse.Stats["actors_unlinked_suppressed"])
230 +}
231 +
232 +func TestTopologyRegistry_ManagedDeviceFocusTargets_ReturnsPerDeviceIPTargets(t *testing.T) {
233 + registry := newTopologyRegistry()
234 + registry.register(newTestTopologyCacheLLDP(
235 + "agent-test",
236 + time.Now().UTC(),
237 + "00:11:22:33:44:55",
238 + "sw-a",
239 + "10.0.0.1",
240 + "Gi0/1",
241 + "aa:bb:cc:dd:ee:ff",
242 + "sw-b",
243 + "10.0.0.2",
244 + "Gi0/2",
245 + ))
246 +
247 + targets := registry.managedDeviceFocusTargets()
248 + require.Len(t, targets, 1)
249 + require.Equal(t, "ip:10.0.0.1", targets[0].Value)
250 + require.Equal(t, "sw-a (10.0.0.1)", targets[0].Name)
251 +}
252 +
253 +func TestTopologyCache_SnapshotEngineObservationsUsesDirectLocalObservation(t *testing.T) {
254 + cache := newTopologyCache()
255 + cache.updateTime = time.Now()
256 + cache.lastUpdate = cache.updateTime
257 + cache.agentID = "agent-test"
258 + cache.localDevice = topologyDevice{
259 + ChassisID: "00:11:22:33:44:55",
260 + ChassisIDType: "macAddress",
261 + SysName: "sw-a",
262 + ManagementIP: "10.0.0.1",
263 + }
264 + cache.lldpLocPorts["1"] = &lldpLocPort{
265 + portNum: "1",
266 + portID: "Gi0/1",
267 + portIDSubtype: "interfaceName",
268 + }
269 + cache.lldpRemotes["1:1"] = &lldpRemote{
270 + localPortNum: "1",
271 + remIndex: "1",
272 + chassisID: "aa:bb:cc:dd:ee:ff",
273 + chassisIDSubtype: "macAddress",
274 + portID: "Gi0/2",
275 + portIDSubtype: "interfaceName",
276 + sysName: "sw-b",
277 + managementAddr: "10.0.0.2",
278 + }
279 + cache.cdpRemotes["1:1"] = &cdpRemote{
280 + ifIndex: "1",
281 + ifName: "Gi0/1",
282 + deviceID: "sw-b",
283 + sysName: "sw-b",
284 + devicePort: "Gi0/2",
285 + address: "10.0.0.2",
286 + }
287 +
288 + snapshot, ok := cache.snapshotEngineObservations()
289 + require.True(t, ok)
290 + require.Len(t, snapshot.l2Observations, 1)
291 + require.Equal(t, snapshot.localDeviceID, snapshot.l2Observations[0].DeviceID)
292 + require.Len(t, snapshot.l2Observations[0].LLDPRemotes, 1)
293 + require.Len(t, snapshot.l2Observations[0].CDPRemotes, 1)
294 +}
295 +
296 +func TestTopologyRegistry_SnapshotReturnsFalseWithoutCollectedCaches(t *testing.T) {
297 + registry := newTopologyRegistry()
298 + cache := newTopologyCache()
299 + registry.register(cache)
300 +
301 + _, ok := registry.snapshot()
302 + require.False(t, ok)
303 +}
304 +
305 +func TestTopologyRegistry_SnapshotDeterministicAcrossRepeatedCalls(t *testing.T) {
306 + registry := newTopologyRegistry()
307 +
308 + cacheA := newTopologyCache()
309 + cacheA.updateTime = time.Now()
310 + cacheA.lastUpdate = cacheA.updateTime
311 + cacheA.agentID = "agent-test"
312 + cacheA.localDevice = topologyDevice{
313 + ChassisID: "00:11:22:33:44:55",
314 + ChassisIDType: "macAddress",
315 + SysName: "sw-a",
316 + ManagementIP: "10.0.0.1",
317 + }
318 + cacheA.lldpLocPorts["1"] = &lldpLocPort{
319 + portNum: "1",
320 + portID: "Gi0/1",
321 + portIDSubtype: "interfaceName",
322 + }
323 + cacheA.lldpRemotes["1:1"] = &lldpRemote{
324 + localPortNum: "1",
325 + remIndex: "1",
326 + chassisID: "aa:bb:cc:dd:ee:ff",
327 + chassisIDSubtype: "macAddress",
328 + portID: "Gi0/2",
329 + portIDSubtype: "interfaceName",
330 + sysName: "sw-b",
331 + managementAddr: "10.0.0.2",
332 + }
333 +
334 + cacheB := newTopologyCache()
335 + cacheB.updateTime = time.Now().Add(time.Second)
336 + cacheB.lastUpdate = cacheB.updateTime
337 + cacheB.agentID = "agent-test"
338 + cacheB.localDevice = topologyDevice{
339 + ChassisID: "aa:bb:cc:dd:ee:ff",
340 + ChassisIDType: "macAddress",
341 + SysName: "sw-b",
342 + ManagementIP: "10.0.0.2",
343 + }
344 + cacheB.lldpLocPorts["1"] = &lldpLocPort{
345 + portNum: "1",
346 + portID: "Gi0/2",
347 + portIDSubtype: "interfaceName",
348 + }
349 + cacheB.lldpRemotes["1:1"] = &lldpRemote{
350 + localPortNum: "1",
351 + remIndex: "1",
352 + chassisID: "00:11:22:33:44:55",
353 + chassisIDSubtype: "macAddress",
354 + portID: "Gi0/1",
355 + portIDSubtype: "interfaceName",
356 + sysName: "sw-a",
357 + managementAddr: "10.0.0.1",
358 + }
359 +
360 + registry.register(cacheA)
361 + registry.register(cacheB)
362 +
363 + baseline, ok := registry.snapshot()
364 + require.True(t, ok)
365 + require.NotEmpty(t, baseline.Actors)
366 + require.NotEmpty(t, baseline.Links)
367 +
368 + for range 10 {
369 + next, ok := registry.snapshot()
370 + require.True(t, ok)
371 + require.Equal(t, baseline, next)
372 + }
373 +}
374 +
375 +func TestTopologyRegistry_SnapshotDeduplicatesDuplicateDeviceObservations(t *testing.T) {
376 + registry := newTopologyRegistry()
377 +
378 + cacheA := newTopologyCache()
379 + cacheA.updateTime = time.Now()
380 + cacheA.lastUpdate = cacheA.updateTime
381 + cacheA.agentID = "agent-test"
382 + cacheA.localDevice = topologyDevice{
383 + ChassisID: "00:11:22:33:44:55",
384 + ChassisIDType: "macAddress",
385 + SysName: "sw-a",
386 + ManagementIP: "10.0.0.1",
387 + }
388 + cacheA.lldpLocPorts["1"] = &lldpLocPort{
389 + portNum: "1",
390 + portID: "Gi0/1",
391 + portIDSubtype: "interfaceName",
392 + }
393 + cacheA.lldpRemotes["1:1"] = &lldpRemote{
394 + localPortNum: "1",
395 + remIndex: "1",
396 + chassisID: "aa:bb:cc:dd:ee:ff",
397 + chassisIDSubtype: "macAddress",
398 + portID: "Gi0/2",
399 + portIDSubtype: "interfaceName",
400 + sysName: "sw-b",
401 + managementAddr: "10.0.0.2",
402 + }
403 +
404 + cacheB := newTopologyCache()
405 + cacheB.updateTime = cacheA.updateTime
406 + cacheB.lastUpdate = cacheA.lastUpdate
407 + cacheB.agentID = cacheA.agentID
408 + cacheB.localDevice = cacheA.localDevice
409 + cacheB.lldpLocPorts["1"] = cacheA.lldpLocPorts["1"]
410 + cacheB.lldpRemotes["1:1"] = cacheA.lldpRemotes["1:1"]
411 +
412 + registry.register(cacheA)
413 + registry.register(cacheB)
414 +
415 + data, ok := registry.snapshot()
416 + require.True(t, ok)
417 +
418 + require.Len(t, data.Links, 1)
419 + require.Equal(t, 1, data.Stats["links_total"])
420 + require.Equal(t, 2, countActorsByType(data, "device"))
421 +}
422 +
423 +func TestCanonicalMatchKey_NormalizesEquivalentMACRepresentations(t *testing.T) {
424 + raw := topologyMatch{ChassisIDs: []string{"7049a26572cd"}}
425 + colon := topologyMatch{MacAddresses: []string{"70:49:A2:65:72:CD"}}
426 + require.Equal(t, "mac:70:49:a2:65:72:cd", canonicalMatchKey(raw))
427 + require.Equal(t, "mac:70:49:a2:65:72:cd", canonicalMatchKey(colon))
428 + require.Contains(t, topologyMatchIdentityKeys(raw), "hw:70:49:a2:65:72:cd")
429 + require.Contains(t, topologyMatchIdentityKeys(colon), "hw:70:49:a2:65:72:cd")
430 +}
431 +
432 +func TestApplySNMPTopologyOutputPolicies_CollapsesActorsByIP(t *testing.T) {
433 + data := topologyData{
434 + Actors: []topologyActor{
435 + {
436 + ActorID: "device:a",
437 + ActorType: "device",
438 + Match: topologyMatch{
439 + IPAddresses: []string{"10.0.0.10"},
440 + MacAddresses: []string{"aa:aa:aa:aa:aa:aa"},
441 + },
442 + Attributes: map[string]any{"inferred": false},
443 + },
444 + {
445 + ActorID: "endpoint:b",
446 + ActorType: "endpoint",
447 + Match: topologyMatch{
448 + IPAddresses: []string{"10.0.0.10"},
449 + MacAddresses: []string{"bb:bb:bb:bb:bb:bb"},
450 + },
451 + },
452 + },
453 + Links: []topologyLink{
454 + {
455 + SrcActorID: "endpoint:b",
456 + DstActorID: "device:a",
457 + Protocol: "fdb",
458 + Direction: "bidirectional",
459 + },
460 + },
461 + Stats: map[string]any{},
462 + }
463 +
464 + applySNMPTopologyOutputPolicies(&data, topologyQueryOptions{
465 + CollapseActorsByIP: true,
466 + MapType: topologyMapTypeHighConfidenceInferred,
467 + })
468 +
469 + require.Len(t, data.Actors, 1)
470 + require.Len(t, data.Links, 0)
471 + require.Equal(t, 1, data.Stats["actors_collapsed_by_ip"])
472 +}
473 +
474 +func TestApplySNMPTopologyOutputPolicies_EliminatesNonIPInferredActorsAndSparseSegments(t *testing.T) {
475 + data := topologyData{
476 + Actors: []topologyActor{
477 + {
478 + ActorID: "segment:s1",
479 + ActorType: "segment",
480 + Match: topologyMatch{
481 + Hostnames: []string{"segment:s1"},
482 + },
483 + },
484 + {
485 + ActorID: "endpoint:e1",
486 + ActorType: "endpoint",
487 + Match: topologyMatch{
488 + MacAddresses: []string{"cc:cc:cc:cc:cc:cc"},
489 + },
490 + },
491 + },
492 + Links: []topologyLink{
493 + {
494 + SrcActorID: "segment:s1",
495 + DstActorID: "endpoint:e1",
496 + Protocol: "fdb",
497 + Direction: "bidirectional",
498 + },
499 + },
500 + Stats: map[string]any{},
501 + }
502 +
503 + applySNMPTopologyOutputPolicies(&data, topologyQueryOptions{
504 + EliminateNonIPInferred: true,
505 + MapType: topologyMapTypeHighConfidenceInferred,
506 + })
507 +
508 + require.Len(t, data.Actors, 0)
509 + require.Len(t, data.Links, 0)
510 + require.Equal(t, 1, data.Stats["actors_non_ip_inferred_suppressed"])
511 + require.Equal(t, 1, data.Stats["segments_sparse_suppressed"])
512 +}
513 +
514 +func TestApplySNMPTopologyOutputPolicies_HighConfidenceSuppressesUnlinkedInferredEndpoints(t *testing.T) {
515 + data := topologyData{
516 + Actors: []topologyActor{
517 + {
518 + ActorID: "device:d1",
519 + ActorType: "device",
520 + Source: "snmp",
521 + Match: topologyMatch{IPAddresses: []string{"10.0.0.1"}},
522 + },
523 + {
524 + ActorID: "endpoint:linked",
525 + ActorType: "endpoint",
526 + Source: "snmp",
527 + Match: topologyMatch{IPAddresses: []string{"10.0.0.2"}},
528 + },
529 + {
530 + ActorID: "endpoint:unlinked",
531 + ActorType: "endpoint",
532 + Source: "snmp",
533 + Match: topologyMatch{IPAddresses: []string{"10.0.0.3"}},
534 + },
535 + },
536 + Links: []topologyLink{
537 + {
538 + SrcActorID: "device:d1",
539 + DstActorID: "endpoint:linked",
540 + Protocol: "fdb",
541 + Direction: "bidirectional",
542 + },
543 + },
544 + Stats: map[string]any{},
545 + }
546 +
547 + applySNMPTopologyOutputPolicies(&data, topologyQueryOptions{
548 + MapType: topologyMapTypeHighConfidenceInferred,
549 + })
550 +
551 + require.Len(t, data.Actors, 2)
552 + require.Equal(t, 1, data.Stats["actors_map_type_suppressed"])
553 + for _, actor := range data.Actors {
554 + require.NotEqual(t, "endpoint:unlinked", actor.ActorID)
555 + }
556 +}
557 +
558 +func TestApplySNMPTopologyOutputPolicies_LLDPManagedMapKeepsOnlyLLDPCDPAndManagedDevices(t *testing.T) {
559 + data := topologyData{
560 + Actors: []topologyActor{
561 + {
562 + ActorID: "device:d1",
563 + ActorType: "device",
564 + Source: "snmp",
565 + Match: topologyMatch{IPAddresses: []string{"10.0.0.1"}},
566 + },
567 + {
568 + ActorID: "device:d2",
569 + ActorType: "device",
570 + Source: "snmp",
571 + Match: topologyMatch{IPAddresses: []string{"10.0.0.2"}},
572 + },
573 + {
574 + ActorID: "endpoint:e1",
575 + ActorType: "endpoint",
576 + Source: "snmp",
577 + Match: topologyMatch{IPAddresses: []string{"10.0.0.3"}},
578 + },
579 + },
580 + Links: []topologyLink{
581 + {
582 + SrcActorID: "device:d1",
583 + DstActorID: "device:d2",
584 + Protocol: "lldp",
585 + Direction: "bidirectional",
586 + },
587 + {
588 + SrcActorID: "device:d1",
589 + DstActorID: "endpoint:e1",
590 + Protocol: "fdb",
591 + Direction: "bidirectional",
592 + },
593 + },
594 + Stats: map[string]any{},
595 + }
596 +
597 + applySNMPTopologyOutputPolicies(&data, topologyQueryOptions{
598 + MapType: topologyMapTypeLLDPCDPManaged,
599 + })
600 +
601 + require.Len(t, data.Actors, 2)
602 + require.Len(t, data.Links, 1)
603 + require.Equal(t, "lldp", data.Links[0].Protocol)
604 + require.Equal(t, 1, data.Stats["actors_map_type_suppressed"])
605 +}
606 +
607 +func TestMarkProbableDeltaLinks_MarksAllAddedLinksAsProbable(t *testing.T) {
608 + strictData := topologyData{
609 + Links: []topologyLink{
610 + {
611 + SrcActorID: "device:d1",
612 + DstActorID: "device:d2",
613 + Protocol: "lldp",
614 + Direction: "bidirectional",
615 + },
616 + },
617 + Stats: map[string]any{},
618 + }
619 + probableData := topologyData{
620 + Links: []topologyLink{
621 + {
622 + SrcActorID: "device:d1",
623 + DstActorID: "device:d2",
624 + Protocol: "lldp",
625 + Direction: "bidirectional",
626 + },
627 + {
628 + SrcActorID: "device:d1",
629 + DstActorID: "segment:s1",
630 + Protocol: "bridge",
631 + Direction: "bidirectional",
632 + Metrics: map[string]any{
633 + "bridge_domain": "bridge-domain:s1",
634 + },
635 + },
636 + },
637 + Stats: map[string]any{},
638 + }
639 +
640 + markProbableDeltaLinks(&strictData, &probableData)
641 +
642 + require.Len(t, probableData.Links, 2)
643 + require.Equal(t, "", probableData.Links[0].State)
644 + require.Equal(t, "probable", probableData.Links[1].State)
645 + require.Equal(t, "probable", probableData.Links[1].Metrics["inference"])
646 + require.Equal(t, "probable_bridge_anchor", probableData.Links[1].Metrics["attachment_mode"])
647 +}
648 +
649 +func TestApplyTopologyDepthFocusFilter_ManagedFocusDepthZero(t *testing.T) {
650 + data := topologyData{
651 + Actors: []topologyActor{
652 + {
653 + ActorID: "device:managed-a",
654 + ActorType: "device",
655 + Source: "snmp",
656 + Match: topologyMatch{IPAddresses: []string{"10.0.0.1"}},
657 + },
658 + {
659 + ActorID: "device:managed-b",
660 + ActorType: "device",
661 + Source: "snmp",
662 + Match: topologyMatch{IPAddresses: []string{"10.0.0.2"}},
663 + },
664 + {
665 + ActorID: "endpoint:e1",
666 + ActorType: "endpoint",
667 + Source: "snmp",
668 + Match: topologyMatch{IPAddresses: []string{"10.0.0.3"}},
669 + },
670 + {
671 + ActorID: "segment:s1",
672 + ActorType: "segment",
673 + Source: "snmp",
674 + Match: topologyMatch{Hostnames: []string{"segment:s1"}},
675 + },
676 + },
677 + Links: []topologyLink{
678 + {
679 + SrcActorID: "device:managed-a",
680 + DstActorID: "device:managed-b",
681 + Protocol: "lldp",
682 + Direction: "bidirectional",
683 + },
684 + {
685 + SrcActorID: "device:managed-a",
686 + DstActorID: "segment:s1",
687 + Protocol: "bridge",
688 + Direction: "bidirectional",
689 + },
690 + {
691 + SrcActorID: "segment:s1",
692 + DstActorID: "endpoint:e1",
693 + Protocol: "fdb",
694 + Direction: "bidirectional",
695 + },
696 + },
697 + Stats: map[string]any{},
698 + }
699 +
700 + applyTopologyDepthFocusFilter(&data, topologyQueryOptions{
701 + ManagedDeviceFocus: "ip:10.0.0.1",
702 + Depth: 0,
703 + EliminateNonIPInferred: true,
704 + })
705 +
706 + require.Len(t, data.Actors, 1)
707 + require.Len(t, data.Links, 0)
708 + require.Equal(t, "ip:10.0.0.1", data.Stats["managed_snmp_device_focus"])
709 + require.Equal(t, 0, data.Stats["depth"])
710 +}
711 +
712 +func TestApplyTopologyDepthFocusFilter_ManagedFocusDepthOneIncludesDirectNeighbors(t *testing.T) {
713 + data := topologyData{
714 + Actors: []topologyActor{
715 + {
716 + ActorID: "device:managed-a",
717 + ActorType: "device",
718 + Source: "snmp",
719 + Match: topologyMatch{IPAddresses: []string{"10.0.0.1"}},
720 + },
721 + {
722 + ActorID: "device:managed-b",
723 + ActorType: "device",
724 + Source: "snmp",
725 + Match: topologyMatch{IPAddresses: []string{"10.0.0.2"}},
726 + },
727 + {
728 + ActorID: "endpoint:e1",
729 + ActorType: "endpoint",
730 + Source: "snmp",
731 + Match: topologyMatch{IPAddresses: []string{"10.0.0.3"}},
732 + },
733 + {
734 + ActorID: "segment:s1",
735 + ActorType: "segment",
736 + Source: "snmp",
737 + Match: topologyMatch{Hostnames: []string{"segment:s1"}},
738 + },
739 + },
740 + Links: []topologyLink{
741 + {
742 + SrcActorID: "device:managed-a",
743 + DstActorID: "device:managed-b",
744 + Protocol: "lldp",
745 + Direction: "bidirectional",
746 + },
747 + {
748 + SrcActorID: "device:managed-a",
749 + DstActorID: "segment:s1",
750 + Protocol: "bridge",
751 + Direction: "bidirectional",
752 + },
753 + {
754 + SrcActorID: "segment:s1",
755 + DstActorID: "endpoint:e1",
756 + Protocol: "fdb",
757 + Direction: "bidirectional",
758 + },
759 + },
760 + Stats: map[string]any{},
761 + }
762 +
763 + applyTopologyDepthFocusFilter(&data, topologyQueryOptions{
764 + ManagedDeviceFocus: "ip:10.0.0.1",
765 + Depth: 1,
766 + EliminateNonIPInferred: true,
767 + })
768 +
769 + require.Len(t, data.Actors, 4)
770 + require.Len(t, data.Links, 3)
771 + require.Equal(t, "ip:10.0.0.1", data.Stats["managed_snmp_device_focus"])
772 + require.Equal(t, 1, data.Stats["depth"])
773 +}
774 +
775 +func TestApplyTopologyDepthFocusFilter_MultiFocusDepthZeroIncludesAllShortestPaths(t *testing.T) {
776 + data := topologyData{
777 + Actors: []topologyActor{
778 + {
779 + ActorID: "device:managed-a",
780 + ActorType: "device",
781 + Source: "snmp",
782 + Match: topologyMatch{IPAddresses: []string{"10.0.0.1"}},
783 + },
784 + {
785 + ActorID: "device:managed-b",
786 + ActorType: "device",
787 + Source: "snmp",
788 + Match: topologyMatch{IPAddresses: []string{"10.0.0.2"}},
789 + },
790 + {
791 + ActorID: "device:managed-c",
792 + ActorType: "device",
793 + Source: "snmp",
794 + Match: topologyMatch{IPAddresses: []string{"10.0.0.3"}},
795 + },
796 + {
797 + ActorID: "segment:s1",
798 + ActorType: "segment",
799 + Source: "snmp",
800 + Match: topologyMatch{Hostnames: []string{"segment:s1"}},
801 + },
802 + },
803 + Links: []topologyLink{
804 + {
805 + SrcActorID: "device:managed-a",
806 + DstActorID: "device:managed-b",
807 + Protocol: "lldp",
808 + Direction: "bidirectional",
809 + },
810 + {
811 + SrcActorID: "device:managed-b",
812 + DstActorID: "device:managed-c",
813 + Protocol: "lldp",
814 + Direction: "bidirectional",
815 + },
816 + {
817 + SrcActorID: "device:managed-a",
818 + DstActorID: "segment:s1",
819 + Protocol: "bridge",
820 + Direction: "bidirectional",
821 + },
822 + {
823 + SrcActorID: "segment:s1",
824 + DstActorID: "device:managed-c",
825 + Protocol: "fdb",
826 + Direction: "bidirectional",
827 + },
828 + },
829 + Stats: map[string]any{},
830 + }
831 +
832 + applyTopologyDepthFocusFilter(&data, topologyQueryOptions{
833 + ManagedDeviceFocus: "ip:10.0.0.3,ip:10.0.0.1",
834 + Depth: 0,
835 + EliminateNonIPInferred: true,
836 + })
837 +
838 + actorIDs := make([]string, 0, len(data.Actors))
839 + for _, actor := range data.Actors {
840 + actorIDs = append(actorIDs, actor.ActorID)
841 + }
842 + assert.ElementsMatch(
843 + t,
844 + []string{"device:managed-a", "device:managed-b", "device:managed-c", "segment:s1"},
845 + actorIDs,
846 + )
847 + require.Len(t, data.Links, 4)
848 + require.Equal(t, "ip:10.0.0.1,ip:10.0.0.3", data.Stats["managed_snmp_device_focus"])
849 + require.Equal(t, 0, data.Stats["depth"])
850 +}
851 +
852 +func TestApplyTopologyDepthFocusFilter_DepthExpandsFromSelectedRootsOnly(t *testing.T) {
853 + data := topologyData{
854 + Actors: []topologyActor{
855 + {
856 + ActorID: "device:managed-a",
857 + ActorType: "device",
858 + Source: "snmp",
859 + Match: topologyMatch{IPAddresses: []string{"10.0.0.1"}},
860 + },
861 + {
862 + ActorID: "device:managed-b",
863 + ActorType: "device",
864 + Source: "snmp",
865 + Match: topologyMatch{IPAddresses: []string{"10.0.0.2"}},
866 + },
867 + {
868 + ActorID: "device:managed-c",
869 + ActorType: "device",
870 + Source: "snmp",
871 + Match: topologyMatch{IPAddresses: []string{"10.0.0.3"}},
872 + },
873 + {
874 + ActorID: "endpoint:x",
875 + ActorType: "endpoint",
876 + Source: "snmp",
877 + Match: topologyMatch{IPAddresses: []string{"10.0.0.50"}},
878 + },
879 + },
880 + Links: []topologyLink{
881 + {
882 + SrcActorID: "device:managed-a",
883 + DstActorID: "device:managed-b",
884 + Protocol: "lldp",
885 + Direction: "bidirectional",
886 + },
887 + {
888 + SrcActorID: "device:managed-b",
889 + DstActorID: "device:managed-c",
890 + Protocol: "lldp",
891 + Direction: "bidirectional",
892 + },
893 + {
894 + SrcActorID: "device:managed-b",
895 + DstActorID: "endpoint:x",
896 + Protocol: "fdb",
897 + Direction: "bidirectional",
898 + },
899 + },
900 + Stats: map[string]any{},
901 + }
902 +
903 + applyTopologyDepthFocusFilter(&data, topologyQueryOptions{
904 + ManagedDeviceFocus: "ip:10.0.0.1,ip:10.0.0.3",
905 + Depth: 1,
906 + EliminateNonIPInferred: true,
907 + })
908 +
909 + actorIDs := make([]string, 0, len(data.Actors))
910 + for _, actor := range data.Actors {
911 + actorIDs = append(actorIDs, actor.ActorID)
912 + }
913 + assert.ElementsMatch(
914 + t,
915 + []string{"device:managed-a", "device:managed-b", "device:managed-c"},
916 + actorIDs,
917 + )
918 + for _, link := range data.Links {
919 + assert.False(t, link.SrcActorID == "endpoint:x" || link.DstActorID == "endpoint:x")
920 + }
921 +}
922 +
923 +func countActorsByType(data topologyData, actorType string) int {
924 + total := 0
925 + for _, actor := range data.Actors {
926 + if actor.ActorType == actorType {
927 + total++
928 + }
929 + }
930 + return total
931 +}
src/go/plugin/go.d/collector/snmp_topology/topology_snapshot_builder.go new
+112
@@ -0,0 +1,112 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "time"
7 +
8 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
9 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
10 +)
11 +
12 +func buildLocalTopologyDevice(dev ddsnmp.DeviceConnectionInfo) topologyDevice {
13 + device := topologyDevice{
14 + ManagementIP: dev.Hostname,
15 + ChartIDPrefix: topologyProfileChartIDPrefix,
16 + ChartContextPrefix: topologyProfileChartContextPrefix,
17 + SysObjectID: dev.SysObjectID,
18 + SysName: dev.SysName,
19 + SysDescr: dev.SysDescr,
20 + SysContact: dev.SysContact,
21 + SysLocation: dev.SysLocation,
22 + Vendor: dev.Vendor,
23 + Model: dev.Model,
24 + }
25 +
26 + if dev.VnodeGUID != "" {
27 + device.AgentID = dev.VnodeGUID
28 + device.NetdataHostID = dev.VnodeGUID
29 + }
30 +
31 + if len(dev.VnodeLabels) > 0 {
32 + device.Labels = cloneTopologyLabels(dev.VnodeLabels)
33 + }
34 +
35 + if value := topologyMetadataValue(device.Labels, topologyMetadataAliasSysDescr); value != "" && device.SysDescr == "" {
36 + device.SysDescr = value
37 + }
38 + if value := topologyMetadataValue(device.Labels, topologyMetadataAliasSysContact); value != "" && device.SysContact == "" {
39 + device.SysContact = value
40 + }
41 + if value := topologyMetadataValue(device.Labels, topologyMetadataAliasSysLocation); value != "" && device.SysLocation == "" {
42 + device.SysLocation = value
43 + }
44 + if value := topologyMetadataValue(device.Labels, topologyMetadataAliasVendor); value != "" && device.Vendor == "" {
45 + device.Vendor = value
46 + }
47 + if value := topologyMetadataValue(device.Labels, topologyMetadataAliasModel); value != "" && device.Model == "" {
48 + device.Model = value
49 + }
50 +
51 + if value := topologyMetadataValue(device.Labels, topologyMetadataAliasSysUptime); value != "" {
52 + if uptime := parsePositiveInt64(value); uptime > 0 {
53 + device.SysUptime = uptime
54 + }
55 + }
56 + if value := topologyMetadataValue(device.Labels, topologyMetadataAliasSerial); value != "" {
57 + device.SerialNumber = value
58 + setTopologyMetadataLabelIfMissing(device.Labels, "serial_number", value)
59 + }
60 + if value := topologyMetadataValue(device.Labels, topologyMetadataAliasSoftware); value != "" {
61 + device.SoftwareVersion = value
62 + setTopologyMetadataLabelIfMissing(device.Labels, "software_version", value)
63 + }
64 + if value := topologyMetadataValue(device.Labels, topologyMetadataAliasFirmware); value != "" {
65 + device.FirmwareVersion = value
66 + setTopologyMetadataLabelIfMissing(device.Labels, "firmware_version", value)
67 + }
68 + if value := topologyMetadataValue(device.Labels, topologyMetadataAliasHardware); value != "" {
69 + device.HardwareVersion = value
70 + setTopologyMetadataLabelIfMissing(device.Labels, "hardware_version", value)
71 + }
72 +
73 + return device
74 +}
75 +
76 +func (c *topologyCache) snapshot() (topologyData, bool) {
77 + if !c.hasFreshSnapshotAt(time.Now()) {
78 + return topologyData{}, false
79 + }
80 +
81 + local := c.localDevice
82 + local = normalizeTopologyDevice(local)
83 +
84 + observations, localDeviceID := c.buildEngineObservations(local)
85 + if len(observations) == 0 {
86 + return topologyData{}, false
87 + }
88 +
89 + result, err := topologyengine.BuildL2ResultFromObservations(observations, topologyengine.DiscoverOptions{
90 + EnableLLDP: true,
91 + EnableCDP: true,
92 + EnableBridge: true,
93 + EnableARP: true,
94 + })
95 + if err != nil {
96 + return topologyData{}, false
97 + }
98 +
99 + data := topologyengine.ToTopologyData(result, topologyengine.TopologyDataOptions{
100 + SchemaVersion: topologySchemaVersion,
101 + Source: "snmp",
102 + Layer: "2",
103 + View: "summary",
104 + AgentID: c.agentID,
105 + LocalDeviceID: localDeviceID,
106 + CollectedAt: c.lastUpdate,
107 + ResolveDNSName: resolveTopologyReverseDNSName,
108 + })
109 +
110 + augmentLocalActorFromCache(&data, local)
111 + return data, true
112 +}
src/go/plugin/go.d/collector/snmp_topology/topology_snmp_hex.go new
+57
@@ -0,0 +1,57 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "encoding/hex"
7 + "fmt"
8 + "strings"
9 +)
10 +
11 +func canonicalSNMPEnumValue(value string) string {
12 + value = strings.ToLower(strings.TrimSpace(value))
13 + if value == "" {
14 + return ""
15 + }
16 + if open := strings.IndexByte(value, '('); open > 0 && strings.HasSuffix(value, ")") {
17 + value = strings.TrimSpace(value[:open])
18 + }
19 + return value
20 +}
21 +
22 +func decodeHexString(value string) ([]byte, error) {
23 + clean := strings.TrimPrefix(strings.ToLower(normalizeSNMPHexText(value)), "0x")
24 + clean = strings.NewReplacer(":", "", "-", "", ".", "", " ", "").Replace(clean)
25 + if clean == "" {
26 + return nil, fmt.Errorf("empty hex string")
27 + }
28 + if len(clean)%2 == 1 {
29 + clean = "0" + clean
30 + }
31 + return hex.DecodeString(clean)
32 +}
33 +
34 +func normalizeSNMPHexText(value string) string {
35 + value = strings.TrimSpace(value)
36 + if value == "" {
37 + return ""
38 + }
39 + trimQuotes := func(v string) string {
40 + return strings.TrimSpace(strings.Trim(v, "\"'"))
41 + }
42 + value = trimQuotes(value)
43 + lower := strings.ToLower(value)
44 + for _, prefix := range []string{
45 + "hex-string:",
46 + "hex string:",
47 + "octet-string:",
48 + "octet string:",
49 + "string:",
50 + } {
51 + if strings.HasPrefix(lower, prefix) {
52 + value = trimQuotes(value[len(prefix):])
53 + lower = strings.ToLower(value)
54 + }
55 + }
56 + return value
57 +}
src/go/plugin/go.d/collector/snmp_topology/topology_snmp_value_helpers.go new
+38
@@ -0,0 +1,38 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 +)
9 +
10 +func parsePositiveInt64(value string) int64 {
11 + value = strings.TrimSpace(value)
12 + if value == "" {
13 + return 0
14 + }
15 + parsed, err := strconv.ParseInt(value, 10, 64)
16 + if err != nil || parsed <= 0 {
17 + return 0
18 + }
19 + return parsed
20 +}
21 +
22 +func parseIndex(value string) int {
23 + if value == "" {
24 + return 0
25 + }
26 + v, err := strconv.Atoi(value)
27 + if err != nil {
28 + return 0
29 + }
30 + return v
31 +}
32 +
33 +func maxInt(a, b int) int {
34 + if a > b {
35 + return a
36 + }
37 + return b
38 +}
src/go/plugin/go.d/collector/snmp_topology/topology_snmp_value_helpers_test.go new
+24
@@ -0,0 +1,24 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/require"
9 +)
10 +
11 +func TestNormalizeSNMPHexText_StripsPrefixesAndQuotes(t *testing.T) {
12 + require.Equal(t, "00 11 22 33", normalizeSNMPHexText(`"hex-string: 00 11 22 33"`))
13 + require.Equal(t, "0A14043C", normalizeSNMPHexText("octet string: 0A14043C"))
14 + require.Equal(t, "abc", normalizeSNMPHexText(`'string: abc'`))
15 +}
16 +
17 +func TestDecodeLLDPCapabilities_AndInferCategory(t *testing.T) {
18 + require.Equal(t,
19 + []string{"bridge", "router"},
20 + decodeLLDPCapabilities("28"),
21 + )
22 + require.Equal(t, "router", inferCategoryFromCapabilities([]string{"bridge", "router"}))
23 + require.Equal(t, "access point", inferCategoryFromCapabilities([]string{"wlanAccessPoint"}))
24 +}
src/go/plugin/go.d/collector/snmp_topology/topology_snmprec_forwarding_test.go new
+561
@@ -0,0 +1,561 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +//go:build topology_fixtures
4 +
5 +package snmptopology
6 +
7 +import (
8 + "bufio"
9 + "fmt"
10 + "os"
11 + "path/filepath"
12 + "strconv"
13 + "strings"
14 + "testing"
15 +
16 + topologyengine "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
17 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
18 + "github.com/stretchr/testify/require"
19 +)
20 +
21 +type snmprecForwardingFixture struct {
22 + bridgeMetadata map[string]ddsnmp.MetaTag
23 + ifNameEntries map[string]map[string]string
24 + ifStatusEntries map[string]map[string]string
25 + ipIfEntries map[string]map[string]string
26 + bridgePorts map[string]map[string]string
27 + fdbEntries map[string]map[string]string
28 + qBridgeFdb map[string]map[string]string
29 + qBridgeVLANs map[string]map[string]string
30 + stpPorts map[string]map[string]string
31 + vtpVLANs map[string]map[string]string
32 + arpEntries map[string]map[string]string
33 + arpIPs map[string]struct{}
34 + vtpVLANNames map[string]struct{}
35 +}
36 +
37 +func TestTopologyCache_RealSnmprecForwardingFixtures(t *testing.T) {
38 + tests := []struct {
39 + name string
40 + fixture string
41 + wantARP bool
42 + wantSTP bool
43 + wantDot1qFDB bool
44 + wantVTPVLANMap bool
45 + }{
46 + {
47 + name: "ArubaCX",
48 + fixture: "arubaos-cx_10.10.snmprec",
49 + wantARP: true,
50 + wantSTP: true,
51 + },
52 + {
53 + name: "CiscoSmallBusiness",
54 + fixture: "ciscosb_sg350x-24p.snmprec",
55 + wantDot1qFDB: true,
56 + wantSTP: true,
57 + },
58 + {
59 + name: "IOSXE",
60 + fixture: "iosxe_ie32008t2s-ios17-12.snmprec",
61 + wantARP: true,
62 + wantSTP: true,
63 + wantVTPVLANMap: true,
64 + },
65 + }
66 +
67 + for _, tt := range tests {
68 + tt := tt
69 + t.Run(tt.name, func(t *testing.T) {
70 + data := parseSnmprecForwardingFixture(t, filepath.Join("../../../../testdata/snmp/snmprec", tt.fixture))
71 +
72 + require.NotEmpty(t, data.ifNameEntries, "fixture %q should expose ifName data", tt.fixture)
73 + require.NotEmpty(t, data.bridgePorts, "fixture %q should expose bridge port mappings", tt.fixture)
74 + require.True(t, len(data.fdbEntries) > 0 || len(data.qBridgeFdb) > 0, "fixture %q should expose FDB data", tt.fixture)
75 +
76 + coll := replaySnmprecForwardingFixture(t, tt.fixture, data)
77 + obs := coll.topologyCache.buildEngineObservation(coll.topologyCache.localDevice)
78 +
79 + require.NotEmpty(t, obs.Interfaces, "expected observed interfaces from fixture %q", tt.fixture)
80 + require.NotEmpty(t, obs.BridgePorts, "expected observed bridge ports from fixture %q", tt.fixture)
81 + require.NotEmpty(t, obs.FDBEntries, "expected observed FDB entries from fixture %q", tt.fixture)
82 +
83 + if tt.wantDot1qFDB {
84 + require.True(t, observedFDBHasVLANID(obs), "expected VLAN-aware FDB entries from fixture %q", tt.fixture)
85 + }
86 + if tt.wantARP {
87 + require.NotEmpty(t, obs.ARPNDEntries, "expected ARP/ND entries from fixture %q", tt.fixture)
88 + require.True(t, observedARPContainsIP(obs, data.arpIPs), "expected ARP IP from fixture %q", tt.fixture)
89 + }
90 + if tt.wantSTP {
91 + require.NotEmpty(t, obs.STPPorts, "expected STP ports from fixture %q", tt.fixture)
92 + require.True(t, observedSTPHasInterface(obs), "expected STP port/interface correlation from fixture %q", tt.fixture)
93 + }
94 + if tt.wantVTPVLANMap {
95 + require.NotEmpty(t, data.vtpVLANs, "fixture %q should expose VTP VLAN data", tt.fixture)
96 + require.True(t, cacheContainsAnyVLANName(coll.topologyCache.vlanIDToName, data.vtpVLANNames), "expected VTP VLAN names from fixture %q", tt.fixture)
97 + }
98 + })
99 + }
100 +}
101 +
102 +func replaySnmprecForwardingFixture(t *testing.T, fixture string, data snmprecForwardingFixture) *Collector {
103 + t.Helper()
104 +
105 + coll := newTestCollector(ddsnmp.DeviceConnectionInfo{
106 + Hostname: "192.0.2.10",
107 + SysObjectID: "1.3.6.1.4.1.9.1.1",
108 + SysName: fixture,
109 + })
110 +
111 + if len(data.bridgeMetadata) > 0 {
112 + coll.updateTopologyProfileTags([]*ddsnmp.ProfileMetrics{{DeviceMetadata: data.bridgeMetadata}})
113 + }
114 + for _, tags := range data.ifNameEntries {
115 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricTopologyIfNameEntry, Tags: tags})
116 + }
117 + for _, tags := range data.ifStatusEntries {
118 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricTopologyIfStatusEntry, Tags: tags})
119 + }
120 + for _, tags := range data.ipIfEntries {
121 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricTopologyIPIfEntry, Tags: tags})
122 + }
123 + for _, tags := range data.bridgePorts {
124 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricBridgePortMapEntry, Tags: tags})
125 + }
126 + for _, tags := range data.qBridgeVLANs {
127 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricDot1qVlanEntry, Tags: tags})
128 + }
129 + for _, tags := range data.vtpVLANs {
130 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricVtpVlanEntry, Tags: tags})
131 + }
132 + for _, tags := range data.fdbEntries {
133 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricFdbEntry, Tags: tags})
134 + }
135 + for _, tags := range data.qBridgeFdb {
136 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricDot1qFdbEntry, Tags: tags})
137 + }
138 + for _, tags := range data.stpPorts {
139 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricStpPortEntry, Tags: tags})
140 + }
141 + for _, tags := range data.arpEntries {
142 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricArpEntry, Tags: tags})
143 + }
144 +
145 + return coll
146 +}
147 +
148 +func parseSnmprecForwardingFixture(t *testing.T, path string) snmprecForwardingFixture {
149 + t.Helper()
150 +
151 + file, err := os.Open(path)
152 + require.NoError(t, err)
153 + defer file.Close()
154 +
155 + data := snmprecForwardingFixture{
156 + bridgeMetadata: make(map[string]ddsnmp.MetaTag),
157 + ifNameEntries: make(map[string]map[string]string),
158 + ifStatusEntries: make(map[string]map[string]string),
159 + ipIfEntries: make(map[string]map[string]string),
160 + bridgePorts: make(map[string]map[string]string),
161 + fdbEntries: make(map[string]map[string]string),
162 + qBridgeFdb: make(map[string]map[string]string),
163 + qBridgeVLANs: make(map[string]map[string]string),
164 + stpPorts: make(map[string]map[string]string),
165 + vtpVLANs: make(map[string]map[string]string),
166 + arpEntries: make(map[string]map[string]string),
167 + arpIPs: make(map[string]struct{}),
168 + vtpVLANNames: make(map[string]struct{}),
169 + }
170 +
171 + scanner := bufio.NewScanner(file)
172 + buf := make([]byte, 0, 1024*1024)
173 + scanner.Buffer(buf, 2*1024*1024)
174 +
175 + for scanner.Scan() {
176 + line := strings.TrimSpace(scanner.Text())
177 + if line == "" || strings.HasPrefix(line, "#") {
178 + continue
179 + }
180 + parts := strings.SplitN(line, "|", 3)
181 + if len(parts) != 3 {
182 + continue
183 + }
184 +
185 + oid := parts[0]
186 + typ := parts[1]
187 + val := parts[2]
188 + if strings.HasSuffix(typ, "x") {
189 + val = strings.ToLower(val)
190 + }
191 +
192 + switch oid {
193 + case "1.3.6.1.2.1.17.1.1.0":
194 + data.bridgeMetadata[tagBridgeBaseAddress] = ddsnmp.MetaTag{Value: val}
195 + continue
196 + }
197 +
198 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.31.1.1.1.1"); ok {
199 + entry := ensureTagMap(data.ifNameEntries, ifIndex)
200 + entry[tagTopoIfIndex] = ifIndex
201 + entry[tagTopoIfName] = val
202 + continue
203 + }
204 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.31.1.1.1.18"); ok {
205 + entry := ensureTagMap(data.ifNameEntries, ifIndex)
206 + entry[tagTopoIfIndex] = ifIndex
207 + entry[tagTopoIfAlias] = val
208 + continue
209 + }
210 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.31.1.1.1.15"); ok {
211 + entry := ensureTagMap(data.ifNameEntries, ifIndex)
212 + entry[tagTopoIfIndex] = ifIndex
213 + entry[tagTopoIfHigh] = val
214 + continue
215 + }
216 +
217 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.2.2.1.2"); ok {
218 + entry := ensureTagMap(data.ifStatusEntries, ifIndex)
219 + entry[tagTopoIfIndex] = ifIndex
220 + entry[tagTopoIfDescr] = val
221 + continue
222 + }
223 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.2.2.1.3"); ok {
224 + entry := ensureTagMap(data.ifStatusEntries, ifIndex)
225 + entry[tagTopoIfIndex] = ifIndex
226 + entry[tagTopoIfType] = val
227 + continue
228 + }
229 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.2.2.1.5"); ok {
230 + entry := ensureTagMap(data.ifStatusEntries, ifIndex)
231 + entry[tagTopoIfIndex] = ifIndex
232 + entry[tagTopoIfSpeed] = val
233 + continue
234 + }
235 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.2.2.1.6"); ok {
236 + entry := ensureTagMap(data.ifStatusEntries, ifIndex)
237 + entry[tagTopoIfIndex] = ifIndex
238 + entry[tagTopoIfPhys] = val
239 + continue
240 + }
241 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.2.2.1.7"); ok {
242 + entry := ensureTagMap(data.ifStatusEntries, ifIndex)
243 + entry[tagTopoIfIndex] = ifIndex
244 + entry[tagTopoIfAdmin] = val
245 + continue
246 + }
247 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.2.2.1.8"); ok {
248 + entry := ensureTagMap(data.ifStatusEntries, ifIndex)
249 + entry[tagTopoIfIndex] = ifIndex
250 + entry[tagTopoIfOper] = val
251 + continue
252 + }
253 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.2.2.1.9"); ok {
254 + entry := ensureTagMap(data.ifStatusEntries, ifIndex)
255 + entry[tagTopoIfIndex] = ifIndex
256 + entry[tagTopoIfLast] = val
257 + continue
258 + }
259 +
260 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.4.20.1.1"); ok {
261 + entry := ensureTagMap(data.ipIfEntries, suffix)
262 + entry[tagTopoIPAddr] = val
263 + continue
264 + }
265 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.4.20.1.2"); ok {
266 + entry := ensureTagMap(data.ipIfEntries, suffix)
267 + entry[tagTopoIfIndex] = val
268 + continue
269 + }
270 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.4.20.1.3"); ok {
271 + entry := ensureTagMap(data.ipIfEntries, suffix)
272 + entry[tagTopoIPMask] = val
273 + continue
274 + }
275 +
276 + if basePort, ok := parseOIDIndex(oid, "1.3.6.1.2.1.17.1.4.1.2"); ok {
277 + entry := ensureTagMap(data.bridgePorts, basePort)
278 + entry[tagBridgeBasePort] = basePort
279 + entry[tagBridgeIfIndex] = val
280 + continue
281 + }
282 +
283 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.17.4.3.1.1"); ok {
284 + entry := ensureTagMap(data.fdbEntries, suffix)
285 + entry[tagFdbMac] = val
286 + continue
287 + }
288 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.17.4.3.1.2"); ok {
289 + entry := ensureTagMap(data.fdbEntries, suffix)
290 + if entry[tagFdbMac] == "" {
291 + entry[tagFdbMac] = macFromOIDIndexSuffix(strings.Split(suffix, "."))
292 + }
293 + entry[tagFdbBridgePort] = val
294 + continue
295 + }
296 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.17.4.3.1.3"); ok {
297 + entry := ensureTagMap(data.fdbEntries, suffix)
298 + if entry[tagFdbMac] == "" {
299 + entry[tagFdbMac] = macFromOIDIndexSuffix(strings.Split(suffix, "."))
300 + }
301 + entry[tagFdbStatus] = val
302 + continue
303 + }
304 +
305 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.17.7.1.2.2.1.1"); ok {
306 + entry := ensureTagMap(data.qBridgeFdb, suffix)
307 + parts := strings.Split(suffix, ".")
308 + if len(parts) >= 7 {
309 + entry[tagDot1qFdbID] = parts[0]
310 + }
311 + entry[tagDot1qFdbMac] = val
312 + continue
313 + }
314 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.17.7.1.2.2.1.2"); ok {
315 + entry := ensureTagMap(data.qBridgeFdb, suffix)
316 + parts := strings.Split(suffix, ".")
317 + if len(parts) >= 7 {
318 + entry[tagDot1qFdbID] = parts[0]
319 + if entry[tagDot1qFdbMac] == "" {
320 + entry[tagDot1qFdbMac] = macFromOIDIndexSuffix(parts[1:])
321 + }
322 + }
323 + entry[tagDot1qFdbPort] = val
324 + continue
325 + }
326 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.17.7.1.2.2.1.3"); ok {
327 + entry := ensureTagMap(data.qBridgeFdb, suffix)
328 + parts := strings.Split(suffix, ".")
329 + if len(parts) >= 7 {
330 + entry[tagDot1qFdbID] = parts[0]
331 + if entry[tagDot1qFdbMac] == "" {
332 + entry[tagDot1qFdbMac] = macFromOIDIndexSuffix(parts[1:])
333 + }
334 + }
335 + entry[tagDot1qFdbStatus] = val
336 + continue
337 + }
338 +
339 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.17.7.1.4.2.1.3"); ok {
340 + entry := ensureTagMap(data.qBridgeVLANs, suffix)
341 + parts := strings.Split(suffix, ".")
342 + switch len(parts) {
343 + case 1:
344 + entry[tagDot1qVlanID] = parts[0]
345 + entry[tagDot1qVlanID1] = parts[0]
346 + default:
347 + entry[tagDot1qVlanID1] = parts[0]
348 + entry[tagDot1qVlanID] = parts[1]
349 + }
350 + entry[tagDot1qVlanFdbID] = val
351 + continue
352 + }
353 +
354 + if stpPort, ok := parseOIDIndex(oid, "1.3.6.1.2.1.17.2.15.1.2"); ok {
355 + entry := ensureTagMap(data.stpPorts, stpPort)
356 + entry[tagStpPort] = stpPort
357 + entry[tagStpPortPriority] = val
358 + continue
359 + }
360 + if stpPort, ok := parseOIDIndex(oid, "1.3.6.1.2.1.17.2.15.1.3"); ok {
361 + entry := ensureTagMap(data.stpPorts, stpPort)
362 + entry[tagStpPort] = stpPort
363 + entry[tagStpPortState] = val
364 + continue
365 + }
366 + if stpPort, ok := parseOIDIndex(oid, "1.3.6.1.2.1.17.2.15.1.4"); ok {
367 + entry := ensureTagMap(data.stpPorts, stpPort)
368 + entry[tagStpPort] = stpPort
369 + entry[tagStpPortEnable] = val
370 + continue
371 + }
372 + if stpPort, ok := parseOIDIndex(oid, "1.3.6.1.2.1.17.2.15.1.5"); ok {
373 + entry := ensureTagMap(data.stpPorts, stpPort)
374 + entry[tagStpPort] = stpPort
375 + entry[tagStpPortPathCost] = val
376 + continue
377 + }
378 + if stpPort, ok := parseOIDIndex(oid, "1.3.6.1.2.1.17.2.15.1.6"); ok {
379 + entry := ensureTagMap(data.stpPorts, stpPort)
380 + entry[tagStpPort] = stpPort
381 + entry[tagStpPortDesignatedRoot] = val
382 + continue
383 + }
384 + if stpPort, ok := parseOIDIndex(oid, "1.3.6.1.2.1.17.2.15.1.7"); ok {
385 + entry := ensureTagMap(data.stpPorts, stpPort)
386 + entry[tagStpPort] = stpPort
387 + entry[tagStpPortDesignatedCost] = val
388 + continue
389 + }
390 + if stpPort, ok := parseOIDIndex(oid, "1.3.6.1.2.1.17.2.15.1.8"); ok {
391 + entry := ensureTagMap(data.stpPorts, stpPort)
392 + entry[tagStpPort] = stpPort
393 + entry[tagStpPortDesignatedBridge] = val
394 + continue
395 + }
396 + if stpPort, ok := parseOIDIndex(oid, "1.3.6.1.2.1.17.2.15.1.9"); ok {
397 + entry := ensureTagMap(data.stpPorts, stpPort)
398 + entry[tagStpPort] = stpPort
399 + entry[tagStpPortDesignatedPort] = val
400 + continue
401 + }
402 +
403 + if vlanID, ok := parseOIDIndex(oid, "1.3.6.1.4.1.9.9.46.1.3.1.1.2"); ok {
404 + entry := ensureTagMap(data.vtpVLANs, vlanID)
405 + entry[tagVtpVlanIndex] = vlanID
406 + entry[tagVtpVlanState] = val
407 + continue
408 + }
409 + if vlanID, ok := parseOIDIndex(oid, "1.3.6.1.4.1.9.9.46.1.3.1.1.3"); ok {
410 + entry := ensureTagMap(data.vtpVLANs, vlanID)
411 + entry[tagVtpVlanIndex] = vlanID
412 + entry[tagVtpVlanType] = val
413 + continue
414 + }
415 + if vlanID, ok := parseOIDIndex(oid, "1.3.6.1.4.1.9.9.46.1.3.1.1.4"); ok {
416 + entry := ensureTagMap(data.vtpVLANs, vlanID)
417 + entry[tagVtpVlanIndex] = vlanID
418 + entry[tagVtpVlanName] = val
419 + if val != "" {
420 + data.vtpVLANNames[val] = struct{}{}
421 + }
422 + continue
423 + }
424 +
425 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.4.35.1.1"); ok {
426 + entry := ensureTagMap(data.arpEntries, suffix)
427 + entry[tagArpIfIndex] = val
428 + continue
429 + }
430 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.4.35.1.2"); ok {
431 + entry := ensureTagMap(data.arpEntries, suffix)
432 + entry[tagArpAddrType] = val
433 + continue
434 + }
435 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.4.35.1.3"); ok {
436 + entry := ensureTagMap(data.arpEntries, suffix)
437 + entry[tagArpIP] = val
438 + if val != "" {
439 + data.arpIPs[val] = struct{}{}
440 + }
441 + continue
442 + }
443 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.4.35.1.4"); ok {
444 + entry := ensureTagMap(data.arpEntries, suffix)
445 + if entry[tagArpIfIndex] == "" || entry[tagArpAddrType] == "" || entry[tagArpIP] == "" {
446 + ifIndex, addrType, ip := arpModernIndexFields(strings.Split(suffix, "."))
447 + if entry[tagArpIfIndex] == "" {
448 + entry[tagArpIfIndex] = ifIndex
449 + }
450 + if entry[tagArpAddrType] == "" {
451 + entry[tagArpAddrType] = addrType
452 + }
453 + if entry[tagArpIP] == "" {
454 + entry[tagArpIP] = ip
455 + }
456 + if ip != "" {
457 + data.arpIPs[ip] = struct{}{}
458 + }
459 + }
460 + entry[tagArpMac] = val
461 + continue
462 + }
463 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.4.35.1.6"); ok {
464 + entry := ensureTagMap(data.arpEntries, suffix)
465 + entry[tagArpState] = val
466 + continue
467 + }
468 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.4.22.1.2"); ok {
469 + entry := ensureTagMap(data.arpEntries, suffix)
470 + if entry[tagArpIfIndex] == "" || entry[tagArpIP] == "" {
471 + ifIndex, ip := arpLegacyIndexFields(strings.Split(suffix, "."))
472 + if entry[tagArpIfIndex] == "" {
473 + entry[tagArpIfIndex] = ifIndex
474 + }
475 + if entry[tagArpIP] == "" {
476 + entry[tagArpIP] = ip
477 + }
478 + if ip != "" {
479 + data.arpIPs[ip] = struct{}{}
480 + }
481 + }
482 + entry[tagArpMac] = val
483 + continue
484 + }
485 + if suffix, ok := parseOIDSuffix(oid, "1.3.6.1.2.1.4.22.1.4"); ok {
486 + entry := ensureTagMap(data.arpEntries, suffix)
487 + entry[tagArpType] = val
488 + continue
489 + }
490 + }
491 +
492 + require.NoError(t, scanner.Err())
493 + return data
494 +}
495 +
496 +func observedFDBHasVLANID(obs topologyengine.L2Observation) bool {
497 + for _, entry := range obs.FDBEntries {
498 + if strings.TrimSpace(entry.VLANID) != "" {
499 + return true
500 + }
501 + }
502 + return false
503 +}
504 +
505 +func observedARPContainsIP(obs topologyengine.L2Observation, ips map[string]struct{}) bool {
506 + for _, entry := range obs.ARPNDEntries {
507 + if _, ok := ips[strings.TrimSpace(entry.IP)]; ok {
508 + return true
509 + }
510 + }
511 + return false
512 +}
513 +
514 +func observedSTPHasInterface(obs topologyengine.L2Observation) bool {
515 + for _, entry := range obs.STPPorts {
516 + if entry.IfIndex > 0 || strings.TrimSpace(entry.IfName) != "" {
517 + return true
518 + }
519 + }
520 + return false
521 +}
522 +
523 +func cacheContainsAnyVLANName(names map[string]string, expected map[string]struct{}) bool {
524 + for _, name := range names {
525 + if _, ok := expected[strings.TrimSpace(name)]; ok {
526 + return true
527 + }
528 + }
529 + return false
530 +}
531 +
532 +func arpModernIndexFields(parts []string) (ifIndex, addrType, ip string) {
533 + if len(parts) < 4 {
534 + return "", "", ""
535 + }
536 + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), strings.Join(parts[3:], ".")
537 +}
538 +
539 +func arpLegacyIndexFields(parts []string) (ifIndex, ip string) {
540 + if len(parts) < 2 {
541 + return "", ""
542 + }
543 + return strings.TrimSpace(parts[0]), strings.Join(parts[1:], ".")
544 +}
545 +
546 +func macFromOIDIndexSuffix(parts []string) string {
547 + if len(parts) < 6 {
548 + return ""
549 + }
550 +
551 + parts = parts[len(parts)-6:]
552 + octets := make([]string, 0, len(parts))
553 + for _, part := range parts {
554 + value, err := strconv.Atoi(strings.TrimSpace(part))
555 + if err != nil || value < 0 || value > 255 {
556 + return ""
557 + }
558 + octets = append(octets, fmt.Sprintf("%02x", value))
559 + }
560 + return strings.Join(octets, ":")
561 +}
src/go/plugin/go.d/collector/snmp_topology/topology_snmprec_test.go new
+763
@@ -0,0 +1,763 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +//go:build topology_fixtures
4 +
5 +package snmptopology
6 +
7 +import (
8 + "bufio"
9 + "os"
10 + "path/filepath"
11 + "strings"
12 + "testing"
13 +
14 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
15 + "github.com/stretchr/testify/require"
16 +)
17 +
18 +type snmprecTopology struct {
19 + lldpLocalMeta map[string]ddsnmp.MetaTag
20 + lldpLocPorts map[string]map[string]string
21 + lldpLocManAddrs map[string]map[string]string
22 + lldpRemotes map[string]map[string]string
23 + lldpRemManAddrs map[string]map[string]string
24 + cdpRemotes map[string]map[string]string
25 + ifNames map[string]string
26 + lldpSysNames map[string]struct{}
27 + lldpMgmtAddrs map[string]struct{}
28 + cdpDeviceIDs map[string]struct{}
29 + cdpSysNames map[string]struct{}
30 + cdpMgmtAddrs map[string]struct{}
31 +}
32 +
33 +func TestTopologyCache_RealSnmprecFixtures(t *testing.T) {
34 + files, err := filepath.Glob("../../../../testdata/snmp/snmprec/*.snmprec")
35 + require.NoError(t, err)
36 + require.NotEmpty(t, files)
37 +
38 + for _, path := range files {
39 + path := path
40 + t.Run(filepath.Base(path), func(t *testing.T) {
41 + data := parseSnmprecTopology(t, path)
42 + if len(data.lldpRemotes) == 0 && len(data.cdpRemotes) == 0 {
43 + t.Skip("no LLDP/CDP data detected")
44 + }
45 +
46 + coll := newTestCollector(ddsnmp.DeviceConnectionInfo{
47 + Hostname: "192.0.2.10", SysObjectID: "1.3.6.1.4.1.9.1.1", SysName: filepath.Base(path),
48 + })
49 +
50 + if len(data.lldpLocalMeta) > 0 {
51 + coll.updateTopologyProfileTags([]*ddsnmp.ProfileMetrics{{DeviceMetadata: data.lldpLocalMeta}})
52 + }
53 + for _, tags := range data.lldpLocPorts {
54 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricLldpLocPortEntry, Tags: tags})
55 + }
56 + for _, tags := range data.lldpLocManAddrs {
57 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricLldpLocManAddrEntry, Tags: tags})
58 + }
59 + for _, tags := range data.lldpRemotes {
60 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricLldpRemEntry, Tags: tags})
61 + }
62 + for _, tags := range data.lldpRemManAddrs {
63 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricLldpRemManAddrEntry, Tags: tags})
64 + }
65 + for _, tags := range data.cdpRemotes {
66 + coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricCdpCacheEntry, Tags: tags})
67 + }
68 + coll.finalizeTopologyCache()
69 +
70 + coll.topologyCache.mu.RLock()
71 + snapshot, ok := coll.topologyCache.snapshot()
72 + coll.topologyCache.mu.RUnlock()
73 +
74 + require.True(t, ok)
75 + require.GreaterOrEqual(t, len(snapshot.Actors), 1)
76 + if hasLinkableLLDP(data) || hasLinkableCDP(data) {
77 + require.Greater(t, len(snapshot.Links), 0)
78 + }
79 + require.NotEmpty(t, snapshot.Actors[0].Match.ChassisIDs)
80 +
81 + if len(data.lldpRemotes) > 0 || len(data.lldpRemManAddrs) > 0 {
82 + if hasLinkableLLDP(data) {
83 + require.True(t, hasProtocolLink(snapshot, "lldp"), "expected LLDP links")
84 + }
85 + if len(data.lldpSysNames) > 0 && hasLinkableLLDP(data) {
86 + require.True(t, containsSysName(snapshot, data.lldpSysNames), "expected LLDP sysName from snmprec data")
87 + }
88 + }
89 + if len(data.cdpRemotes) > 0 {
90 + if hasLinkableCDP(data) {
91 + require.True(t, hasProtocolLink(snapshot, "cdp"), "expected CDP links")
92 + }
93 + if len(data.cdpDeviceIDs) > 0 {
94 + require.True(t, containsIdentifier(snapshot, data.cdpDeviceIDs), "expected CDP device ID from snmprec data")
95 + }
96 + if len(data.cdpSysNames) > 0 && hasLinkableCDP(data) {
97 + require.True(t, containsSysName(snapshot, data.cdpSysNames), "expected CDP sysName from snmprec data")
98 + }
99 + }
100 + if len(data.lldpMgmtAddrs) > 0 {
101 + require.True(t, containsMgmtAddr(snapshot, data.lldpMgmtAddrs), "expected LLDP management address from snmprec data")
102 + }
103 + if len(data.cdpMgmtAddrs) > 0 {
104 + require.True(t, containsMgmtAddr(snapshot, data.cdpMgmtAddrs), "expected CDP management address from snmprec data")
105 + }
106 + })
107 + }
108 +}
109 +
110 +func parseSnmprecTopology(t *testing.T, path string) snmprecTopology {
111 + t.Helper()
112 +
113 + file, err := os.Open(path)
114 + require.NoError(t, err)
115 + defer file.Close()
116 +
117 + data := snmprecTopology{
118 + lldpLocalMeta: make(map[string]ddsnmp.MetaTag),
119 + lldpLocPorts: make(map[string]map[string]string),
120 + lldpLocManAddrs: make(map[string]map[string]string),
121 + lldpRemotes: make(map[string]map[string]string),
122 + lldpRemManAddrs: make(map[string]map[string]string),
123 + cdpRemotes: make(map[string]map[string]string),
124 + ifNames: make(map[string]string),
125 + lldpSysNames: make(map[string]struct{}),
126 + lldpMgmtAddrs: make(map[string]struct{}),
127 + cdpDeviceIDs: make(map[string]struct{}),
128 + cdpSysNames: make(map[string]struct{}),
129 + cdpMgmtAddrs: make(map[string]struct{}),
130 + }
131 +
132 + scanner := bufio.NewScanner(file)
133 + buf := make([]byte, 0, 1024*1024)
134 + scanner.Buffer(buf, 2*1024*1024)
135 +
136 + for scanner.Scan() {
137 + line := strings.TrimSpace(scanner.Text())
138 + if line == "" || strings.HasPrefix(line, "#") {
139 + continue
140 + }
141 + parts := strings.SplitN(line, "|", 3)
142 + if len(parts) != 3 {
143 + continue
144 + }
145 + oid := parts[0]
146 + typ := parts[1]
147 + val := parts[2]
148 + if strings.HasSuffix(typ, "x") {
149 + val = strings.ToLower(val)
150 + }
151 +
152 + switch oid {
153 + case "1.0.8802.1.1.2.1.3.1.0":
154 + data.lldpLocalMeta[tagLldpLocChassisIDSubtype] = ddsnmp.MetaTag{Value: val}
155 + continue
156 + case "1.0.8802.1.1.2.1.3.2.0":
157 + data.lldpLocalMeta[tagLldpLocChassisID] = ddsnmp.MetaTag{Value: val}
158 + continue
159 + case "1.0.8802.1.1.2.1.3.3.0":
160 + data.lldpLocalMeta[tagLldpLocSysName] = ddsnmp.MetaTag{Value: val}
161 + continue
162 + case "1.0.8802.1.1.2.1.3.4.0":
163 + data.lldpLocalMeta[tagLldpLocSysDesc] = ddsnmp.MetaTag{Value: val}
164 + continue
165 + case "1.0.8802.1.1.2.1.3.5.0":
166 + data.lldpLocalMeta[tagLldpLocSysCapSupported] = ddsnmp.MetaTag{Value: val}
167 + continue
168 + case "1.0.8802.1.1.2.1.3.6.0":
169 + data.lldpLocalMeta[tagLldpLocSysCapEnabled] = ddsnmp.MetaTag{Value: val}
170 + continue
171 + }
172 +
173 + if portNum, ok := parseOIDIndex(oid, "1.0.8802.1.1.2.1.3.7.1.2"); ok {
174 + entry := ensureTagMap(data.lldpLocPorts, portNum)
175 + entry[tagLldpLocPortNum] = portNum
176 + entry[tagLldpLocPortIDSubtype] = val
177 + continue
178 + }
179 + if portNum, ok := parseOIDIndex(oid, "1.0.8802.1.1.2.1.3.7.1.3"); ok {
180 + entry := ensureTagMap(data.lldpLocPorts, portNum)
181 + entry[tagLldpLocPortNum] = portNum
182 + entry[tagLldpLocPortID] = val
183 + continue
184 + }
185 + if portNum, ok := parseOIDIndex(oid, "1.0.8802.1.1.2.1.3.7.1.4"); ok {
186 + entry := ensureTagMap(data.lldpLocPorts, portNum)
187 + entry[tagLldpLocPortNum] = portNum
188 + entry[tagLldpLocPortDesc] = val
189 + continue
190 + }
191 + if suffix, ok := parseOIDSuffix(oid, "1.0.8802.1.1.2.1.3.8.1.1"); ok {
192 + entry := ensureTagMap(data.lldpLocManAddrs, suffix)
193 + entry[tagLldpLocMgmtAddrSubtype] = val
194 + continue
195 + }
196 + if suffix, ok := parseOIDSuffix(oid, "1.0.8802.1.1.2.1.3.8.1.2"); ok {
197 + entry := ensureTagMap(data.lldpLocManAddrs, suffix)
198 + entry[tagLldpLocMgmtAddr] = val
199 + if val != "" {
200 + data.lldpMgmtAddrs[val] = struct{}{}
201 + }
202 + continue
203 + }
204 + if suffix, ok := parseOIDSuffix(oid, "1.0.8802.1.1.2.1.3.8.1.3"); ok {
205 + entry := ensureTagMap(data.lldpLocManAddrs, suffix)
206 + entry[tagLldpLocMgmtAddrLen] = val
207 + continue
208 + }
209 + if suffix, ok := parseOIDSuffix(oid, "1.0.8802.1.1.2.1.3.8.1.4"); ok {
210 + entry := ensureTagMap(data.lldpLocManAddrs, suffix)
211 + entry[tagLldpLocMgmtAddrIfSubtype] = val
212 + continue
213 + }
214 + if suffix, ok := parseOIDSuffix(oid, "1.0.8802.1.1.2.1.3.8.1.5"); ok {
215 + entry := ensureTagMap(data.lldpLocManAddrs, suffix)
216 + entry[tagLldpLocMgmtAddrIfID] = val
217 + continue
218 + }
219 + if suffix, ok := parseOIDSuffix(oid, "1.0.8802.1.1.2.1.3.8.1.6"); ok {
220 + entry := ensureTagMap(data.lldpLocManAddrs, suffix)
221 + entry[tagLldpLocMgmtAddrOID] = val
222 + continue
223 + }
224 +
225 + if indexes, ok := parseOIDIndexes(oid, "1.0.8802.1.1.2.1.4.1.1.4", 3); ok {
226 + localPort := indexes[1]
227 + remIndex := indexes[2]
228 + entry := ensureTagMap(data.lldpRemotes, localPort+":"+remIndex)
229 + entry[tagLldpLocPortNum] = localPort
230 + entry[tagLldpRemIndex] = remIndex
231 + entry[tagLldpRemChassisIDSubtype] = val
232 + continue
233 + }
234 + if indexes, ok := parseOIDIndexes(oid, "1.0.8802.1.1.2.1.4.1.1.5", 3); ok {
235 + localPort := indexes[1]
236 + remIndex := indexes[2]
237 + entry := ensureTagMap(data.lldpRemotes, localPort+":"+remIndex)
238 + entry[tagLldpLocPortNum] = localPort
239 + entry[tagLldpRemIndex] = remIndex
240 + entry[tagLldpRemChassisID] = val
241 + continue
242 + }
243 + if indexes, ok := parseOIDIndexes(oid, "1.0.8802.1.1.2.1.4.1.1.6", 3); ok {
244 + localPort := indexes[1]
245 + remIndex := indexes[2]
246 + entry := ensureTagMap(data.lldpRemotes, localPort+":"+remIndex)
247 + entry[tagLldpLocPortNum] = localPort
248 + entry[tagLldpRemIndex] = remIndex
249 + entry[tagLldpRemPortIDSubtype] = val
250 + continue
251 + }
252 + if indexes, ok := parseOIDIndexes(oid, "1.0.8802.1.1.2.1.4.1.1.7", 3); ok {
253 + localPort := indexes[1]
254 + remIndex := indexes[2]
255 + entry := ensureTagMap(data.lldpRemotes, localPort+":"+remIndex)
256 + entry[tagLldpLocPortNum] = localPort
257 + entry[tagLldpRemIndex] = remIndex
258 + entry[tagLldpRemPortID] = val
259 + continue
260 + }
261 + if indexes, ok := parseOIDIndexes(oid, "1.0.8802.1.1.2.1.4.1.1.8", 3); ok {
262 + localPort := indexes[1]
263 + remIndex := indexes[2]
264 + entry := ensureTagMap(data.lldpRemotes, localPort+":"+remIndex)
265 + entry[tagLldpLocPortNum] = localPort
266 + entry[tagLldpRemIndex] = remIndex
267 + entry[tagLldpRemPortDesc] = val
268 + continue
269 + }
270 + if indexes, ok := parseOIDIndexes(oid, "1.0.8802.1.1.2.1.4.1.1.9", 3); ok {
271 + localPort := indexes[1]
272 + remIndex := indexes[2]
273 + entry := ensureTagMap(data.lldpRemotes, localPort+":"+remIndex)
274 + entry[tagLldpLocPortNum] = localPort
275 + entry[tagLldpRemIndex] = remIndex
276 + entry[tagLldpRemSysName] = val
277 + if val != "" {
278 + data.lldpSysNames[val] = struct{}{}
279 + }
280 + continue
281 + }
282 + if indexes, ok := parseOIDIndexes(oid, "1.0.8802.1.1.2.1.4.1.1.10", 3); ok {
283 + localPort := indexes[1]
284 + remIndex := indexes[2]
285 + entry := ensureTagMap(data.lldpRemotes, localPort+":"+remIndex)
286 + entry[tagLldpLocPortNum] = localPort
287 + entry[tagLldpRemIndex] = remIndex
288 + entry[tagLldpRemSysDesc] = val
289 + continue
290 + }
291 + if indexes, ok := parseOIDIndexes(oid, "1.0.8802.1.1.2.1.4.1.1.11", 3); ok {
292 + localPort := indexes[1]
293 + remIndex := indexes[2]
294 + entry := ensureTagMap(data.lldpRemotes, localPort+":"+remIndex)
295 + entry[tagLldpLocPortNum] = localPort
296 + entry[tagLldpRemIndex] = remIndex
297 + entry[tagLldpRemSysCapSupported] = val
298 + continue
299 + }
300 + if indexes, ok := parseOIDIndexes(oid, "1.0.8802.1.1.2.1.4.1.1.12", 3); ok {
301 + localPort := indexes[1]
302 + remIndex := indexes[2]
303 + entry := ensureTagMap(data.lldpRemotes, localPort+":"+remIndex)
304 + entry[tagLldpLocPortNum] = localPort
305 + entry[tagLldpRemIndex] = remIndex
306 + entry[tagLldpRemSysCapEnabled] = val
307 + continue
308 + }
309 +
310 + if indexes, ok := parseOIDLeadingIndexes(oid, "1.0.8802.1.1.2.1.4.2.1.1", 3); ok {
311 + suffix := strings.TrimPrefix(oid, "1.0.8802.1.1.2.1.4.2.1.1.")
312 + localPort := indexes[1]
313 + remIndex := indexes[2]
314 + entry := ensureTagMap(data.lldpRemManAddrs, localPort+":"+remIndex+":"+suffix)
315 + entry[tagLldpLocPortNum] = localPort
316 + entry[tagLldpRemIndex] = remIndex
317 + entry[tagLldpRemMgmtAddrSubtype] = val
318 + continue
319 + }
320 + if indexes, ok := parseOIDLeadingIndexes(oid, "1.0.8802.1.1.2.1.4.2.1.2", 3); ok {
321 + suffix := strings.TrimPrefix(oid, "1.0.8802.1.1.2.1.4.2.1.2.")
322 + localPort := indexes[1]
323 + remIndex := indexes[2]
324 + entry := ensureTagMap(data.lldpRemManAddrs, localPort+":"+remIndex+":"+suffix)
325 + entry[tagLldpLocPortNum] = localPort
326 + entry[tagLldpRemIndex] = remIndex
327 + entry[tagLldpRemMgmtAddr] = val
328 + if val != "" {
329 + data.lldpMgmtAddrs[val] = struct{}{}
330 + }
331 + continue
332 + }
333 + if indexes, ok := parseOIDLeadingIndexes(oid, "1.0.8802.1.1.2.1.4.2.1.3", 3); ok {
334 + suffix := strings.TrimPrefix(oid, "1.0.8802.1.1.2.1.4.2.1.3.")
335 + localPort := indexes[1]
336 + remIndex := indexes[2]
337 + entry := ensureTagMap(data.lldpRemManAddrs, localPort+":"+remIndex+":"+suffix)
338 + entry[tagLldpLocPortNum] = localPort
339 + entry[tagLldpRemIndex] = remIndex
340 + entry[tagLldpRemMgmtAddrIfSubtype] = val
341 + continue
342 + }
343 + if indexes, ok := parseOIDLeadingIndexes(oid, "1.0.8802.1.1.2.1.4.2.1.4", 3); ok {
344 + suffix := strings.TrimPrefix(oid, "1.0.8802.1.1.2.1.4.2.1.4.")
345 + localPort := indexes[1]
346 + remIndex := indexes[2]
347 + entry := ensureTagMap(data.lldpRemManAddrs, localPort+":"+remIndex+":"+suffix)
348 + entry[tagLldpLocPortNum] = localPort
349 + entry[tagLldpRemIndex] = remIndex
350 + entry[tagLldpRemMgmtAddrIfID] = val
351 + continue
352 + }
353 + if indexes, ok := parseOIDLeadingIndexes(oid, "1.0.8802.1.1.2.1.4.2.1.5", 3); ok {
354 + suffix := strings.TrimPrefix(oid, "1.0.8802.1.1.2.1.4.2.1.5.")
355 + localPort := indexes[1]
356 + remIndex := indexes[2]
357 + entry := ensureTagMap(data.lldpRemManAddrs, localPort+":"+remIndex+":"+suffix)
358 + entry[tagLldpLocPortNum] = localPort
359 + entry[tagLldpRemIndex] = remIndex
360 + entry[tagLldpRemMgmtAddrOID] = val
361 + continue
362 + }
363 +
364 + if ifIndex, ok := parseOIDIndex(oid, "1.3.6.1.2.1.31.1.1.1.1"); ok {
365 + data.ifNames[ifIndex] = val
366 + continue
367 + }
368 +
369 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.6", 2); ok {
370 + ifIndex := indexes[0]
371 + deviceIndex := indexes[1]
372 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
373 + entry[tagCdpIfIndex] = ifIndex
374 + entry[tagCdpDeviceIndex] = deviceIndex
375 + entry[tagCdpDeviceID] = val
376 + if val != "" {
377 + data.cdpDeviceIDs[val] = struct{}{}
378 + }
379 + continue
380 + }
381 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.3", 2); ok {
382 + ifIndex := indexes[0]
383 + deviceIndex := indexes[1]
384 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
385 + entry[tagCdpIfIndex] = ifIndex
386 + entry[tagCdpDeviceIndex] = deviceIndex
387 + entry[tagCdpAddressType] = val
388 + continue
389 + }
390 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.7", 2); ok {
391 + ifIndex := indexes[0]
392 + deviceIndex := indexes[1]
393 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
394 + entry[tagCdpIfIndex] = ifIndex
395 + entry[tagCdpDeviceIndex] = deviceIndex
396 + entry[tagCdpDevicePort] = val
397 + continue
398 + }
399 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.8", 2); ok {
400 + ifIndex := indexes[0]
401 + deviceIndex := indexes[1]
402 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
403 + entry[tagCdpIfIndex] = ifIndex
404 + entry[tagCdpDeviceIndex] = deviceIndex
405 + entry[tagCdpPlatform] = val
406 + continue
407 + }
408 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.5", 2); ok {
409 + ifIndex := indexes[0]
410 + deviceIndex := indexes[1]
411 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
412 + entry[tagCdpIfIndex] = ifIndex
413 + entry[tagCdpDeviceIndex] = deviceIndex
414 + entry[tagCdpVersion] = val
415 + continue
416 + }
417 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.9", 2); ok {
418 + ifIndex := indexes[0]
419 + deviceIndex := indexes[1]
420 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
421 + entry[tagCdpIfIndex] = ifIndex
422 + entry[tagCdpDeviceIndex] = deviceIndex
423 + entry[tagCdpCaps] = val
424 + continue
425 + }
426 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.4", 2); ok {
427 + ifIndex := indexes[0]
428 + deviceIndex := indexes[1]
429 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
430 + entry[tagCdpIfIndex] = ifIndex
431 + entry[tagCdpDeviceIndex] = deviceIndex
432 + entry[tagCdpAddress] = val
433 + if val != "" {
434 + data.cdpMgmtAddrs[val] = struct{}{}
435 + }
436 + continue
437 + }
438 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.10", 2); ok {
439 + ifIndex := indexes[0]
440 + deviceIndex := indexes[1]
441 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
442 + entry[tagCdpIfIndex] = ifIndex
443 + entry[tagCdpDeviceIndex] = deviceIndex
444 + entry[tagCdpVTPDomain] = val
445 + continue
446 + }
447 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.11", 2); ok {
448 + ifIndex := indexes[0]
449 + deviceIndex := indexes[1]
450 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
451 + entry[tagCdpIfIndex] = ifIndex
452 + entry[tagCdpDeviceIndex] = deviceIndex
453 + entry[tagCdpNativeVLAN] = val
454 + continue
455 + }
456 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.12", 2); ok {
457 + ifIndex := indexes[0]
458 + deviceIndex := indexes[1]
459 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
460 + entry[tagCdpIfIndex] = ifIndex
461 + entry[tagCdpDeviceIndex] = deviceIndex
462 + entry[tagCdpDuplex] = val
463 + continue
464 + }
465 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.15", 2); ok {
466 + ifIndex := indexes[0]
467 + deviceIndex := indexes[1]
468 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
469 + entry[tagCdpIfIndex] = ifIndex
470 + entry[tagCdpDeviceIndex] = deviceIndex
471 + entry[tagCdpPower] = val
472 + continue
473 + }
474 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.16", 2); ok {
475 + ifIndex := indexes[0]
476 + deviceIndex := indexes[1]
477 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
478 + entry[tagCdpIfIndex] = ifIndex
479 + entry[tagCdpDeviceIndex] = deviceIndex
480 + entry[tagCdpMTU] = val
481 + continue
482 + }
483 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.17", 2); ok {
484 + ifIndex := indexes[0]
485 + deviceIndex := indexes[1]
486 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
487 + entry[tagCdpIfIndex] = ifIndex
488 + entry[tagCdpDeviceIndex] = deviceIndex
489 + entry[tagCdpSysName] = val
490 + if val != "" {
491 + data.cdpSysNames[val] = struct{}{}
492 + }
493 + continue
494 + }
495 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.18", 2); ok {
496 + ifIndex := indexes[0]
497 + deviceIndex := indexes[1]
498 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
499 + entry[tagCdpIfIndex] = ifIndex
500 + entry[tagCdpDeviceIndex] = deviceIndex
501 + entry[tagCdpSysObjectID] = val
502 + continue
503 + }
504 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.19", 2); ok {
505 + ifIndex := indexes[0]
506 + deviceIndex := indexes[1]
507 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
508 + entry[tagCdpIfIndex] = ifIndex
509 + entry[tagCdpDeviceIndex] = deviceIndex
510 + entry[tagCdpPrimaryMgmtAddrType] = val
511 + continue
512 + }
513 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.20", 2); ok {
514 + ifIndex := indexes[0]
515 + deviceIndex := indexes[1]
516 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
517 + entry[tagCdpIfIndex] = ifIndex
518 + entry[tagCdpDeviceIndex] = deviceIndex
519 + entry[tagCdpPrimaryMgmtAddr] = val
520 + if val != "" {
521 + data.cdpMgmtAddrs[val] = struct{}{}
522 + }
523 + continue
524 + }
525 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.21", 2); ok {
526 + ifIndex := indexes[0]
527 + deviceIndex := indexes[1]
528 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
529 + entry[tagCdpIfIndex] = ifIndex
530 + entry[tagCdpDeviceIndex] = deviceIndex
531 + entry[tagCdpSecondaryMgmtAddrType] = val
532 + continue
533 + }
534 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.22", 2); ok {
535 + ifIndex := indexes[0]
536 + deviceIndex := indexes[1]
537 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
538 + entry[tagCdpIfIndex] = ifIndex
539 + entry[tagCdpDeviceIndex] = deviceIndex
540 + entry[tagCdpSecondaryMgmtAddr] = val
541 + if val != "" {
542 + data.cdpMgmtAddrs[val] = struct{}{}
543 + }
544 + continue
545 + }
546 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.23", 2); ok {
547 + ifIndex := indexes[0]
548 + deviceIndex := indexes[1]
549 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
550 + entry[tagCdpIfIndex] = ifIndex
551 + entry[tagCdpDeviceIndex] = deviceIndex
552 + entry[tagCdpPhysicalLocation] = val
553 + continue
554 + }
555 + if indexes, ok := parseOIDIndexes(oid, "1.3.6.1.4.1.9.9.23.1.2.1.1.24", 2); ok {
556 + ifIndex := indexes[0]
557 + deviceIndex := indexes[1]
558 + entry := ensureTagMap(data.cdpRemotes, ifIndex+":"+deviceIndex)
559 + entry[tagCdpIfIndex] = ifIndex
560 + entry[tagCdpDeviceIndex] = deviceIndex
561 + entry[tagCdpLastChange] = val
562 + continue
563 + }
564 + }
565 +
566 + require.NoError(t, scanner.Err())
567 +
568 + for key, entry := range data.cdpRemotes {
569 + parts := strings.Split(key, ":")
570 + if len(parts) == 2 {
571 + if name := data.ifNames[parts[0]]; name != "" {
572 + entry[tagCdpIfName] = name
573 + }
574 + }
575 + }
576 +
577 + return data
578 +}
579 +
580 +func ensureTagMap(target map[string]map[string]string, key string) map[string]string {
581 + entry := target[key]
582 + if entry == nil {
583 + entry = make(map[string]string)
584 + target[key] = entry
585 + }
586 + return entry
587 +}
588 +
589 +func parseOIDIndex(oid, prefix string) (string, bool) {
590 + if !strings.HasPrefix(oid, prefix+".") {
591 + return "", false
592 + }
593 + suffix := strings.TrimPrefix(oid, prefix+".")
594 + if suffix == "" {
595 + return "", false
596 + }
597 + parts := strings.Split(suffix, ".")
598 + return parts[len(parts)-1], true
599 +}
600 +
601 +func parseOIDIndexes(oid, prefix string, count int) ([]string, bool) {
602 + if !strings.HasPrefix(oid, prefix+".") {
603 + return nil, false
604 + }
605 + suffix := strings.TrimPrefix(oid, prefix+".")
606 + parts := strings.Split(suffix, ".")
607 + if len(parts) < count {
608 + return nil, false
609 + }
610 + return parts[len(parts)-count:], true
611 +}
612 +
613 +func parseOIDLeadingIndexes(oid, prefix string, count int) ([]string, bool) {
614 + if !strings.HasPrefix(oid, prefix+".") {
615 + return nil, false
616 + }
617 + suffix := strings.TrimPrefix(oid, prefix+".")
618 + parts := strings.Split(suffix, ".")
619 + if len(parts) < count {
620 + return nil, false
621 + }
622 + return parts[:count], true
623 +}
624 +
625 +func parseOIDSuffix(oid, prefix string) (string, bool) {
626 + if !strings.HasPrefix(oid, prefix+".") {
627 + return "", false
628 + }
629 + return strings.TrimPrefix(oid, prefix+"."), true
630 +}
631 +
632 +func hasProtocolLink(snapshot topologyData, protocol string) bool {
633 + for _, link := range snapshot.Links {
634 + if link.Protocol == protocol {
635 + return true
636 + }
637 + }
638 + return false
639 +}
640 +
641 +func containsSysName(snapshot topologyData, names map[string]struct{}) bool {
642 + for _, link := range snapshot.Links {
643 + sysName, _ := link.Dst.Attributes["sys_name"].(string)
644 + if sysName == "" {
645 + continue
646 + }
647 + if _, ok := names[sysName]; ok {
648 + return true
649 + }
650 + }
651 + for _, actor := range snapshot.Actors {
652 + if actor.Match.SysName == "" {
653 + continue
654 + }
655 + if _, ok := names[actor.Match.SysName]; ok {
656 + return true
657 + }
658 + }
659 + return false
660 +}
661 +
662 +func hasLinkableLLDP(data snmprecTopology) bool {
663 + for _, tags := range data.lldpRemotes {
664 + if tags[tagLldpRemChassisID] != "" || tags[tagLldpRemMgmtAddr] != "" {
665 + return true
666 + }
667 + }
668 + for _, tags := range data.lldpRemManAddrs {
669 + if tags[tagLldpRemMgmtAddr] != "" {
670 + return true
671 + }
672 + }
673 + return false
674 +}
675 +
676 +func hasLinkableCDP(data snmprecTopology) bool {
677 + for _, tags := range data.cdpRemotes {
678 + if tags[tagCdpDeviceID] != "" || tags[tagCdpAddress] != "" || tags[tagCdpPrimaryMgmtAddr] != "" || tags[tagCdpSecondaryMgmtAddr] != "" {
679 + return true
680 + }
681 + }
682 + return false
683 +}
684 +
685 +func containsIdentifier(snapshot topologyData, ids map[string]struct{}) bool {
686 + exact := make(map[string]struct{}, len(ids))
687 + macs := make(map[string]struct{}, len(ids))
688 + ips := make(map[string]struct{}, len(ids))
689 + hosts := make(map[string]struct{}, len(ids))
690 + for id := range ids {
691 + id = strings.TrimSpace(id)
692 + if id == "" {
693 + continue
694 + }
695 + exact[strings.ToLower(id)] = struct{}{}
696 + if mac := normalizeMAC(id); mac != "" {
697 + macs[mac] = struct{}{}
698 + }
699 + if ip := normalizeIPAddress(id); ip != "" {
700 + ips[ip] = struct{}{}
701 + }
702 + hosts[strings.ToLower(strings.TrimSuffix(id, "."))] = struct{}{}
703 + }
704 + matches := func(value string) bool {
705 + value = strings.TrimSpace(value)
706 + if value == "" {
707 + return false
708 + }
709 + if _, ok := exact[strings.ToLower(value)]; ok {
710 + return true
711 + }
712 + if mac := normalizeMAC(value); mac != "" {
713 + if _, ok := macs[mac]; ok {
714 + return true
715 + }
716 + }
717 + if ip := normalizeIPAddress(value); ip != "" {
718 + if _, ok := ips[ip]; ok {
719 + return true
720 + }
721 + }
722 + if _, ok := hosts[strings.ToLower(strings.TrimSuffix(value, "."))]; ok {
723 + return true
724 + }
725 + return false
726 + }
727 +
728 + for _, link := range snapshot.Links {
729 + if sysName, _ := link.Dst.Attributes["sys_name"].(string); sysName != "" {
730 + if matches(sysName) {
731 + return true
732 + }
733 + }
734 + for _, id := range link.Dst.Match.ChassisIDs {
735 + if matches(id) {
736 + return true
737 + }
738 + }
739 + for _, ip := range link.Dst.Match.IPAddresses {
740 + if matches(ip) {
741 + return true
742 + }
743 + }
744 + }
745 + for _, actor := range snapshot.Actors {
746 + if actor.Match.SysName != "" {
747 + if matches(actor.Match.SysName) {
748 + return true
749 + }
750 + }
751 + for _, id := range actor.Match.ChassisIDs {
752 + if matches(id) {
753 + return true
754 + }
755 + }
756 + for _, ip := range actor.Match.IPAddresses {
757 + if matches(ip) {
758 + return true
759 + }
760 + }
761 + }
762 + return false
763 +}
src/go/plugin/go.d/collector/snmp_topology/topology_stp_helpers.go new
+133
@@ -0,0 +1,133 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "net"
7 + "strconv"
8 + "strings"
9 +)
10 +
11 +type stpBridgeIDStatus uint8
12 +
13 +const (
14 + stpBridgeIDInvalid stpBridgeIDStatus = iota
15 + stpBridgeIDEmpty
16 + stpBridgeIDValid
17 +)
18 +
19 +func stpBridgeAddressToMAC(value string) string {
20 + mac, status := parseSTPBridgeID(value, 0)
21 + if status != stpBridgeIDValid {
22 + return ""
23 + }
24 + return mac
25 +}
26 +
27 +func parseSTPBridgeID(value string, depth int) (string, stpBridgeIDStatus) {
28 + if depth > 2 {
29 + return "", stpBridgeIDInvalid
30 + }
31 +
32 + value = strings.TrimSpace(value)
33 + if value == "" {
34 + return "", stpBridgeIDEmpty
35 + }
36 +
37 + if mac := normalizeMAC(value); mac != "" && strings.Count(mac, ":") == 5 {
38 + if mac == "00:00:00:00:00:00" {
39 + return "", stpBridgeIDEmpty
40 + }
41 + return mac, stpBridgeIDValid
42 + }
43 +
44 + if priority, bridgeID, ok := splitSTPBridgeIDWithPriority(value); ok {
45 + if priority == "0" && isSTPAllZeroBridgeID(bridgeID) {
46 + return "", stpBridgeIDEmpty
47 + }
48 + return parseSTPBridgeID(bridgeID, depth+1)
49 + }
50 +
51 + bs, err := decodeHexString(value)
52 + if err != nil || len(bs) == 0 {
53 + return "", stpBridgeIDInvalid
54 + }
55 + if allBytesZero(bs) {
56 + return "", stpBridgeIDEmpty
57 + }
58 + if ascii := decodePrintableASCII(bs); ascii != "" && depth < 2 {
59 + return parseSTPBridgeID(ascii, depth+1)
60 + }
61 + switch len(bs) {
62 + case 6:
63 + mac := strings.ToLower(net.HardwareAddr(bs).String())
64 + if mac == "00:00:00:00:00:00" {
65 + return "", stpBridgeIDEmpty
66 + }
67 + return mac, stpBridgeIDValid
68 + case 8:
69 + mac := strings.ToLower(net.HardwareAddr(bs[len(bs)-6:]).String())
70 + if mac == "00:00:00:00:00:00" {
71 + return "", stpBridgeIDEmpty
72 + }
73 + return mac, stpBridgeIDValid
74 + default:
75 + return "", stpBridgeIDInvalid
76 + }
77 +}
78 +
79 +func splitSTPBridgeIDWithPriority(value string) (string, string, bool) {
80 + parts := strings.SplitN(value, "-", 2)
81 + if len(parts) != 2 {
82 + return "", "", false
83 + }
84 + priority := strings.TrimSpace(parts[0])
85 + bridgeID := strings.TrimSpace(parts[1])
86 + if priority == "" || bridgeID == "" {
87 + return "", "", false
88 + }
89 + if _, err := strconv.Atoi(priority); err != nil {
90 + return "", "", false
91 + }
92 + return priority, bridgeID, true
93 +}
94 +
95 +func isSTPAllZeroBridgeID(value string) bool {
96 + mac := normalizeMAC(value)
97 + if mac == "00:00:00:00:00:00" {
98 + return true
99 + }
100 + if mac != "" {
101 + return false
102 + }
103 + clean := normalizeHexIdentifier(value)
104 + if clean == "" {
105 + return false
106 + }
107 + for _, r := range clean {
108 + if r != '0' {
109 + return false
110 + }
111 + }
112 + return true
113 +}
114 +
115 +func allBytesZero(bs []byte) bool {
116 + for _, b := range bs {
117 + if b != 0 {
118 + return false
119 + }
120 + }
121 + return true
122 +}
123 +
124 +func stpDesignatedPortString(value string) string {
125 + value = strings.TrimSpace(value)
126 + if value == "" {
127 + return ""
128 + }
129 + if _, err := strconv.Atoi(value); err == nil {
130 + return value
131 + }
132 + return normalizeHexIdentifier(value)
133 +}
src/go/plugin/go.d/collector/snmp_topology/topology_test_helpers_test.go new
+26
@@ -0,0 +1,26 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +func containsMgmtAddr(snapshot topologyData, addrs map[string]struct{}) bool {
6 + for _, actor := range snapshot.Actors {
7 + for _, ip := range actor.Match.IPAddresses {
8 + if _, ok := addrs[ip]; ok {
9 + return true
10 + }
11 + }
12 + }
13 + for _, link := range snapshot.Links {
14 + for _, ip := range link.Src.Match.IPAddresses {
15 + if _, ok := addrs[ip]; ok {
16 + return true
17 + }
18 + }
19 + for _, ip := range link.Dst.Match.IPAddresses {
20 + if _, ok := addrs[ip]; ok {
21 + return true
22 + }
23 + }
24 + }
25 + return false
26 +}
src/go/plugin/go.d/collector/snmp_topology/topology_types.go new
+75
@@ -0,0 +1,75 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import "github.com/netdata/netdata/go/plugins/pkg/topology"
6 +
7 +const topologySchemaVersion = "2.0"
8 +
9 +type topologyData = topology.Data
10 +type topologyActor = topology.Actor
11 +type topologyMatch = topology.Match
12 +type topologyLink = topology.Link
13 +type topologyLinkEndpoint = topology.LinkEndpoint
14 +type topologyFlow = topology.Flow
15 +type topologyIPPolicy = topology.IPPolicy
16 +
17 +type topologyManagementAddress struct {
18 + Address string `json:"address"`
19 + AddressType string `json:"address_type,omitempty"`
20 + IfSubtype string `json:"if_subtype,omitempty"`
21 + IfID string `json:"if_id,omitempty"`
22 + OID string `json:"oid,omitempty"`
23 + Source string `json:"source,omitempty"`
24 +}
25 +
26 +type topologyInterfaceChartRef struct {
27 + ChartIDSuffix string `json:"chart_id_suffix,omitempty"`
28 + AvailableMetrics []string `json:"available_metrics,omitempty"`
29 +}
30 +
31 +type topologyDevice struct {
32 + ChassisID string `json:"chassis_id"`
33 + ChassisIDType string `json:"chassis_id_type"`
34 + SysObjectID string `json:"sys_object_id,omitempty"`
35 + SysName string `json:"sys_name,omitempty"`
36 + SysDescr string `json:"sys_descr,omitempty"`
37 + SysContact string `json:"sys_contact,omitempty"`
38 + SysLocation string `json:"sys_location,omitempty"`
39 + SysUptime int64 `json:"sys_uptime,omitempty"`
40 + SerialNumber string `json:"serial_number,omitempty"`
41 + SoftwareVersion string `json:"software_version,omitempty"`
42 + FirmwareVersion string `json:"firmware_version,omitempty"`
43 + HardwareVersion string `json:"hardware_version,omitempty"`
44 + ManagementIP string `json:"management_ip,omitempty"`
45 + ManagementAddresses []topologyManagementAddress `json:"management_addresses,omitempty"`
46 + AgentID string `json:"agent_id,omitempty"`
47 + AgentJobID string `json:"agent_job_id,omitempty"`
48 + NetdataHostID string `json:"netdata_host_id,omitempty"`
49 + ChartIDPrefix string `json:"chart_id_prefix,omitempty"`
50 + ChartContextPrefix string `json:"chart_context_prefix,omitempty"`
51 + DeviceCharts map[string]string `json:"device_charts,omitempty"`
52 + InterfaceCharts map[string]topologyInterfaceChartRef `json:"interface_charts,omitempty"`
53 + Vendor string `json:"vendor,omitempty"`
54 + Model string `json:"model,omitempty"`
55 + Capabilities []string `json:"capabilities,omitempty"`
56 + CapabilitiesSupported []string `json:"capabilities_supported,omitempty"`
57 + CapabilitiesEnabled []string `json:"capabilities_enabled,omitempty"`
58 + Labels map[string]string `json:"labels,omitempty"`
59 + Discovered bool `json:"discovered,omitempty"`
60 +}
61 +
62 +type topologyEndpoint struct {
63 + ChassisID string `json:"chassis_id"`
64 + ChassisIDType string `json:"chassis_id_type"`
65 + IfIndex int `json:"if_index,omitempty"`
66 + IfName string `json:"if_name,omitempty"`
67 + PortID string `json:"port_id,omitempty"`
68 + PortIDType string `json:"port_id_type,omitempty"`
69 + PortDescr string `json:"port_descr,omitempty"`
70 + SysName string `json:"sys_name,omitempty"`
71 + ManagementIP string `json:"management_ip,omitempty"`
72 + ManagementAddresses []topologyManagementAddress `json:"management_addresses,omitempty"`
73 + AgentID string `json:"agent_id,omitempty"`
74 + Labels map[string]string `json:"labels,omitempty"`
75 +}
src/go/plugin/go.d/collector/snmp_topology/topology_vlan_context.go new
+33
@@ -0,0 +1,33 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
7 +)
8 +
9 +func (c *Collector) collectTopologyVTPVLANContexts(dev ddsnmp.DeviceConnectionInfo) {
10 + if c.topologyCache == nil {
11 + return
12 + }
13 +
14 + contexts := c.topologyCache.vtpVLANContexts()
15 + if len(contexts) == 0 {
16 + return
17 + }
18 +
19 + profiles, err := loadTopologyVLANContextProfiles()
20 + if err != nil {
21 + c.Warningf("device '%s': topology vlan-context polling disabled: failed to load profiles: %v", dev.Hostname, err)
22 + return
23 + }
24 +
25 + for _, context := range contexts {
26 + pms, err := collectTopologyVLANContext(c, dev, context.vlanID, profiles)
27 + if err != nil {
28 + c.Warningf("device '%s': topology vlan-context polling failed for vlan %s: %v", dev.Hostname, context.vlanID, err)
29 + continue
30 + }
31 + c.ingestTopologyVLANContextMetrics(context.vlanID, context.vlanName, pms)
32 + }
33 +}
src/go/plugin/go.d/collector/snmp_topology/topology_vlan_context_collect.go new
+83
@@ -0,0 +1,83 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "fmt"
7 + "strconv"
8 + "strings"
9 +
10 + "github.com/gosnmp/gosnmp"
11 +
12 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
13 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector"
14 +)
15 +
16 +func loadTopologyVLANContextProfiles() ([]*ddsnmp.Profile, error) {
17 + names := []string{fdbArpProfileName, stpProfileName}
18 + profiles := make([]*ddsnmp.Profile, 0, len(names))
19 + for _, name := range names {
20 + profile, err := ddsnmp.LoadProfileByName(name)
21 + if err != nil {
22 + return nil, err
23 + }
24 + profiles = append(profiles, profile)
25 + }
26 +
27 + return ddsnmp.FinalizeProfiles(profiles), nil
28 +}
29 +
30 +func collectTopologyVLANContext(c *Collector, dev ddsnmp.DeviceConnectionInfo, vlanID string, profiles []*ddsnmp.Profile) ([]*ddsnmp.ProfileMetrics, error) {
31 + if strings.TrimSpace(vlanID) == "" {
32 + return nil, fmt.Errorf("empty vlan id")
33 + }
34 + if _, err := strconv.Atoi(vlanID); err != nil {
35 + return nil, fmt.Errorf("invalid vlan id '%s': %w", vlanID, err)
36 + }
37 +
38 + snmpClient, err := initTopologyVLANClient(c, dev, vlanID)
39 + if err != nil {
40 + return nil, err
41 + }
42 + defer func() {
43 + _ = snmpClient.Close()
44 + }()
45 +
46 + vlanCollector := c.newDdSnmpColl(ddsnmpcollector.Config{
47 + SnmpClient: snmpClient,
48 + Profiles: profiles,
49 + Log: c.Logger,
50 + SysObjectID: dev.SysObjectID,
51 + DisableBulkWalk: dev.DisableBulkWalk,
52 + })
53 +
54 + return vlanCollector.Collect()
55 +}
56 +
57 +func initTopologyVLANClient(c *Collector, dev ddsnmp.DeviceConnectionInfo, vlanID string) (gosnmp.Handler, error) {
58 + client, err := newSNMPClientFromDeviceInfo(c.newSnmpClient, dev)
59 + if err != nil {
60 + return nil, err
61 + }
62 +
63 + switch client.Version() {
64 + case gosnmp.Version3:
65 + client.SetContextName("vlan-" + vlanID)
66 + default:
67 + baseCommunity := client.Community()
68 + if baseCommunity == "" {
69 + baseCommunity = dev.Community
70 + }
71 + client.SetCommunity(baseCommunity + "@" + vlanID)
72 + }
73 +
74 + if dev.MaxRepetitions != 0 {
75 + client.SetMaxRepetitions(dev.MaxRepetitions)
76 + }
77 +
78 + if err := client.Connect(); err != nil {
79 + return nil, err
80 + }
81 +
82 + return client, nil
83 +}
src/go/plugin/go.d/collector/snmp_topology/topology_vlan_context_ingest.go new
+52
@@ -0,0 +1,52 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "maps"
7 + "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
10 +)
11 +
12 +func (c *Collector) ingestTopologyVLANContextMetrics(vlanID, vlanName string, pms []*ddsnmp.ProfileMetrics) {
13 + c.updateTopologyProfileTags(pms)
14 +
15 + for _, pm := range pms {
16 + for _, metric := range pm.Metrics {
17 + if !isTopologyVLANContextMetric(metric.Name) {
18 + continue
19 + }
20 +
21 + tags := withTopologyVLANContextTags(metric.Tags, vlanID, vlanName)
22 + c.updateTopologyCacheEntry(ddsnmp.Metric{
23 + Name: metric.Name,
24 + Tags: tags,
25 + })
26 + }
27 + }
28 +}
29 +
30 +func isTopologyVLANContextMetric(name string) bool {
31 + switch name {
32 + case metricTopologyIfNameEntry, metricBridgePortMapEntry, metricFdbEntry, metricStpPortEntry:
33 + return true
34 + default:
35 + return false
36 + }
37 +}
38 +
39 +func withTopologyVLANContextTags(tags map[string]string, vlanID, vlanName string) map[string]string {
40 + if strings.TrimSpace(vlanID) == "" {
41 + return tags
42 + }
43 +
44 + merged := make(map[string]string, len(tags)+2)
45 + maps.Copy(merged, tags)
46 + merged[tagTopologyContextVLANID] = strings.TrimSpace(vlanID)
47 + if v := strings.TrimSpace(vlanName); v != "" {
48 + merged[tagTopologyContextVLANName] = v
49 + }
50 +
51 + return merged
52 +}
src/go/plugin/go.d/collector/snmp_topology/topology_vlan_context_inventory.go new
+46
@@ -0,0 +1,46 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmptopology
4 +
5 +import (
6 + "sort"
7 + "strconv"
8 + "strings"
9 +)
10 +
11 +func (c *topologyCache) vtpVLANContexts() []topologyVLANContext {
12 + c.mu.RLock()
13 + defer c.mu.RUnlock()
14 +
15 + contexts := make([]topologyVLANContext, 0, len(c.vlanIDToName))
16 + for vlanID, vlanName := range c.vlanIDToName {
17 + id := strings.TrimSpace(vlanID)
18 + if id == "" {
19 + continue
20 + }
21 + if _, err := strconv.Atoi(id); err != nil {
22 + continue
23 + }
24 + contexts = append(contexts, topologyVLANContext{
25 + vlanID: id,
26 + vlanName: strings.TrimSpace(vlanName),
27 + })
28 + }
29 +
30 + sortTopologyVLANContexts(contexts)
31 + return contexts
32 +}
33 +
34 +func sortTopologyVLANContexts(contexts []topologyVLANContext) {
35 + sort.Slice(contexts, func(i, j int) bool {
36 + left, leftErr := strconv.Atoi(contexts[i].vlanID)
37 + right, rightErr := strconv.Atoi(contexts[j].vlanID)
38 + if leftErr == nil && rightErr == nil && left != right {
39 + return left < right
40 + }
41 + if contexts[i].vlanID != contexts[j].vlanID {
42 + return contexts[i].vlanID < contexts[j].vlanID
43 + }
44 + return contexts[i].vlanName < contexts[j].vlanName
45 + })
46 +}
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_arista.yaml
+6
@@ -1,3 +1,9 @@
1 +extends:
2 + - _std-lldp-mib.yaml
3 + - _std-topology-fdb-arp-mib.yaml
4 + - _std-topology-q-bridge-mib.yaml
5 + - _std-topology-stp-mib.yaml
6 +
7 metadata:
8 device:
9 fields:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_aruba-base.yaml
+6
@@ -1,3 +1,9 @@
1 +extends:
2 + - _std-lldp-mib.yaml
3 + - _std-topology-fdb-arp-mib.yaml
4 + - _std-topology-q-bridge-mib.yaml
5 + - _std-topology-stp-mib.yaml
6 +
7 metadata:
8 device:
9 fields:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_cisco-base.yaml
+6
@@ -2,6 +2,12 @@
2
3 extends:
4 - _std-if-mib.yaml
5 + - _std-lldp-mib.yaml
6 + - _std-cdp-mib.yaml
7 + - _std-topology-fdb-arp-mib.yaml
8 + - _std-topology-q-bridge-mib.yaml
9 + - _std-topology-stp-mib.yaml
10 + - _std-topology-cisco-vtp-mib.yaml
11 - _std-tcp-mib.yaml
12 - _std-udp-mib.yaml
13 - _std-ospf-mib.yaml
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_juniper.yaml
+4
@@ -1,5 +1,9 @@
1 extends:
2 - _system-base.yaml
3 + - _std-lldp-mib.yaml
4 + - _std-topology-fdb-arp-mib.yaml
5 + - _std-topology-q-bridge-mib.yaml
6 + - _std-topology-stp-mib.yaml
7
8 metadata:
9 device:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-cdp-mib.yaml new
+155
@@ -0,0 +1,155 @@
1 +# CISCO-CDP-MIB profile for topology discovery.
2 +# MIB: CISCO-CDP-MIB
3 +
4 +metrics:
5 + - MIB: CISCO-CDP-MIB
6 + symbol:
7 + OID: 1.3.6.1.4.1.9.9.23.1.3.1.0
8 + name: cdpGlobalRun
9 +
10 + - MIB: CISCO-CDP-MIB
11 + symbol:
12 + OID: 1.3.6.1.4.1.9.9.23.1.3.2.0
13 + name: cdpGlobalMessageInterval
14 +
15 + - MIB: CISCO-CDP-MIB
16 + symbol:
17 + OID: 1.3.6.1.4.1.9.9.23.1.3.3.0
18 + name: cdpGlobalHoldTime
19 +
20 + - MIB: CISCO-CDP-MIB
21 + symbol:
22 + OID: 1.3.6.1.4.1.9.9.23.1.3.4.0
23 + name: cdpGlobalDeviceId
24 +
25 + - MIB: CISCO-CDP-MIB
26 + symbol:
27 + OID: 1.3.6.1.4.1.9.9.23.1.3.5.0
28 + name: cdpGlobalLastChange
29 +
30 + - MIB: CISCO-CDP-MIB
31 + symbol:
32 + OID: 1.3.6.1.4.1.9.9.23.1.3.7.0
33 + name: cdpGlobalDeviceIdFormat
34 +
35 + - MIB: CISCO-CDP-MIB
36 + table:
37 + OID: 1.3.6.1.4.1.9.9.23.1.1.1
38 + name: cdpInterfaceTable
39 + symbols:
40 + - OID: 1.3.6.1.4.1.9.9.23.1.1.1.1.2
41 + name: cdpInterfaceEnable
42 + - OID: 1.3.6.1.4.1.9.9.23.1.1.1.1.3
43 + name: cdpInterfaceMessageInterval
44 + - OID: 1.3.6.1.4.1.9.9.23.1.1.1.1.6
45 + name: cdpInterfaceName
46 + metric_tags:
47 + - tag: cdp_if_index
48 + symbol:
49 + OID: 1.3.6.1.4.1.9.9.23.1.1.1.1.1
50 + name: cdpInterfaceIfIndex
51 +
52 + - MIB: CISCO-CDP-MIB
53 + table:
54 + OID: 1.3.6.1.4.1.9.9.23.1.2.1
55 + name: cdpCacheTable
56 + symbols:
57 + # Use a required numeric column to ensure rows are emitted by ddsnmp.
58 + - OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.3
59 + name: _topology_cdp_cache_entry
60 + metric_tags:
61 + - tag: cdp_if_index
62 + index: 1
63 + - tag: cdp_device_index
64 + index: 2
65 + - tag: cdp_device_id
66 + symbol:
67 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.6
68 + name: cdpCacheDeviceId
69 + - tag: cdp_address_type
70 + symbol:
71 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.3
72 + name: cdpCacheAddressType
73 + - tag: cdp_device_port
74 + symbol:
75 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.7
76 + name: cdpCacheDevicePort
77 + - tag: cdp_version
78 + symbol:
79 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.5
80 + name: cdpCacheVersion
81 + - tag: cdp_platform
82 + symbol:
83 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.8
84 + name: cdpCachePlatform
85 + - tag: cdp_capabilities
86 + symbol:
87 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.9
88 + name: cdpCacheCapabilities
89 + - tag: cdp_address
90 + symbol:
91 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.4
92 + name: cdpCacheAddress
93 + format: hex
94 + - tag: cdp_vtp_mgmt_domain
95 + symbol:
96 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.10
97 + name: cdpCacheVTPMgmtDomain
98 + - tag: cdp_native_vlan
99 + symbol:
100 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.11
101 + name: cdpCacheNativeVLAN
102 + - tag: cdp_duplex
103 + symbol:
104 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.12
105 + name: cdpCacheDuplex
106 + - tag: cdp_power_consumption
107 + symbol:
108 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.15
109 + name: cdpCachePowerConsumption
110 + - tag: cdp_mtu
111 + symbol:
112 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.16
113 + name: cdpCacheMTU
114 + - tag: cdp_sys_name
115 + symbol:
116 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.17
117 + name: cdpCacheSysName
118 + - tag: cdp_sys_object_id
119 + symbol:
120 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.18
121 + name: cdpCacheSysObjectID
122 + - tag: cdp_primary_mgmt_addr_type
123 + symbol:
124 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.19
125 + name: cdpCachePrimaryMgmtAddrType
126 + - tag: cdp_primary_mgmt_addr
127 + symbol:
128 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.20
129 + name: cdpCachePrimaryMgmtAddr
130 + format: hex
131 + - tag: cdp_secondary_mgmt_addr_type
132 + symbol:
133 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.21
134 + name: cdpCacheSecondaryMgmtAddrType
135 + - tag: cdp_secondary_mgmt_addr
136 + symbol:
137 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.22
138 + name: cdpCacheSecondaryMgmtAddr
139 + format: hex
140 + - tag: cdp_physical_location
141 + symbol:
142 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.23
143 + name: cdpCachePhysLocation
144 + - tag: cdp_last_change
145 + symbol:
146 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.24
147 + name: cdpCacheLastChange
148 + - tag: cdp_if_name
149 + table: ifXTable
150 + symbol:
151 + OID: 1.3.6.1.2.1.31.1.1.1.1
152 + name: ifName
153 + index_transform:
154 + - start: 0
155 + end: 0
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-lldp-mib.yaml new
+67
@@ -0,0 +1,67 @@
1 +# LLDP-MIB profile for topology discovery.
2 +# MIB: LLDP-MIB (IEEE 802.1AB)
3 +
4 +extends:
5 + - _std-topology-lldp-mib.yaml
6 +
7 +metrics:
8 + - MIB: LLDP-MIB
9 + symbol:
10 + OID: 1.0.8802.1.1.2.1.2.1.0
11 + name: lldpStatsRemTablesLastChangeTime
12 +
13 + - MIB: LLDP-MIB
14 + symbol:
15 + OID: 1.0.8802.1.1.2.1.2.2.0
16 + name: lldpStatsRemTablesInserts
17 +
18 + - MIB: LLDP-MIB
19 + symbol:
20 + OID: 1.0.8802.1.1.2.1.2.3.0
21 + name: lldpStatsRemTablesDeletes
22 +
23 + - MIB: LLDP-MIB
24 + symbol:
25 + OID: 1.0.8802.1.1.2.1.2.4.0
26 + name: lldpStatsRemTablesDrops
27 +
28 + - MIB: LLDP-MIB
29 + symbol:
30 + OID: 1.0.8802.1.1.2.1.2.5.0
31 + name: lldpStatsRemTablesAgeouts
32 +
33 + - MIB: LLDP-MIB
34 + table:
35 + OID: 1.0.8802.1.1.2.1.2.6
36 + name: lldpStatsTxPortTable
37 + symbols:
38 + - OID: 1.0.8802.1.1.2.1.2.6.1.2
39 + name: lldpStatsTxPortFramesTotal
40 + metric_tags:
41 + - tag: lldp_loc_port_num
42 + symbol:
43 + OID: 1.0.8802.1.1.2.1.2.6.1.1
44 + name: lldpStatsTxPortNum
45 +
46 + - MIB: LLDP-MIB
47 + table:
48 + OID: 1.0.8802.1.1.2.1.2.7
49 + name: lldpStatsRxPortTable
50 + symbols:
51 + - OID: 1.0.8802.1.1.2.1.2.7.1.2
52 + name: lldpStatsRxPortFramesDiscardedTotal
53 + - OID: 1.0.8802.1.1.2.1.2.7.1.3
54 + name: lldpStatsRxPortFramesErrors
55 + - OID: 1.0.8802.1.1.2.1.2.7.1.4
56 + name: lldpStatsRxPortFramesTotal
57 + - OID: 1.0.8802.1.1.2.1.2.7.1.5
58 + name: lldpStatsRxPortTLVsDiscardedTotal
59 + - OID: 1.0.8802.1.1.2.1.2.7.1.6
60 + name: lldpStatsRxPortTLVsUnrecognizedTotal
61 + - OID: 1.0.8802.1.1.2.1.2.7.1.7
62 + name: lldpStatsRxPortAgeoutsTotal
63 + metric_tags:
64 + - tag: lldp_loc_port_num
65 + symbol:
66 + OID: 1.0.8802.1.1.2.1.2.7.1.1
67 + name: lldpStatsRxPortNum
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-topology-cisco-vtp-mib.yaml new
+40
@@ -0,0 +1,40 @@
1 +# Supplemental topology profile for Cisco VTP VLAN metadata enrichment.
2 +# Source: CISCO-VTP-MIB.
3 +
4 +metadata:
5 + device:
6 + fields:
7 + vtp_version:
8 + symbol:
9 + OID: 1.3.6.1.4.1.9.9.46.1.1.1
10 + name: vtpVersion
11 +
12 +metrics:
13 + - MIB: CISCO-VTP-MIB
14 + table:
15 + OID: 1.3.6.1.4.1.9.9.46.1.3.1.1
16 + name: vtpVlanTable
17 + symbols:
18 + # Numeric anchor to force row emission.
19 + - OID: 1.3.6.1.4.1.9.9.46.1.3.1.1.2
20 + name: _topology_vtp_vlan_entry
21 + metric_tags:
22 + - tag: vtp_vlan_index
23 + index: 1
24 + - tag: vtp_vlan_state
25 + symbol:
26 + OID: 1.3.6.1.4.1.9.9.46.1.3.1.1.2
27 + name: vtpVlanState
28 + mapping:
29 + 1: operational
30 + 2: suspended
31 + 3: mtuTooBigForDevice
32 + 4: mtuTooBigForTrunk
33 + - tag: vtp_vlan_type
34 + symbol:
35 + OID: 1.3.6.1.4.1.9.9.46.1.3.1.1.3
36 + name: vtpVlanType
37 + - tag: vtp_vlan_name
38 + symbol:
39 + OID: 1.3.6.1.4.1.9.9.46.1.3.1.1.4
40 + name: vtpVlanName
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-topology-fdb-arp-mib.yaml new
+274
@@ -0,0 +1,274 @@
1 +# Supplemental topology discovery profile for endpoint correlation.
2 +# Sources: BRIDGE-MIB FDB and IP-MIB neighbor tables.
3 +
4 +metrics:
5 + # Interface index -> interface name map used to annotate topology links.
6 + - MIB: IF-MIB
7 + table:
8 + OID: 1.3.6.1.2.1.31.1.1
9 + name: ifXTable
10 + symbols:
11 + # Use a numeric column to ensure row emission.
12 + - OID: 1.3.6.1.2.1.31.1.1.1.15
13 + name: _topology_if_name_entry
14 + metric_tags:
15 + - tag: topo_if_index
16 + index: 1
17 + - tag: topo_if_name
18 + symbol:
19 + OID: 1.3.6.1.2.1.31.1.1.1.1
20 + name: ifName
21 + - tag: topo_if_alias
22 + symbol:
23 + OID: 1.3.6.1.2.1.31.1.1.1.18
24 + name: ifAlias
25 + - tag: topo_if_high_speed
26 + symbol:
27 + OID: 1.3.6.1.2.1.31.1.1.1.15
28 + name: ifHighSpeed
29 +
30 + # IF-MIB ifTable: explicit interface status collection for topology port states.
31 + # Some vendors do not support stable cross-table joins from ifXTable to ifTable.
32 + - MIB: IF-MIB
33 + table:
34 + OID: 1.3.6.1.2.1.2.2
35 + name: ifTable
36 + symbols:
37 + # Use oper status symbol to guarantee row emission for interfaces.
38 + - OID: 1.3.6.1.2.1.2.2.1.8
39 + name: _topology_if_status_entry
40 + metric_tags:
41 + - tag: topo_if_index
42 + index: 1
43 + - tag: topo_if_type
44 + symbol:
45 + OID: 1.3.6.1.2.1.2.2.1.3
46 + name: ifType
47 + - tag: topo_if_descr
48 + symbol:
49 + OID: 1.3.6.1.2.1.2.2.1.2
50 + name: ifDescr
51 + - tag: topo_if_admin_status
52 + symbol:
53 + OID: 1.3.6.1.2.1.2.2.1.7
54 + name: ifAdminStatus
55 + mapping:
56 + 1: up
57 + 2: down
58 + 3: testing
59 + - tag: topo_if_oper_status
60 + symbol:
61 + OID: 1.3.6.1.2.1.2.2.1.8
62 + name: ifOperStatus
63 + mapping:
64 + 1: up
65 + 2: down
66 + 3: testing
67 + 4: unknown
68 + 5: dormant
69 + 6: notPresent
70 + 7: lowerLayerDown
71 + - tag: topo_if_phys_address
72 + symbol:
73 + OID: 1.3.6.1.2.1.2.2.1.6
74 + name: ifPhysAddress
75 + format: hex
76 + - tag: topo_if_speed
77 + symbol:
78 + OID: 1.3.6.1.2.1.2.2.1.5
79 + name: ifSpeed
80 + - tag: topo_if_last_change
81 + symbol:
82 + OID: 1.3.6.1.2.1.2.2.1.9
83 + name: ifLastChange
84 +
85 + # EtherLike-MIB: interface duplex mode.
86 + - MIB: EtherLike-MIB
87 + table:
88 + OID: 1.3.6.1.2.1.10.7.2
89 + name: dot3StatsTable
90 + symbols:
91 + - OID: 1.3.6.1.2.1.10.7.2.1.19
92 + name: _topology_if_duplex_entry
93 + metric_tags:
94 + - tag: topo_if_index
95 + index: 1
96 + - tag: topo_if_duplex
97 + symbol:
98 + OID: 1.3.6.1.2.1.10.7.2.1.19
99 + name: dot3StatsDuplexStatus
100 + mapping:
101 + 1: unknown
102 + 2: half
103 + 3: full
104 +
105 + # IP-MIB: interface address map used for management IP -> ifIndex correlation.
106 + - MIB: IP-MIB
107 + table:
108 + OID: 1.3.6.1.2.1.4.20
109 + name: ipAddrTable
110 + symbols:
111 + - OID: 1.3.6.1.2.1.4.20.1.2
112 + name: _topology_ip_if_index_entry
113 + metric_tags:
114 + - tag: topo_ip_addr
115 + symbol:
116 + OID: 1.3.6.1.2.1.4.20.1.1
117 + name: ipAdEntAddr
118 + - tag: topo_if_index
119 + symbol:
120 + OID: 1.3.6.1.2.1.4.20.1.2
121 + name: ipAdEntIfIndex
122 + - tag: topo_ip_netmask
123 + symbol:
124 + OID: 1.3.6.1.2.1.4.20.1.3
125 + name: ipAdEntNetMask
126 +
127 + # BRIDGE-MIB: maps bridge port number to ifIndex.
128 + - MIB: BRIDGE-MIB
129 + table:
130 + OID: 1.3.6.1.2.1.17.1.4
131 + name: dot1dBasePortTable
132 + symbols:
133 + - OID: 1.3.6.1.2.1.17.1.4.1.2
134 + name: _topology_bridge_port_if_index_entry
135 + metric_tags:
136 + - tag: bridge_base_address
137 + symbol:
138 + OID: 1.3.6.1.2.1.17.1.1
139 + name: dot1dBaseBridgeAddress
140 + format: hex
141 + - tag: bridge_base_port
142 + index: 1
143 + - tag: bridge_if_index
144 + symbol:
145 + OID: 1.3.6.1.2.1.17.1.4.1.2
146 + name: dot1dBasePortIfIndex
147 +
148 + # BRIDGE-MIB: forwarding database (MAC -> bridge port).
149 + - MIB: BRIDGE-MIB
150 + table:
151 + OID: 1.3.6.1.2.1.17.4.3
152 + name: dot1dTpFdbTable
153 + symbols:
154 + # Use the port column so rows are emitted only for learned entries.
155 + - OID: 1.3.6.1.2.1.17.4.3.1.2
156 + name: _topology_fdb_entry
157 + metric_tags:
158 + - tag: bridge_base_address
159 + symbol:
160 + OID: 1.3.6.1.2.1.17.1.1
161 + name: dot1dBaseBridgeAddress
162 + format: hex
163 + - tag: fdb_mac
164 + symbol:
165 + OID: 1.3.6.1.2.1.17.4.3.1.1
166 + name: dot1dTpFdbAddress
167 + format: hex
168 + - tag: fdb_bridge_port
169 + symbol:
170 + OID: 1.3.6.1.2.1.17.4.3.1.2
171 + name: dot1dTpFdbPort
172 + - tag: fdb_status
173 + symbol:
174 + OID: 1.3.6.1.2.1.17.4.3.1.3
175 + name: dot1dTpFdbStatus
176 + mapping:
177 + 1: other
178 + 2: invalid
179 + 3: learned
180 + 4: self
181 + 5: mgmt
182 +
183 + # IP-MIB: modern ARP/ND cache (IPv4 + IPv6).
184 + - MIB: IP-MIB
185 + table:
186 + OID: 1.3.6.1.2.1.4.35.1
187 + name: ipNetToPhysicalTable
188 + symbols:
189 + # Use state column to emit rows only when neighbors exist.
190 + - OID: 1.3.6.1.2.1.4.35.1.6
191 + name: _topology_arp_entry
192 + metric_tags:
193 + - tag: arp_if_index
194 + symbol:
195 + OID: 1.3.6.1.2.1.4.35.1.1
196 + name: ipNetToPhysicalIfIndex
197 + - tag: arp_if_name
198 + table: ifXTable
199 + symbol:
200 + OID: 1.3.6.1.2.1.31.1.1.1.1
201 + name: ifName
202 + index_transform:
203 + - start: 0
204 + end: 0
205 + - tag: arp_addr_type
206 + symbol:
207 + OID: 1.3.6.1.2.1.4.35.1.2
208 + name: ipNetToPhysicalNetAddressType
209 + mapping:
210 + 0: unknown
211 + 1: ipv4
212 + 2: ipv6
213 + 16: dns
214 + - tag: arp_ip
215 + symbol:
216 + OID: 1.3.6.1.2.1.4.35.1.3
217 + name: ipNetToPhysicalNetAddress
218 + - tag: arp_mac
219 + symbol:
220 + OID: 1.3.6.1.2.1.4.35.1.4
221 + name: ipNetToPhysicalPhysAddress
222 + format: hex
223 + - tag: arp_state
224 + symbol:
225 + OID: 1.3.6.1.2.1.4.35.1.6
226 + name: ipNetToPhysicalState
227 + mapping:
228 + 1: reachable
229 + 2: stale
230 + 3: delay
231 + 4: probe
232 + 5: invalid
233 + 6: unknown
234 + 7: incomplete
235 +
236 + # IP-MIB: legacy IPv4 ARP cache fallback.
237 + - MIB: IP-MIB
238 + table:
239 + OID: 1.3.6.1.2.1.4.22
240 + name: ipNetToMediaTable
241 + symbols:
242 + - OID: 1.3.6.1.2.1.4.22.1.4
243 + name: _topology_arp_legacy_entry
244 + metric_tags:
245 + - tag: arp_if_index
246 + symbol:
247 + OID: 1.3.6.1.2.1.4.22.1.1
248 + name: ipNetToMediaIfIndex
249 + - tag: arp_if_name
250 + table: ifXTable
251 + symbol:
252 + OID: 1.3.6.1.2.1.31.1.1.1.1
253 + name: ifName
254 + index_transform:
255 + - start: 0
256 + end: 0
257 + - tag: arp_ip
258 + symbol:
259 + OID: 1.3.6.1.2.1.4.22.1.3
260 + name: ipNetToMediaNetAddress
261 + - tag: arp_mac
262 + symbol:
263 + OID: 1.3.6.1.2.1.4.22.1.2
264 + name: ipNetToMediaPhysAddress
265 + format: hex
266 + - tag: arp_type
267 + symbol:
268 + OID: 1.3.6.1.2.1.4.22.1.4
269 + name: ipNetToMediaType
270 + mapping:
271 + 1: other
272 + 2: invalid
273 + 3: dynamic
274 + 4: static
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-topology-lldp-mib.yaml new
+319
@@ -0,0 +1,319 @@
1 +# Topology-focused LLDP profile for robust multi-vendor discovery.
2 +#
3 +# Required contract:
4 +# - lldpLocPortTable
5 +# - lldpRemTable
6 +# - lldpRemManAddrTable
7 +#
8 +# Why:
9 +# - lldpRemManAddrTable is broadly implemented and provides stable remote identity.
10 +# - lldpRemTable provides the remote chassis/sysname/port fields required for
11 +# LLDP link construction and Enlinkd-parity matching passes.
12 +# - This topology profile keeps the LLDP remote projection minimal and focused
13 +# on link identity fields to limit walk overhead.
14 +
15 +metadata:
16 + device:
17 + fields:
18 + lldp_loc_chassis_id:
19 + symbol:
20 + OID: 1.0.8802.1.1.2.1.3.2.0
21 + name: lldpLocChassisId
22 + lldp_loc_chassis_id_subtype:
23 + symbol:
24 + OID: 1.0.8802.1.1.2.1.3.1.0
25 + name: lldpLocChassisIdSubtype
26 + mapping:
27 + 1: chassisComponent
28 + 2: interfaceAlias
29 + 3: portComponent
30 + 4: macAddress
31 + 5: networkAddress
32 + 6: interfaceName
33 + 7: local
34 + lldp_loc_sys_name:
35 + symbol:
36 + OID: 1.0.8802.1.1.2.1.3.3.0
37 + name: lldpLocSysName
38 + lldp_loc_sys_desc:
39 + symbol:
40 + OID: 1.0.8802.1.1.2.1.3.4.0
41 + name: lldpLocSysDesc
42 + lldp_loc_sys_cap_supported:
43 + symbol:
44 + OID: 1.0.8802.1.1.2.1.3.5.0
45 + name: lldpLocSysCapSupported
46 + format: hex
47 + lldp_loc_sys_cap_enabled:
48 + symbol:
49 + OID: 1.0.8802.1.1.2.1.3.6.0
50 + name: lldpLocSysCapEnabled
51 + format: hex
52 +
53 +metrics:
54 + - MIB: LLDP-MIB
55 + table:
56 + OID: 1.0.8802.1.1.2.1.3.7
57 + name: lldpLocPortTable
58 + symbols:
59 + - OID: 1.0.8802.1.1.2.1.3.7.1.2
60 + name: _topology_lldp_loc_port_entry
61 + metric_tags:
62 + - tag: lldp_loc_port_num
63 + index: 1
64 + - tag: lldp_loc_port_id
65 + symbol:
66 + OID: 1.0.8802.1.1.2.1.3.7.1.3
67 + name: lldpLocPortId
68 + - tag: lldp_loc_port_id_subtype
69 + symbol:
70 + OID: 1.0.8802.1.1.2.1.3.7.1.2
71 + name: lldpLocPortIdSubtype
72 + mapping:
73 + 1: interfaceAlias
74 + 2: portComponent
75 + 3: macAddress
76 + 4: networkAddress
77 + 5: interfaceName
78 + 6: agentCircuitId
79 + 7: local
80 + - tag: lldp_loc_port_desc
81 + symbol:
82 + OID: 1.0.8802.1.1.2.1.3.7.1.4
83 + name: lldpLocPortDesc
84 +
85 + - MIB: LLDP-MIB
86 + table:
87 + OID: 1.0.8802.1.1.2.1.3.8
88 + name: lldpLocManAddrTable
89 + symbols:
90 + - OID: 1.0.8802.1.1.2.1.3.8.1.1
91 + name: _topology_lldp_loc_man_addr_entry
92 + metric_tags:
93 + - tag: lldp_loc_mgmt_addr_subtype
94 + symbol:
95 + OID: 1.0.8802.1.1.2.1.3.8.1.1
96 + name: lldpLocManAddrSubtype
97 + - tag: lldp_loc_mgmt_addr
98 + symbol:
99 + OID: 1.0.8802.1.1.2.1.3.8.1.2
100 + name: lldpLocManAddr
101 + format: hex
102 + - tag: lldp_loc_mgmt_addr_if_subtype
103 + symbol:
104 + OID: 1.0.8802.1.1.2.1.3.8.1.4
105 + name: lldpLocManAddrIfSubtype
106 + - tag: lldp_loc_mgmt_addr_if_id
107 + symbol:
108 + OID: 1.0.8802.1.1.2.1.3.8.1.5
109 + name: lldpLocManAddrIfId
110 + - tag: lldp_loc_mgmt_addr_oid
111 + symbol:
112 + OID: 1.0.8802.1.1.2.1.3.8.1.6
113 + name: lldpLocManAddrOID
114 +
115 + - MIB: LLDP-MIB
116 + table:
117 + OID: 1.0.8802.1.1.2.1.4.1
118 + name: lldpRemTable
119 + symbols:
120 + # Use a required numeric column to ensure rows are emitted by ddsnmp.
121 + - OID: 1.0.8802.1.1.2.1.4.1.1.6
122 + name: _topology_lldp_rem_entry
123 + metric_tags:
124 + - tag: lldp_loc_port_num
125 + index: 2
126 + - tag: lldp_rem_index
127 + index: 3
128 + - tag: lldp_rem_chassis_id_subtype
129 + symbol:
130 + OID: 1.0.8802.1.1.2.1.4.1.1.4
131 + name: lldpRemChassisIdSubtype
132 + mapping:
133 + 1: chassisComponent
134 + 2: interfaceAlias
135 + 3: portComponent
136 + 4: macAddress
137 + 5: networkAddress
138 + 6: interfaceName
139 + 7: local
140 + - tag: lldp_rem_chassis_id
141 + symbol:
142 + OID: 1.0.8802.1.1.2.1.4.1.1.5
143 + name: lldpRemChassisId
144 + - tag: lldp_rem_port_id_subtype
145 + symbol:
146 + OID: 1.0.8802.1.1.2.1.4.1.1.6
147 + name: lldpRemPortIdSubtype
148 + mapping:
149 + 1: interfaceAlias
150 + 2: portComponent
151 + 3: macAddress
152 + 4: networkAddress
153 + 5: interfaceName
154 + 6: agentCircuitId
155 + 7: local
156 + - tag: lldp_rem_port_id
157 + symbol:
158 + OID: 1.0.8802.1.1.2.1.4.1.1.7
159 + name: lldpRemPortId
160 + - tag: lldp_rem_port_desc
161 + symbol:
162 + OID: 1.0.8802.1.1.2.1.4.1.1.8
163 + name: lldpRemPortDesc
164 + - tag: lldp_rem_sys_name
165 + symbol:
166 + OID: 1.0.8802.1.1.2.1.4.1.1.9
167 + name: lldpRemSysName
168 + - tag: lldp_rem_sys_desc
169 + symbol:
170 + OID: 1.0.8802.1.1.2.1.4.1.1.10
171 + name: lldpRemSysDesc
172 + - tag: lldp_rem_sys_cap_supported
173 + symbol:
174 + OID: 1.0.8802.1.1.2.1.4.1.1.11
175 + name: lldpRemSysCapSupported
176 + format: hex
177 + - tag: lldp_rem_sys_cap_enabled
178 + symbol:
179 + OID: 1.0.8802.1.1.2.1.4.1.1.12
180 + name: lldpRemSysCapEnabled
181 + format: hex
182 +
183 + - MIB: LLDP-MIB
184 + table:
185 + OID: 1.0.8802.1.1.2.1.4.2
186 + name: lldpRemManAddrTable
187 + symbols:
188 + # Primary anchor for implementations that expose columns .1/.2
189 + # (e.g. MikroTik).
190 + - OID: 1.0.8802.1.1.2.1.4.2.1.1
191 + name: _topology_lldp_rem_man_addr_entry
192 + metric_tags:
193 + - tag: lldp_loc_port_num
194 + index: 2
195 + - tag: lldp_rem_index
196 + index: 3
197 + - tag: lldp_rem_mgmt_addr_subtype
198 + symbol:
199 + OID: 1.0.8802.1.1.2.1.4.2.1.1
200 + name: lldpRemManAddrSubtype
201 + - tag: lldp_rem_mgmt_addr
202 + symbol:
203 + OID: 1.0.8802.1.1.2.1.4.2.1.2
204 + name: lldpRemManAddr
205 + format: hex
206 + - tag: lldp_rem_mgmt_addr_len
207 + index: 5
208 + - tag: lldp_rem_mgmt_addr_octet_1
209 + index: 6
210 + - tag: lldp_rem_mgmt_addr_octet_2
211 + index: 7
212 + - tag: lldp_rem_mgmt_addr_octet_3
213 + index: 8
214 + - tag: lldp_rem_mgmt_addr_octet_4
215 + index: 9
216 + - tag: lldp_rem_mgmt_addr_octet_5
217 + index: 10
218 + - tag: lldp_rem_mgmt_addr_octet_6
219 + index: 11
220 + - tag: lldp_rem_mgmt_addr_octet_7
221 + index: 12
222 + - tag: lldp_rem_mgmt_addr_octet_8
223 + index: 13
224 + - tag: lldp_rem_mgmt_addr_octet_9
225 + index: 14
226 + - tag: lldp_rem_mgmt_addr_octet_10
227 + index: 15
228 + - tag: lldp_rem_mgmt_addr_octet_11
229 + index: 16
230 + - tag: lldp_rem_mgmt_addr_octet_12
231 + index: 17
232 + - tag: lldp_rem_mgmt_addr_octet_13
233 + index: 18
234 + - tag: lldp_rem_mgmt_addr_octet_14
235 + index: 19
236 + - tag: lldp_rem_mgmt_addr_octet_15
237 + index: 20
238 + - tag: lldp_rem_mgmt_addr_octet_16
239 + index: 21
240 + - tag: lldp_rem_mgmt_addr_if_subtype
241 + symbol:
242 + OID: 1.0.8802.1.1.2.1.4.2.1.3
243 + name: lldpRemManAddrIfSubtype
244 + - tag: lldp_rem_mgmt_addr_if_id
245 + symbol:
246 + OID: 1.0.8802.1.1.2.1.4.2.1.4
247 + name: lldpRemManAddrIfId
248 + - tag: lldp_rem_mgmt_addr_oid
249 + symbol:
250 + OID: 1.0.8802.1.1.2.1.4.2.1.5
251 + name: lldpRemManAddrOID
252 +
253 + - MIB: LLDP-MIB
254 + table:
255 + OID: 1.0.8802.1.1.2.1.4.2
256 + name: lldpRemManAddrTable
257 + symbols:
258 + # Compatibility anchor for implementations that expose .3/.4/.5 but
259 + # not .1/.2 (e.g. XS1930). Address bytes are reconstructed from index.
260 + - OID: 1.0.8802.1.1.2.1.4.2.1.3
261 + name: _topology_lldp_rem_man_addr_compat_entry
262 + metric_tags:
263 + - tag: lldp_loc_port_num
264 + index: 2
265 + - tag: lldp_rem_index
266 + index: 3
267 + - tag: lldp_rem_mgmt_addr_subtype
268 + index: 4
269 + - tag: lldp_rem_mgmt_addr
270 + symbol:
271 + OID: 1.0.8802.1.1.2.1.4.2.1.2
272 + name: lldpRemManAddr
273 + format: hex
274 + - tag: lldp_rem_mgmt_addr_len
275 + index: 5
276 + - tag: lldp_rem_mgmt_addr_octet_1
277 + index: 6
278 + - tag: lldp_rem_mgmt_addr_octet_2
279 + index: 7
280 + - tag: lldp_rem_mgmt_addr_octet_3
281 + index: 8
282 + - tag: lldp_rem_mgmt_addr_octet_4
283 + index: 9
284 + - tag: lldp_rem_mgmt_addr_octet_5
285 + index: 10
286 + - tag: lldp_rem_mgmt_addr_octet_6
287 + index: 11
288 + - tag: lldp_rem_mgmt_addr_octet_7
289 + index: 12
290 + - tag: lldp_rem_mgmt_addr_octet_8
291 + index: 13
292 + - tag: lldp_rem_mgmt_addr_octet_9
293 + index: 14
294 + - tag: lldp_rem_mgmt_addr_octet_10
295 + index: 15
296 + - tag: lldp_rem_mgmt_addr_octet_11
297 + index: 16
298 + - tag: lldp_rem_mgmt_addr_octet_12
299 + index: 17
300 + - tag: lldp_rem_mgmt_addr_octet_13
301 + index: 18
302 + - tag: lldp_rem_mgmt_addr_octet_14
303 + index: 19
304 + - tag: lldp_rem_mgmt_addr_octet_15
305 + index: 20
306 + - tag: lldp_rem_mgmt_addr_octet_16
307 + index: 21
308 + - tag: lldp_rem_mgmt_addr_if_subtype
309 + symbol:
310 + OID: 1.0.8802.1.1.2.1.4.2.1.3
311 + name: lldpRemManAddrIfSubtype
312 + - tag: lldp_rem_mgmt_addr_if_id
313 + symbol:
314 + OID: 1.0.8802.1.1.2.1.4.2.1.4
315 + name: lldpRemManAddrIfId
316 + - tag: lldp_rem_mgmt_addr_oid
317 + symbol:
318 + OID: 1.0.8802.1.1.2.1.4.2.1.5
319 + name: lldpRemManAddrOID
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-topology-q-bridge-mib.yaml new
+59
@@ -0,0 +1,59 @@
1 +# Supplemental topology profile for VLAN-aware bridge/FDB enrichment.
2 +# Source: Q-BRIDGE-MIB.
3 +#
4 +# Why:
5 +# - dot1qTpFdbTable provides per-FDB-domain MAC learning entries.
6 +# - dot1qVlanCurrentTable maps FDB domain IDs to VLAN IDs where available.
7 +
8 +metrics:
9 + # Q-BRIDGE-MIB: VLAN/FDB-domain forwarding table (MAC -> bridge port).
10 + - MIB: Q-BRIDGE-MIB
11 + table:
12 + OID: 1.3.6.1.2.1.17.7.1.2.2.1
13 + name: dot1qTpFdbTable
14 + symbols:
15 + # Use port column as numeric row anchor.
16 + - OID: 1.3.6.1.2.1.17.7.1.2.2.1.2
17 + name: _topology_qbridge_fdb_entry
18 + metric_tags:
19 + - tag: dot1q_fdb_id
20 + index: 1
21 + - tag: dot1q_fdb_mac
22 + symbol:
23 + OID: 1.3.6.1.2.1.17.7.1.2.2.1.1
24 + name: dot1qTpFdbAddress
25 + format: hex
26 + - tag: dot1q_fdb_bridge_port
27 + symbol:
28 + OID: 1.3.6.1.2.1.17.7.1.2.2.1.2
29 + name: dot1qTpFdbPort
30 + - tag: dot1q_fdb_status
31 + symbol:
32 + OID: 1.3.6.1.2.1.17.7.1.2.2.1.3
33 + name: dot1qTpFdbStatus
34 + mapping:
35 + 1: other
36 + 2: invalid
37 + 3: learned
38 + 4: self
39 + 5: mgmt
40 +
41 + # Q-BRIDGE-MIB: maps FDB domain ID to VLAN ID.
42 + - MIB: Q-BRIDGE-MIB
43 + table:
44 + OID: 1.3.6.1.2.1.17.7.1.4.2.1
45 + name: dot1qVlanCurrentTable
46 + symbols:
47 + - OID: 1.3.6.1.2.1.17.7.1.4.2.1.3
48 + name: _topology_qbridge_vlan_entry
49 + metric_tags:
50 + # Some devices expose timemark+vlan index, others only vlan.
51 + # Keep both and resolve in code.
52 + - tag: dot1q_vlan_id_idx1
53 + index: 1
54 + - tag: dot1q_vlan_id
55 + index: 2
56 + - tag: dot1q_vlan_fdb_id
57 + symbol:
58 + OID: 1.3.6.1.2.1.17.7.1.4.2.1.3
59 + name: dot1qVlanCurrentFdbId
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-topology-stp-mib.yaml new
+64
@@ -0,0 +1,64 @@
1 +# Supplemental topology profile for STP-derived bridge links.
2 +# Sources: BRIDGE-MIB dot1dBase + dot1dStpPortTable.
3 +
4 +metadata:
5 + device:
6 + fields:
7 + bridge_base_address:
8 + symbol:
9 + OID: 1.3.6.1.2.1.17.1.1
10 + name: dot1dBaseBridgeAddress
11 + format: hex
12 + stp_designated_root:
13 + symbol:
14 + OID: 1.3.6.1.2.1.17.2.5
15 + name: dot1dStpDesignatedRoot
16 + format: hex
17 +
18 +metrics:
19 + - MIB: BRIDGE-MIB
20 + table:
21 + OID: 1.3.6.1.2.1.17.2.15.1
22 + name: dot1dStpPortTable
23 + symbols:
24 + # Numeric anchor to force row emission.
25 + - OID: 1.3.6.1.2.1.17.2.15.1.3
26 + name: _topology_stp_port_entry
27 + metric_tags:
28 + - tag: stp_port
29 + index: 1
30 + - tag: stp_port_priority
31 + symbol:
32 + OID: 1.3.6.1.2.1.17.2.15.1.2
33 + name: dot1dStpPortPriority
34 + - tag: stp_port_state
35 + symbol:
36 + OID: 1.3.6.1.2.1.17.2.15.1.3
37 + name: dot1dStpPortState
38 + - tag: stp_port_enable
39 + symbol:
40 + OID: 1.3.6.1.2.1.17.2.15.1.4
41 + name: dot1dStpPortEnable
42 + - tag: stp_port_path_cost
43 + symbol:
44 + OID: 1.3.6.1.2.1.17.2.15.1.5
45 + name: dot1dStpPortPathCost
46 + - tag: stp_port_designated_root
47 + symbol:
48 + OID: 1.3.6.1.2.1.17.2.15.1.6
49 + name: dot1dStpPortDesignatedRoot
50 + format: hex
51 + - tag: stp_port_designated_cost
52 + symbol:
53 + OID: 1.3.6.1.2.1.17.2.15.1.7
54 + name: dot1dStpPortDesignatedCost
55 + - tag: stp_port_designated_bridge
56 + symbol:
57 + OID: 1.3.6.1.2.1.17.2.15.1.8
58 + name: dot1dStpPortDesignatedBridge
59 + format: hex
60 + - tag: stp_port_designated_port
61 + symbol:
62 + OID: 1.3.6.1.2.1.17.2.15.1.9
63 + name: dot1dStpPortDesignatedPort
64 + format: hex
src/go/plugin/go.d/config/go.d/snmp.profiles/default/alcatel-lucent.yaml
+4
@@ -1,6 +1,10 @@
1 extends:
2 - _system-base.yaml
3 - _std-if-mib.yaml
4 + - _std-lldp-mib.yaml
5 + - _std-topology-fdb-arp-mib.yaml
6 + - _std-topology-q-bridge-mib.yaml
7 + - _std-topology-stp-mib.yaml
8
9 metadata:
10 device:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/cisco-sb.yaml
+5
@@ -1,6 +1,11 @@
1 extends:
2 - _system-base.yaml
3 - _std-if-mib.yaml
4 + - _std-lldp-mib.yaml
5 + - _std-cdp-mib.yaml
6 + - _std-topology-fdb-arp-mib.yaml
7 + - _std-topology-q-bridge-mib.yaml
8 + - _std-topology-stp-mib.yaml
9 # This profile does not import cisco.yaml on purpose
10
11 selector:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/dlink-dgs-switch.yaml
+4
@@ -1,5 +1,9 @@
1 extends:
2 - dlink.yaml
3 + - _std-lldp-mib.yaml
4 + - _std-topology-fdb-arp-mib.yaml
5 + - _std-topology-q-bridge-mib.yaml
6 + - _std-topology-stp-mib.yaml
7
8 selector:
9 - sysobjectid:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/mikrotik-router.yaml
+4
@@ -1,6 +1,10 @@
1 extends:
2 - _system-base.yaml
3 - _std-if-mib.yaml
4 + - _std-lldp-mib.yaml
5 + - _std-topology-fdb-arp-mib.yaml
6 + - _std-topology-q-bridge-mib.yaml
7 + - _std-topology-stp-mib.yaml
8 - _mikrotik-ipsec.yaml
9
10 selector:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/zyxel-switch.yaml
+4
@@ -1,6 +1,10 @@
1 extends:
2 - _system-base.yaml
3 - _std-if-mib.yaml
4 + - _std-lldp-mib.yaml
5 + - _std-topology-fdb-arp-mib.yaml
6 + - _std-topology-q-bridge-mib.yaml
7 + - _std-topology-stp-mib.yaml
8
9 selector:
10 - sysobjectid:
src/go/plugin/go.d/config/go.d/snmp_topology.conf new
+8
@@ -0,0 +1,8 @@
1 +## SNMP Topology Discovery
2 +## Discovers network topology (LLDP, CDP, FDB, ARP, STP) from SNMP devices.
3 +## Devices are discovered automatically from running SNMP collector jobs.
4 +
5 +jobs:
6 + - name: snmp_topology
7 + update_every: 60
8 + refresh_every: 30m
src/go/tools/topology-oui-dataset/main.go new
+199
@@ -0,0 +1,199 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package main
4 +
5 +import (
6 + "bufio"
7 + "encoding/csv"
8 + "flag"
9 + "fmt"
10 + "io"
11 + "net/http"
12 + "os"
13 + "regexp"
14 + "sort"
15 + "strings"
16 + "time"
17 +)
18 +
19 +type ieeeSource struct {
20 + name string
21 + url string
22 +}
23 +
24 +var ieeeSources = []ieeeSource{
25 + {name: "ieee_oui", url: "https://standards-oui.ieee.org/oui/oui.csv"},
26 + {name: "ieee_cid", url: "https://standards-oui.ieee.org/cid/cid.csv"},
27 + {name: "ieee_oui36", url: "https://standards-oui.ieee.org/oui36/oui36.csv"},
28 + {name: "ieee_iab", url: "https://standards-oui.ieee.org/iab/iab.csv"},
29 +}
30 +
31 +var nonHex = regexp.MustCompile(`[^0-9A-Fa-f]`)
32 +var httpClient = &http.Client{Timeout: 30 * time.Second}
33 +
34 +func main() {
35 + var outputPath string
36 + flag.StringVar(&outputPath, "out", "", "output TSV path (required)")
37 + flag.Parse()
38 +
39 + if strings.TrimSpace(outputPath) == "" {
40 + fmt.Fprintln(os.Stderr, "missing required -out argument")
41 + os.Exit(2)
42 + }
43 +
44 + index, err := buildVendorIndex()
45 + if err != nil {
46 + fmt.Fprintf(os.Stderr, "failed to build OUI index: %v\n", err)
47 + os.Exit(1)
48 + }
49 + if err := writeDataset(outputPath, index); err != nil {
50 + fmt.Fprintf(os.Stderr, "failed to write dataset: %v\n", err)
51 + os.Exit(1)
52 + }
53 + fmt.Printf("wrote %d entries to %s\n", len(index), outputPath)
54 +}
55 +
56 +func buildVendorIndex() (map[string]string, error) {
57 + prefixToVendor := make(map[string]string, 120000)
58 +
59 + for _, src := range ieeeSources {
60 + records, err := fetchCSV(src.url)
61 + if err != nil {
62 + return nil, fmt.Errorf("%s: %w", src.name, err)
63 + }
64 + for i, record := range records {
65 + if i == 0 { // header
66 + continue
67 + }
68 + if len(record) < 3 {
69 + continue
70 + }
71 + registry := strings.TrimSpace(record[0])
72 + assignment := normalizeAssignment(record[1])
73 + vendor := strings.TrimSpace(record[2])
74 + if assignment == "" || vendor == "" {
75 + continue
76 + }
77 + prefixLen := prefixLengthForRegistry(registry, assignment)
78 + if prefixLen == 0 || len(assignment) < prefixLen {
79 + continue
80 + }
81 + prefix := assignment[:prefixLen]
82 +
83 + if existing, ok := prefixToVendor[prefix]; ok {
84 + // Prefer the more descriptive name on conflicts.
85 + if len(existing) >= len(vendor) {
86 + continue
87 + }
88 + }
89 + prefixToVendor[prefix] = vendor
90 + }
91 + }
92 + return prefixToVendor, nil
93 +}
94 +
95 +func fetchCSV(url string) ([][]string, error) {
96 + req, err := http.NewRequest(http.MethodGet, url, nil)
97 + if err != nil {
98 + return nil, err
99 + }
100 + req.Header.Set("User-Agent", "netdata-topology-oui-dataset-updater/1.0")
101 +
102 + resp, err := httpClient.Do(req)
103 + if err != nil {
104 + return nil, err
105 + }
106 + defer resp.Body.Close()
107 +
108 + if resp.StatusCode != http.StatusOK {
109 + return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
110 + }
111 +
112 + reader := csv.NewReader(resp.Body)
113 + reader.FieldsPerRecord = -1
114 + records := make([][]string, 0, 65536)
115 + for {
116 + record, err := reader.Read()
117 + if err == io.EOF {
118 + break
119 + }
120 + if err != nil {
121 + return nil, err
122 + }
123 + records = append(records, record)
124 + }
125 + return records, nil
126 +}
127 +
128 +func normalizeAssignment(raw string) string {
129 + raw = strings.TrimSpace(raw)
130 + raw = nonHex.ReplaceAllString(raw, "")
131 + raw = strings.ToUpper(raw)
132 + if len(raw) > 12 {
133 + raw = raw[:12]
134 + }
135 + return raw
136 +}
137 +
138 +func prefixLengthForRegistry(registry, assignment string) int {
139 + switch strings.ToUpper(strings.TrimSpace(registry)) {
140 + case "MA-S", "IAB":
141 + if len(assignment) >= 9 {
142 + return 9
143 + }
144 + case "MA-M":
145 + if len(assignment) >= 7 {
146 + return 7
147 + }
148 + case "MA-L", "CID":
149 + if len(assignment) >= 6 {
150 + return 6
151 + }
152 + }
153 + // Fallback for non-standard/unknown registry values.
154 + if len(assignment) >= 9 {
155 + return 9
156 + }
157 + if len(assignment) >= 7 {
158 + return 7
159 + }
160 + if len(assignment) >= 6 {
161 + return 6
162 + }
163 + return 0
164 +}
165 +
166 +func writeDataset(path string, index map[string]string) error {
167 + f, err := os.Create(path)
168 + if err != nil {
169 + return err
170 + }
171 + defer f.Close()
172 +
173 + w := bufio.NewWriterSize(f, 1<<20)
174 + defer w.Flush()
175 +
176 + prefixes := make([]string, 0, len(index))
177 + for prefix := range index {
178 + prefixes = append(prefixes, prefix)
179 + }
180 + sort.Slice(prefixes, func(i, j int) bool {
181 + if len(prefixes[i]) != len(prefixes[j]) {
182 + return len(prefixes[i]) > len(prefixes[j])
183 + }
184 + return prefixes[i] < prefixes[j]
185 + })
186 +
187 + fmt.Fprintf(w, "# Netdata Topology OUI Vendor Dataset\n")
188 + fmt.Fprintf(w, "# Generated: %s\n", time.Now().UTC().Format(time.RFC3339))
189 + fmt.Fprintf(w, "# Sources:\n")
190 + for _, src := range ieeeSources {
191 + fmt.Fprintf(w, "# - %s %s\n", src.name, src.url)
192 + }
193 + fmt.Fprintf(w, "# Format: <HEX_PREFIX>\\t<VENDOR>\n")
194 +
195 + for _, prefix := range prefixes {
196 + fmt.Fprintf(w, "%s\t%s\n", prefix, index[prefix])
197 + }
198 + return nil
199 +}
src/go/tools/topology-parity-evidence/main.go new
+2819
@@ -0,0 +1,2819 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package main
4 +
5 +import (
6 + "bufio"
7 + "bytes"
8 + "crypto/sha256"
9 + "encoding/csv"
10 + "encoding/json"
11 + "errors"
12 + "flag"
13 + "fmt"
14 + "io"
15 + "io/fs"
16 + "os"
17 + "os/exec"
18 + "path"
19 + "path/filepath"
20 + "regexp"
21 + "slices"
22 + "sort"
23 + "strconv"
24 + "strings"
25 + "time"
26 +
27 + "github.com/netdata/netdata/go/plugins/pkg/topology/engine"
28 + "github.com/netdata/netdata/go/plugins/pkg/topology/engine/parity"
29 +)
30 +
31 +const (
32 + defaultEnlinkdRoot = "/tmp/topology-library-repos/enlinkd"
33 + defaultFixtureSourceRel = "features/enlinkd/tests/src/test/resources/linkd"
34 + defaultFixtureMirrorRel = "testdata/snmp/enlinkd/upstream/linkd"
35 + defaultManifestRootRel = "testdata/snmp/enlinkd"
36 + defaultEvidenceRel = "testdata/snmp/parity-evidence"
37 + defaultScopedTestsRelEn = "features/enlinkd/tests/src/test/java/org/opennms/netmgt/enlinkd"
38 + defaultScopedTestsRelNB = "features/enlinkd/tests/src/test/java/org/opennms/netmgt/nb"
39 + defaultFixtureInventory = "enlinkd-fixture-inventory.csv"
40 + defaultMethodInventory = "enlinkd-test-method-inventory.csv"
41 + defaultAssertionInventory = "enlinkd-assertion-inventory.csv"
42 + defaultAssertionMapping = "assertion-mapping.csv"
43 + defaultSummaryFile = "parity-summary.json"
44 + defaultPhase2ReportFile = "phase2-parity-report.json"
45 + defaultPhase2GapFile = "phase2-gap-report.md"
46 + defaultOfficeReportFile = "office-live-reliability-report.md"
47 + defaultOracleDiffJSONFile = "behavior-oracle-diff.json"
48 + defaultOracleDiffMDFile = "behavior-oracle-diff.md"
49 +)
50 +
51 +var (
52 + testAnnotationRE = regexp.MustCompile(`^\s*@Test\b`)
53 + packageRE = regexp.MustCompile(`^\s*package\s+([A-Za-z0-9_.]+)\s*;`)
54 + classRE = regexp.MustCompile(`\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
55 + identifierRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
56 + assertionCallRE = regexp.MustCompile(`\b(assert[A-Za-z0-9_]*)\s*\(`)
57 + testFileNameITRE = regexp.MustCompile(`IT\.java$`)
58 + testFileNameTestR = regexp.MustCompile(`Test\.java$`)
59 +)
60 +
61 +type options struct {
62 + mode string
63 + enlinkdRoot string
64 + fixtureSrcRel string
65 + fixtureDstPath string
66 + manifestRoot string
67 + evidencePath string
68 + summaryPath string
69 + phase2Report string
70 + phase2Gap string
71 + oracleDiffJSON string
72 + oracleDiffMD string
73 +}
74 +
75 +type fixtureRow struct {
76 + Scenario string
77 + File string
78 + RelativePath string
79 + SHA256 string
80 + SizeBytes int64
81 + UpstreamPath string
82 +}
83 +
84 +type methodRow struct {
85 + Class string
86 + Method string
87 + SourceFile string
88 + ProtocolScope string
89 +}
90 +
91 +type assertionRow struct {
92 + Class string
93 + Method string
94 + AssertionID string
95 + SourceFile string
96 + Line int
97 + AssertCall string
98 + ProtocolScope string
99 +}
100 +
101 +type methodRange struct {
102 + Name string
103 + Start int
104 + End int
105 + Scope string
106 +}
107 +
108 +type assertionCandidate struct {
109 + Line int
110 + Call string
111 +}
112 +
113 +type mappingStats struct {
114 + MappedAssertions int
115 + TotalAssertions int
116 + MappedMethods int
117 + TotalMethods int
118 + MappedTestFiles int
119 + TotalTestFiles int
120 +}
121 +
122 +type protocolSummary struct {
123 + Protocol string `json:"protocol"`
124 + Total int `json:"total"`
125 + Passed int `json:"passed"`
126 + Failed int `json:"failed"`
127 +}
128 +
129 +type scenarioSummary struct {
130 + ID string `json:"id"`
131 + Manifest string `json:"manifest"`
132 + Protocols []string `json:"protocols"`
133 + Passed bool `json:"passed"`
134 + Failures []string `json:"failures,omitempty"`
135 +}
136 +
137 +type goTestSummary struct {
138 + Package string `json:"package"`
139 + Passed bool `json:"passed"`
140 + Error string `json:"error,omitempty"`
141 +}
142 +
143 +type paritySummary struct {
144 + Version string `json:"version"`
145 + FixtureScenarios int `json:"fixture_scenarios"`
146 + FixtureFiles int `json:"fixture_files"`
147 + TotalScenarios int `json:"total_scenarios"`
148 + ScenariosPassed int `json:"scenarios_passed"`
149 + ScenariosFailed int `json:"scenarios_failed"`
150 + TotalTestsMapped int `json:"total_tests_mapped"`
151 + TotalTestsInventory int `json:"total_tests_inventory"`
152 + TotalAssertionsMapped int `json:"total_assertions_mapped"`
153 + TotalAssertionsTotal int `json:"total_assertions_inventory"`
154 + ProtocolCounts []protocolSummary `json:"protocol_counts"`
155 + ScenarioResults []scenarioSummary `json:"scenario_results"`
156 + GoTests []goTestSummary `json:"go_tests"`
157 + Determinism struct {
158 + Runs int `json:"runs"`
159 + ByteIdentical bool `json:"byte_identical"`
160 + } `json:"determinism"`
161 +}
162 +
163 +type phase2SuiteSummary struct {
164 + FixtureScenarios int `json:"fixture_scenarios"`
165 + FixtureFiles int `json:"fixture_files"`
166 + TotalScenarios int `json:"total_scenarios"`
167 + ScenariosPassed int `json:"scenarios_passed"`
168 + ScenariosFailed int `json:"scenarios_failed"`
169 + TotalTestsMapped int `json:"total_tests_mapped"`
170 + TotalTestsInventory int `json:"total_tests_inventory"`
171 + TotalAssertionsMapped int `json:"total_assertions_mapped"`
172 + TotalAssertionsTotal int `json:"total_assertions_inventory"`
173 + ProtocolCounts []protocolSummary `json:"protocol_counts"`
174 +}
175 +
176 +type phase2CheckStatus struct {
177 + Name string `json:"name"`
178 + Status string `json:"status"`
179 + ChecksPassed int `json:"checks_passed"`
180 + ChecksTotal int `json:"checks_total"`
181 + Commands []string `json:"commands"`
182 + Failed []string `json:"failed,omitempty"`
183 + Missing []string `json:"missing,omitempty"`
184 + Errors []string `json:"errors,omitempty"`
185 +}
186 +
187 +type phase2AssertionCoverage struct {
188 + Status string `json:"status"`
189 + InScopeTotal int `json:"in_scope_total"`
190 + InScopePorted int `json:"in_scope_ported"`
191 + InScopeNotApplicable int `json:"in_scope_not_applicable_approved"`
192 + InScopeUnmapped int `json:"in_scope_unmapped"`
193 + OutOfScopePorted int `json:"out_of_scope_ported"`
194 + OutOfScopeNotApplicable int `json:"out_of_scope_not_applicable_approved"`
195 +}
196 +
197 +type phase2DeferredGap struct {
198 + ID string `json:"id"`
199 + Description string `json:"description"`
200 + Reason string `json:"reason"`
201 + Evidence string `json:"evidence"`
202 +}
203 +
204 +type phase2Report struct {
205 + Version string `json:"version"`
206 + GeneratedAtUTC string `json:"generated_at_utc"`
207 + Status string `json:"status"`
208 + Suite phase2SuiteSummary `json:"suite"`
209 + ModuleParity []phase2CheckStatus `json:"module_parity"`
210 + ReversePairQuality phase2CheckStatus `json:"reverse_pair_quality"`
211 + IdentityMergeQuality phase2CheckStatus `json:"identity_merge_quality"`
212 + AssertionCoverage phase2AssertionCoverage `json:"assertion_coverage"`
213 + DeferredGaps []phase2DeferredGap `json:"deferred_gaps"`
214 +}
215 +
216 +type behaviorOracleReport struct {
217 + Version string `json:"version"`
218 + GeneratedAtUTC string `json:"generated_at_utc"`
219 + Status string `json:"status"`
220 + Scope behaviorOracleScope `json:"scope"`
221 + Totals behaviorOracleTotals `json:"totals"`
222 + Scenarios []behaviorOracleScenarioReport `json:"scenarios"`
223 +}
224 +
225 +type behaviorOracleScope struct {
226 + Protocols []string `json:"protocols"`
227 +}
228 +
229 +type behaviorOracleTotals struct {
230 + ScenariosTotal int `json:"scenarios_total"`
231 + ScenariosInScope int `json:"scenarios_in_scope"`
232 + ScenariosSkipped int `json:"scenarios_skipped"`
233 + ScenariosZeroDiff int `json:"scenarios_zero_diff"`
234 + ScenariosWithDiffs int `json:"scenarios_with_diffs"`
235 + ScenariosWithFailures int `json:"scenarios_with_failures"`
236 +}
237 +
238 +type behaviorOracleScenarioReport struct {
239 + ID string `json:"id"`
240 + Manifest string `json:"manifest"`
241 + Protocols []string `json:"protocols"`
242 + InScope bool `json:"in_scope"`
243 + FixtureInputs []behaviorOracleFixture `json:"fixture_inputs,omitempty"`
244 + Expected behaviorOracleSnapshot `json:"expected"`
245 + Actual behaviorOracleSnapshot `json:"actual"`
246 + Diff behaviorOracleDiff `json:"diff"`
247 + Status string `json:"status"`
248 + Errors []string `json:"errors,omitempty"`
249 +}
250 +
251 +type behaviorOracleFixture struct {
252 + DeviceID string `json:"device_id"`
253 + Hostname string `json:"hostname,omitempty"`
254 + Address string `json:"address,omitempty"`
255 + WalkFile string `json:"walk_file"`
256 + SHA256 string `json:"sha256"`
257 + SizeBytes int64 `json:"size_bytes"`
258 +}
259 +
260 +type behaviorOracleSnapshot struct {
261 + Devices []parity.GoldenDevice `json:"devices"`
262 + Adjacencies []parity.GoldenAdjacency `json:"adjacencies"`
263 + Metadata behaviorOracleMetadata `json:"metadata"`
264 +}
265 +
266 +type behaviorOracleMetadata struct {
267 + Devices int `json:"devices"`
268 + DirectionalAdjacencies int `json:"directional_adjacencies"`
269 +}
270 +
271 +type behaviorOracleDiff struct {
272 + ZeroDiff bool `json:"zero_diff"`
273 + MissingDevices []parity.GoldenDevice `json:"missing_devices,omitempty"`
274 + UnexpectedDevices []parity.GoldenDevice `json:"unexpected_devices,omitempty"`
275 + HostnameMismatches []behaviorOracleDeviceDelta `json:"hostname_mismatches,omitempty"`
276 + MissingAdjacencies []parity.GoldenAdjacency `json:"missing_adjacencies,omitempty"`
277 + UnexpectedAdjacencies []parity.GoldenAdjacency `json:"unexpected_adjacencies,omitempty"`
278 + MetadataMismatches []behaviorOracleCountDelta `json:"metadata_mismatches,omitempty"`
279 +}
280 +
281 +type behaviorOracleDeviceDelta struct {
282 + DeviceID string `json:"device_id"`
283 + Expected string `json:"expected"`
284 + Actual string `json:"actual"`
285 +}
286 +
287 +type behaviorOracleCountDelta struct {
288 + Field string `json:"field"`
289 + Expected int `json:"expected"`
290 + Actual int `json:"actual"`
291 +}
292 +
293 +func main() {
294 + opts := parseOptions()
295 +
296 + switch opts.mode {
297 + case "sync":
298 + if err := runSync(opts); err != nil {
299 + fmt.Fprintf(os.Stderr, "topology-parity-evidence sync failed: %v\n", err)
300 + os.Exit(1)
301 + }
302 + case "verify":
303 + if err := runVerify(opts); err != nil {
304 + fmt.Fprintf(os.Stderr, "topology-parity-evidence verify failed: %v\n", err)
305 + os.Exit(1)
306 + }
307 + case "suite":
308 + if err := runSuite(opts); err != nil {
309 + fmt.Fprintf(os.Stderr, "topology-parity-evidence suite failed: %v\n", err)
310 + os.Exit(1)
311 + }
312 + case "phase2":
313 + if err := runPhase2(opts); err != nil {
314 + fmt.Fprintf(os.Stderr, "topology-parity-evidence phase2 failed: %v\n", err)
315 + os.Exit(1)
316 + }
317 + case "oracle-diff":
318 + if err := runOracleDiff(opts); err != nil {
319 + fmt.Fprintf(os.Stderr, "topology-parity-evidence oracle-diff failed: %v\n", err)
320 + os.Exit(1)
321 + }
322 + default:
323 + fmt.Fprintf(os.Stderr, "unsupported mode %q (want sync|verify|suite|phase2|oracle-diff)\n", opts.mode)
324 + os.Exit(1)
325 + }
326 +}
327 +
328 +func parseOptions() options {
329 + var opts options
330 + flag.StringVar(&opts.mode, "mode", "sync", "sync|verify|suite|phase2|oracle-diff")
331 + flag.StringVar(&opts.enlinkdRoot, "enlinkd-root", defaultEnlinkdRoot, "path to enlinkd checkout root")
332 + flag.StringVar(&opts.fixtureSrcRel, "fixture-source-rel", defaultFixtureSourceRel, "fixture source path relative to enlinkd root")
333 + flag.StringVar(&opts.fixtureDstPath, "fixture-dst", defaultFixtureMirrorRel, "fixture mirror destination path")
334 + flag.StringVar(&opts.manifestRoot, "manifest-root", defaultManifestRootRel, "local manifest root path")
335 + flag.StringVar(&opts.evidencePath, "evidence-dir", defaultEvidenceRel, "evidence output directory")
336 + flag.StringVar(&opts.summaryPath, "summary-file", filepath.Join(defaultEvidenceRel, defaultSummaryFile), "parity summary output file")
337 + flag.StringVar(&opts.phase2Report, "phase2-report-file", filepath.Join(defaultEvidenceRel, defaultPhase2ReportFile), "phase2 parity report output file")
338 + flag.StringVar(&opts.phase2Gap, "phase2-gap-file", filepath.Join(defaultEvidenceRel, defaultPhase2GapFile), "phase2 gap report output file")
339 + flag.StringVar(&opts.oracleDiffJSON, "oracle-diff-json", filepath.Join(defaultEvidenceRel, defaultOracleDiffJSONFile), "behavior oracle diff report (machine-readable JSON)")
340 + flag.StringVar(&opts.oracleDiffMD, "oracle-diff-md", filepath.Join(defaultEvidenceRel, defaultOracleDiffMDFile), "behavior oracle diff report (human-readable Markdown)")
341 + flag.Parse()
342 + return opts
343 +}
344 +
345 +func runSync(opts options) error {
346 + srcRoot := filepath.Join(opts.enlinkdRoot, opts.fixtureSrcRel)
347 + if err := requireDir(srcRoot); err != nil {
348 + return fmt.Errorf("fixture source root: %w", err)
349 + }
350 +
351 + if err := syncFixtureMirror(srcRoot, opts.fixtureDstPath); err != nil {
352 + return fmt.Errorf("sync fixture mirror: %w", err)
353 + }
354 +
355 + fixtureRows, err := collectFixtureInventory(opts.fixtureDstPath, opts.fixtureSrcRel)
356 + if err != nil {
357 + return fmt.Errorf("collect fixture inventory: %w", err)
358 + }
359 +
360 + testFiles, err := listScopedTestFiles(opts.enlinkdRoot)
361 + if err != nil {
362 + return fmt.Errorf("list scoped tests: %w", err)
363 + }
364 + assertionFiles, err := listScopedJavaFiles(opts.enlinkdRoot)
365 + if err != nil {
366 + return fmt.Errorf("list scoped java files: %w", err)
367 + }
368 + methodRows, assertionRows, err := collectTestAndAssertionInventories(opts.enlinkdRoot, testFiles, assertionFiles)
369 + if err != nil {
370 + return err
371 + }
372 +
373 + if err := os.MkdirAll(opts.evidencePath, 0o755); err != nil {
374 + return fmt.Errorf("mkdir evidence dir: %w", err)
375 + }
376 + if err := writeFixtureInventoryCSV(filepath.Join(opts.evidencePath, defaultFixtureInventory), fixtureRows); err != nil {
377 + return err
378 + }
379 + if err := writeMethodInventoryCSV(filepath.Join(opts.evidencePath, defaultMethodInventory), methodRows); err != nil {
380 + return err
381 + }
382 + if err := writeAssertionInventoryCSV(filepath.Join(opts.evidencePath, defaultAssertionInventory), assertionRows); err != nil {
383 + return err
384 + }
385 +
386 + fmt.Printf("sync complete\n")
387 + fmt.Printf("fixture scenarios: %d\n", countDistinctScenarios(fixtureRows))
388 + fmt.Printf("fixture files: %d\n", len(fixtureRows))
389 + fmt.Printf("test files: %d\n", countDistinctFiles(methodRows))
390 + fmt.Printf("test methods: %d\n", len(methodRows))
391 + fmt.Printf("assertions: %d\n", len(assertionRows))
392 + return nil
393 +}
394 +
395 +func runVerify(opts options) error {
396 + localRows, err := verifyFixtureInventory(opts)
397 + if err != nil {
398 + return err
399 + }
400 +
401 + fmt.Printf("verify complete\n")
402 + fmt.Printf("fixture scenarios: %d\n", countDistinctScenarios(localRows))
403 + fmt.Printf("fixture files: %d\n", len(localRows))
404 + return nil
405 +}
406 +
407 +func verifyFixtureInventory(opts options) ([]fixtureRow, error) {
408 + srcRoot := filepath.Join(opts.enlinkdRoot, opts.fixtureSrcRel)
409 + if err := requireDir(srcRoot); err != nil {
410 + return nil, fmt.Errorf("fixture source root: %w", err)
411 + }
412 + if err := requireDir(opts.fixtureDstPath); err != nil {
413 + return nil, fmt.Errorf("fixture destination root: %w", err)
414 + }
415 +
416 + upstreamRows, err := collectFixtureInventory(srcRoot, opts.fixtureSrcRel)
417 + if err != nil {
418 + return nil, fmt.Errorf("collect upstream inventory: %w", err)
419 + }
420 + localRows, err := collectFixtureInventory(opts.fixtureDstPath, opts.fixtureSrcRel)
421 + if err != nil {
422 + return nil, fmt.Errorf("collect local inventory: %w", err)
423 + }
424 + if err := compareFixtureInventories(upstreamRows, localRows); err != nil {
425 + return nil, err
426 + }
427 +
428 + inventoryPath := filepath.Join(opts.evidencePath, defaultFixtureInventory)
429 + fileRows, err := readFixtureInventoryCSV(inventoryPath)
430 + if err != nil {
431 + return nil, err
432 + }
433 + if err := compareFixtureInventories(localRows, fileRows); err != nil {
434 + return nil, fmt.Errorf("inventory file mismatch (%s): %w", inventoryPath, err)
435 + }
436 +
437 + return localRows, nil
438 +}
439 +
440 +func runSuite(opts options) error {
441 + summaryA, err := buildSuiteSummary(opts)
442 + if err != nil {
443 + return err
444 + }
445 + summaryB, err := buildSuiteSummary(opts)
446 + if err != nil {
447 + return err
448 + }
449 +
450 + baseA, err := marshalSummaryJSON(summaryA)
451 + if err != nil {
452 + return err
453 + }
454 + baseB, err := marshalSummaryJSON(summaryB)
455 + if err != nil {
456 + return err
457 + }
458 + byteIdentical := bytes.Equal(baseA, baseB)
459 +
460 + summaryA.Determinism.Runs = 2
461 + summaryA.Determinism.ByteIdentical = byteIdentical
462 +
463 + outBytes, err := marshalSummaryJSON(summaryA)
464 + if err != nil {
465 + return err
466 + }
467 + if err := os.MkdirAll(filepath.Dir(opts.summaryPath), 0o755); err != nil {
468 + return fmt.Errorf("create summary directory: %w", err)
469 + }
470 + if err := os.WriteFile(opts.summaryPath, outBytes, 0o644); err != nil {
471 + return fmt.Errorf("write summary %q: %w", opts.summaryPath, err)
472 + }
473 +
474 + fmt.Printf("suite complete\n")
475 + fmt.Printf("summary file: %s\n", opts.summaryPath)
476 + fmt.Printf("total scenarios: %d (passed=%d failed=%d)\n", summaryA.TotalScenarios, summaryA.ScenariosPassed, summaryA.ScenariosFailed)
477 + fmt.Printf("mapped tests/assertions: %d/%d tests, %d/%d assertions\n",
478 + summaryA.TotalTestsMapped, summaryA.TotalTestsInventory,
479 + summaryA.TotalAssertionsMapped, summaryA.TotalAssertionsTotal)
480 +
481 + if !byteIdentical {
482 + return fmt.Errorf("determinism check failed: canonical summary JSON differs across repeated runs")
483 + }
484 + for _, testResult := range summaryA.GoTests {
485 + if !testResult.Passed {
486 + return fmt.Errorf("go test failed for %s: %s", testResult.Package, testResult.Error)
487 + }
488 + }
489 + if summaryA.ScenariosFailed > 0 {
490 + return fmt.Errorf("%d scenario(s) failed parity validation", summaryA.ScenariosFailed)
491 + }
492 + return nil
493 +}
494 +
495 +func runOracleDiff(opts options) error {
496 + if _, err := verifyFixtureInventory(opts); err != nil {
497 + return err
498 + }
499 +
500 + report, err := buildBehaviorOracleDiffReport(opts.manifestRoot)
501 + if err != nil {
502 + return err
503 + }
504 +
505 + payload, err := json.MarshalIndent(report, "", " ")
506 + if err != nil {
507 + return fmt.Errorf("marshal behavior oracle report: %w", err)
508 + }
509 + payload = append(payload, '\n')
510 +
511 + if err := os.MkdirAll(filepath.Dir(opts.oracleDiffJSON), 0o755); err != nil {
512 + return fmt.Errorf("create oracle diff json directory: %w", err)
513 + }
514 + if err := os.WriteFile(opts.oracleDiffJSON, payload, 0o644); err != nil {
515 + return fmt.Errorf("write oracle diff json %q: %w", opts.oracleDiffJSON, err)
516 + }
517 +
518 + markdown := buildBehaviorOracleDiffMarkdown(report)
519 + if err := os.MkdirAll(filepath.Dir(opts.oracleDiffMD), 0o755); err != nil {
520 + return fmt.Errorf("create oracle diff markdown directory: %w", err)
521 + }
522 + if err := os.WriteFile(opts.oracleDiffMD, []byte(markdown), 0o644); err != nil {
523 + return fmt.Errorf("write oracle diff markdown %q: %w", opts.oracleDiffMD, err)
524 + }
525 +
526 + fmt.Printf("oracle diff complete\n")
527 + fmt.Printf("json report: %s\n", opts.oracleDiffJSON)
528 + fmt.Printf("markdown report: %s\n", opts.oracleDiffMD)
529 + fmt.Printf("status: %s\n", report.Status)
530 + fmt.Printf("in-scope scenarios: %d (zero-diff=%d diffs=%d failures=%d)\n",
531 + report.Totals.ScenariosInScope,
532 + report.Totals.ScenariosZeroDiff,
533 + report.Totals.ScenariosWithDiffs,
534 + report.Totals.ScenariosWithFailures)
535 +
536 + if report.Status != "pass" {
537 + return fmt.Errorf("behavior oracle diff contains in-scope mismatches")
538 + }
539 + return nil
540 +}
541 +
542 +func buildBehaviorOracleDiffReport(manifestRoot string) (behaviorOracleReport, error) {
543 + pattern := filepath.Join(manifestRoot, "*/manifest.yaml")
544 + manifestPaths, err := filepath.Glob(pattern)
545 + if err != nil {
546 + return behaviorOracleReport{}, fmt.Errorf("glob manifests %q: %w", pattern, err)
547 + }
548 + sort.Strings(manifestPaths)
549 + if len(manifestPaths) == 0 {
550 + return behaviorOracleReport{}, fmt.Errorf("no manifests found under %q", manifestRoot)
551 + }
552 +
553 + report := behaviorOracleReport{
554 + Version: "v1",
555 + GeneratedAtUTC: time.Now().UTC().Format(time.RFC3339),
556 + Status: "pass",
557 + Scope: behaviorOracleScope{
558 + Protocols: []string{"lldp", "cdp", "bridge_fdb", "arp_nd"},
559 + },
560 + Scenarios: make([]behaviorOracleScenarioReport, 0, 64),
561 + }
562 +
563 + for _, manifestPath := range manifestPaths {
564 + manifest, err := parity.LoadManifest(manifestPath)
565 + if err != nil {
566 + return behaviorOracleReport{}, err
567 + }
568 +
569 + scenarios := append([]parity.ManifestScenario(nil), manifest.Scenarios...)
570 + sort.Slice(scenarios, func(i, j int) bool {
571 + return scenarios[i].ID < scenarios[j].ID
572 + })
573 +
574 + for _, scenario := range scenarios {
575 + scenarioReport := evaluateBehaviorOracleScenario(manifestPath, scenario)
576 + report.Scenarios = append(report.Scenarios, scenarioReport)
577 + }
578 + }
579 +
580 + report.Totals.ScenariosTotal = len(report.Scenarios)
581 + for _, scenario := range report.Scenarios {
582 + if !scenario.InScope {
583 + report.Totals.ScenariosSkipped++
584 + continue
585 + }
586 +
587 + report.Totals.ScenariosInScope++
588 + switch scenario.Status {
589 + case "zero-diff":
590 + report.Totals.ScenariosZeroDiff++
591 + case "diff":
592 + report.Totals.ScenariosWithDiffs++
593 + default:
594 + report.Totals.ScenariosWithFailures++
595 + }
596 + }
597 +
598 + if report.Totals.ScenariosWithDiffs > 0 || report.Totals.ScenariosWithFailures > 0 {
599 + report.Status = "fail"
600 + }
601 +
602 + return report, nil
603 +}
604 +
605 +func evaluateBehaviorOracleScenario(manifestPath string, scenario parity.ManifestScenario) behaviorOracleScenarioReport {
606 + out := behaviorOracleScenarioReport{
607 + ID: scenario.ID,
608 + Manifest: filepath.ToSlash(manifestPath),
609 + Protocols: enabledProtocols(scenario.Protocols),
610 + InScope: scenarioInScope(scenario),
611 + Status: "error",
612 + Expected: behaviorOracleSnapshot{
613 + Devices: []parity.GoldenDevice{},
614 + Adjacencies: []parity.GoldenAdjacency{},
615 + },
616 + Actual: behaviorOracleSnapshot{
617 + Devices: []parity.GoldenDevice{},
618 + Adjacencies: []parity.GoldenAdjacency{},
619 + },
620 + }
621 +
622 + resolved, err := parity.ResolveScenario(manifestPath, scenario)
623 + if err != nil {
624 + out.Errors = []string{err.Error()}
625 + return out
626 + }
627 +
628 + fixtures, err := collectBehaviorOracleFixtureInputs(resolved)
629 + if err != nil {
630 + out.Errors = []string{err.Error()}
631 + return out
632 + }
633 + out.FixtureInputs = fixtures
634 +
635 + if err := parity.ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON); err != nil {
636 + out.Errors = append(out.Errors, err.Error())
637 + }
638 +
639 + golden, err := parity.LoadGoldenYAML(resolved.GoldenYAML)
640 + if err != nil {
641 + out.Errors = append(out.Errors, err.Error())
642 + return out
643 + }
644 + out.Expected = expectedBehaviorSnapshot(golden)
645 +
646 + walks, err := parity.LoadScenarioWalks(resolved)
647 + if err != nil {
648 + out.Errors = append(out.Errors, err.Error())
649 + return out
650 + }
651 +
652 + result, err := parity.BuildL2ResultFromWalks(walks, parity.BuildOptions{
653 + EnableLLDP: scenario.Protocols.LLDP,
654 + EnableCDP: scenario.Protocols.CDP,
655 + EnableBridge: scenario.Protocols.Bridge,
656 + EnableARP: scenario.Protocols.ARPND,
657 + })
658 + if err != nil {
659 + out.Errors = append(out.Errors, err.Error())
660 + return out
661 + }
662 + out.Actual = actualBehaviorSnapshot(result)
663 + out.Diff = diffBehaviorSnapshots(out.Expected, out.Actual)
664 +
665 + if !out.InScope {
666 + out.Status = "skipped"
667 + return out
668 + }
669 + if len(out.Errors) > 0 {
670 + out.Status = "error"
671 + return out
672 + }
673 + if out.Diff.ZeroDiff {
674 + out.Status = "zero-diff"
675 + } else {
676 + out.Status = "diff"
677 + }
678 + return out
679 +}
680 +
681 +func collectBehaviorOracleFixtureInputs(scenario parity.ResolvedScenario) ([]behaviorOracleFixture, error) {
682 + out := make([]behaviorOracleFixture, 0, len(scenario.Fixtures))
683 + for _, fixture := range scenario.Fixtures {
684 + info, err := os.Stat(fixture.WalkFile)
685 + if err != nil {
686 + return nil, fmt.Errorf("stat walk file %q: %w", fixture.WalkFile, err)
687 + }
688 + sha256Value, err := sha256File(fixture.WalkFile)
689 + if err != nil {
690 + return nil, fmt.Errorf("sha256 walk file %q: %w", fixture.WalkFile, err)
691 + }
692 +
693 + out = append(out, behaviorOracleFixture{
694 + DeviceID: fixture.DeviceID,
695 + Hostname: fixture.Hostname,
696 + Address: fixture.Address,
697 + WalkFile: filepath.ToSlash(fixture.WalkFile),
698 + SHA256: sha256Value,
699 + SizeBytes: info.Size(),
700 + })
701 + }
702 +
703 + sort.Slice(out, func(i, j int) bool {
704 + if out[i].DeviceID != out[j].DeviceID {
705 + return out[i].DeviceID < out[j].DeviceID
706 + }
707 + return out[i].WalkFile < out[j].WalkFile
708 + })
709 + return out, nil
710 +}
711 +
712 +func expectedBehaviorSnapshot(golden parity.GoldenDocument) behaviorOracleSnapshot {
713 + canonical := golden.Canonical()
714 + devices := append([]parity.GoldenDevice(nil), canonical.Devices...)
715 + adjacencies := append([]parity.GoldenAdjacency(nil), canonical.Adjacencies...)
716 + return behaviorOracleSnapshot{
717 + Devices: devices,
718 + Adjacencies: adjacencies,
719 + Metadata: behaviorOracleMetadata{
720 + Devices: canonical.Expectations.Devices,
721 + DirectionalAdjacencies: canonical.Expectations.DirectionalAdjacencies,
722 + },
723 + }
724 +}
725 +
726 +func actualBehaviorSnapshot(result engine.Result) behaviorOracleSnapshot {
727 + devices := make([]parity.GoldenDevice, 0, len(result.Devices))
728 + for _, dev := range result.Devices {
729 + devices = append(devices, parity.GoldenDevice{
730 + ID: dev.ID,
731 + Hostname: dev.Hostname,
732 + })
733 + }
734 + sort.Slice(devices, func(i, j int) bool {
735 + if devices[i].ID != devices[j].ID {
736 + return devices[i].ID < devices[j].ID
737 + }
738 + return devices[i].Hostname < devices[j].Hostname
739 + })
740 +
741 + adjacencies := make([]parity.GoldenAdjacency, 0, len(result.Adjacencies))
742 + for _, adj := range result.Adjacencies {
743 + adjacencies = append(adjacencies, parity.GoldenAdjacency{
744 + Protocol: adj.Protocol,
745 + SourceDevice: adj.SourceID,
746 + SourcePort: adj.SourcePort,
747 + TargetDevice: adj.TargetID,
748 + TargetPort: adj.TargetPort,
749 + })
750 + }
751 + sort.Slice(adjacencies, func(i, j int) bool {
752 + ai := adjacencies[i]
753 + aj := adjacencies[j]
754 + if ai.Protocol != aj.Protocol {
755 + return ai.Protocol < aj.Protocol
756 + }
757 + if ai.SourceDevice != aj.SourceDevice {
758 + return ai.SourceDevice < aj.SourceDevice
759 + }
760 + if ai.SourcePort != aj.SourcePort {
761 + return ai.SourcePort < aj.SourcePort
762 + }
763 + if ai.TargetDevice != aj.TargetDevice {
764 + return ai.TargetDevice < aj.TargetDevice
765 + }
766 + return ai.TargetPort < aj.TargetPort
767 + })
768 +
769 + return behaviorOracleSnapshot{
770 + Devices: devices,
771 + Adjacencies: adjacencies,
772 + Metadata: behaviorOracleMetadata{
773 + Devices: len(devices),
774 + DirectionalAdjacencies: len(adjacencies),
775 + },
776 + }
777 +}
778 +
779 +func diffBehaviorSnapshots(expected, actual behaviorOracleSnapshot) behaviorOracleDiff {
780 + diff := behaviorOracleDiff{
781 + ZeroDiff: true,
782 + }
783 +
784 + expectedByID := make(map[string]parity.GoldenDevice, len(expected.Devices))
785 + for _, dev := range expected.Devices {
786 + expectedByID[dev.ID] = dev
787 + }
788 + actualByID := make(map[string]parity.GoldenDevice, len(actual.Devices))
789 + for _, dev := range actual.Devices {
790 + actualByID[dev.ID] = dev
791 + }
792 +
793 + for _, dev := range expected.Devices {
794 + actualDev, ok := actualByID[dev.ID]
795 + if !ok {
796 + diff.MissingDevices = append(diff.MissingDevices, dev)
797 + continue
798 + }
799 + if dev.Hostname != actualDev.Hostname {
800 + diff.HostnameMismatches = append(diff.HostnameMismatches, behaviorOracleDeviceDelta{
801 + DeviceID: dev.ID,
802 + Expected: dev.Hostname,
803 + Actual: actualDev.Hostname,
804 + })
805 + }
806 + }
807 + for _, dev := range actual.Devices {
808 + if _, ok := expectedByID[dev.ID]; !ok {
809 + diff.UnexpectedDevices = append(diff.UnexpectedDevices, dev)
810 + }
811 + }
812 +
813 + expectedAdjByKey := make(map[string]parity.GoldenAdjacency, len(expected.Adjacencies))
814 + for _, adj := range expected.Adjacencies {
815 + expectedAdjByKey[goldenAdjacencyKey(adj)] = adj
816 + }
817 + actualAdjByKey := make(map[string]parity.GoldenAdjacency, len(actual.Adjacencies))
818 + for _, adj := range actual.Adjacencies {
819 + actualAdjByKey[goldenAdjacencyKey(adj)] = adj
820 + }
821 +
822 + for _, adj := range expected.Adjacencies {
823 + if _, ok := actualAdjByKey[goldenAdjacencyKey(adj)]; !ok {
824 + diff.MissingAdjacencies = append(diff.MissingAdjacencies, adj)
825 + }
826 + }
827 + for _, adj := range actual.Adjacencies {
828 + if _, ok := expectedAdjByKey[goldenAdjacencyKey(adj)]; !ok {
829 + diff.UnexpectedAdjacencies = append(diff.UnexpectedAdjacencies, adj)
830 + }
831 + }
832 +
833 + diff.MetadataMismatches = append(diff.MetadataMismatches,
834 + buildCountDelta("devices", expected.Metadata.Devices, actual.Metadata.Devices),
835 + buildCountDelta("directional_adjacencies", expected.Metadata.DirectionalAdjacencies, actual.Metadata.DirectionalAdjacencies),
836 + )
837 + filteredCountDeltas := make([]behaviorOracleCountDelta, 0, len(diff.MetadataMismatches))
838 + for _, delta := range diff.MetadataMismatches {
839 + if delta.Field == "" {
840 + continue
841 + }
842 + filteredCountDeltas = append(filteredCountDeltas, delta)
843 + }
844 + diff.MetadataMismatches = filteredCountDeltas
845 +
846 + sort.Slice(diff.MissingDevices, func(i, j int) bool { return diff.MissingDevices[i].ID < diff.MissingDevices[j].ID })
847 + sort.Slice(diff.UnexpectedDevices, func(i, j int) bool { return diff.UnexpectedDevices[i].ID < diff.UnexpectedDevices[j].ID })
848 + sort.Slice(diff.HostnameMismatches, func(i, j int) bool { return diff.HostnameMismatches[i].DeviceID < diff.HostnameMismatches[j].DeviceID })
849 + sort.Slice(diff.MissingAdjacencies, func(i, j int) bool {
850 + return goldenAdjacencyKey(diff.MissingAdjacencies[i]) < goldenAdjacencyKey(diff.MissingAdjacencies[j])
851 + })
852 + sort.Slice(diff.UnexpectedAdjacencies, func(i, j int) bool {
853 + return goldenAdjacencyKey(diff.UnexpectedAdjacencies[i]) < goldenAdjacencyKey(diff.UnexpectedAdjacencies[j])
854 + })
855 + sort.Slice(diff.MetadataMismatches, func(i, j int) bool { return diff.MetadataMismatches[i].Field < diff.MetadataMismatches[j].Field })
856 +
857 + if len(diff.MissingDevices) > 0 ||
858 + len(diff.UnexpectedDevices) > 0 ||
859 + len(diff.HostnameMismatches) > 0 ||
860 + len(diff.MissingAdjacencies) > 0 ||
861 + len(diff.UnexpectedAdjacencies) > 0 ||
862 + len(diff.MetadataMismatches) > 0 {
863 + diff.ZeroDiff = false
864 + }
865 + return diff
866 +}
867 +
868 +func buildCountDelta(field string, expected, actual int) behaviorOracleCountDelta {
869 + if expected == actual {
870 + return behaviorOracleCountDelta{}
871 + }
872 + return behaviorOracleCountDelta{
873 + Field: field,
874 + Expected: expected,
875 + Actual: actual,
876 + }
877 +}
878 +
879 +func goldenAdjacencyKey(adj parity.GoldenAdjacency) string {
880 + return fmt.Sprintf("%s|%s|%s|%s|%s", adj.Protocol, adj.SourceDevice, adj.SourcePort, adj.TargetDevice, adj.TargetPort)
881 +}
882 +
883 +func scenarioInScope(scenario parity.ManifestScenario) bool {
884 + for _, protocol := range enabledProtocols(scenario.Protocols) {
885 + switch protocol {
886 + case "lldp", "cdp", "bridge_fdb", "arp_nd":
887 + continue
888 + default:
889 + return false
890 + }
891 + }
892 + return true
893 +}
894 +
895 +func buildBehaviorOracleDiffMarkdown(report behaviorOracleReport) string {
896 + var b strings.Builder
897 + b.WriteString("# Behavior Oracle Diff Report\n\n")
898 + b.WriteString(fmt.Sprintf("- Generated at (UTC): `%s`\n", report.GeneratedAtUTC))
899 + b.WriteString(fmt.Sprintf("- Status: `%s`\n", report.Status))
900 + b.WriteString(fmt.Sprintf("- In-scope protocols: `%s`\n", strings.Join(report.Scope.Protocols, ", ")))
901 + b.WriteString(fmt.Sprintf("- Scenarios: total `%d`, in-scope `%d`, skipped `%d`\n",
902 + report.Totals.ScenariosTotal, report.Totals.ScenariosInScope, report.Totals.ScenariosSkipped))
903 + b.WriteString(fmt.Sprintf("- Zero-diff `%d`, with diffs `%d`, failures `%d`\n\n",
904 + report.Totals.ScenariosZeroDiff, report.Totals.ScenariosWithDiffs, report.Totals.ScenariosWithFailures))
905 +
906 + b.WriteString("## Pass Criteria\n\n")
907 + b.WriteString("- No missing or unexpected device IDs.\n")
908 + b.WriteString("- No hostname mismatches for matched device IDs.\n")
909 + b.WriteString("- No missing or unexpected directed adjacency keys (`protocol|source_device|source_port|target_device|target_port`).\n")
910 + b.WriteString("- No metadata mismatches (`devices`, `directional_adjacencies`).\n\n")
911 +
912 + b.WriteString("## Per-Scenario Summary\n\n")
913 + for _, scenario := range report.Scenarios {
914 + b.WriteString(fmt.Sprintf("- `%s` (%s): status `%s`; missing_devices=%d unexpected_devices=%d hostname_mismatches=%d missing_adjacencies=%d unexpected_adjacencies=%d metadata_mismatches=%d\n",
915 + scenario.ID,
916 + strings.Join(scenario.Protocols, ","),
917 + scenario.Status,
918 + len(scenario.Diff.MissingDevices),
919 + len(scenario.Diff.UnexpectedDevices),
920 + len(scenario.Diff.HostnameMismatches),
921 + len(scenario.Diff.MissingAdjacencies),
922 + len(scenario.Diff.UnexpectedAdjacencies),
923 + len(scenario.Diff.MetadataMismatches)))
924 + }
925 + b.WriteString("\n")
926 +
927 + b.WriteString("## Command Evidence\n\n")
928 + b.WriteString("- `go run ./tools/topology-parity-evidence --mode oracle-diff`\n")
929 + return b.String()
930 +}
931 +
932 +type testSelection struct {
933 + packagePath string
934 + tests []string
935 +}
936 +
937 +type goTestSelectionResult struct {
938 + command string
939 + passed []string
940 + failed []string
941 + missing []string
942 + commandError string
943 +}
944 +
945 +type goTestEvent struct {
946 + Action string `json:"Action"`
947 + Test string `json:"Test"`
948 +}
949 +
950 +func runPhase2(opts options) error {
951 + summary, err := buildSuiteSummary(opts)
952 + if err != nil {
953 + return err
954 + }
955 +
956 + modules := []struct {
957 + name string
958 + selections []testSelection
959 + }{
960 + {
961 + name: "lldp",
962 + selections: []testSelection{{
963 + packagePath: "./pkg/topology/engine",
964 + tests: []string{
965 + "TestMatchLLDPLinksEnlinkdPassOrder_Precedence",
966 + "TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses/port-description",
967 + "TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses/sysname",
968 + "TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses/chassis-port-subtype",
969 + "TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses/chassis-port-description",
970 + "TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses/chassis-only",
971 + },
972 + }},
973 + },
974 + {
975 + name: "cdp",
976 + selections: []testSelection{{
977 + packagePath: "./pkg/topology/engine",
978 + tests: []string{
979 + "TestMatchCDPLinksEnlinkdPassOrder_DefaultAndParsedTarget",
980 + "TestMatchCDPLinksEnlinkdPassOrder_SkipsSelfTarget",
981 + },
982 + }},
983 + },
984 + {
985 + name: "bridge_fdb_arp",
986 + selections: []testSelection{{
987 + packagePath: "./pkg/topology/engine",
988 + tests: []string{
989 + "TestBuildL2ResultFromObservations_FDBAttachments",
990 + "TestBuildL2ResultFromObservations_FDBDropsDuplicateMACAcrossPorts",
991 + "TestBuildL2ResultFromObservations_FDBSkipsSelfAndNonLearned",
992 + "TestBuildL2ResultFromObservations_FDBBridgeDomainFallbackToBridgePort",
993 + },
994 + }},
995 + },
996 + {
997 + name: "updater",
998 + selections: []testSelection{
999 + {
1000 + packagePath: "./pkg/topology/engine",
1001 + tests: []string{
1002 + "TestBuildL2ResultFromObservations_AnnotatesPairMetadata",
1003 + },
1004 + },
1005 + {
1006 + packagePath: "./pkg/topology/engine",
1007 + tests: []string{
1008 + "TestToTopologyData_MergesPairedAdjacenciesIntoBidirectionalLink",
1009 + },
1010 + },
1011 + },
1012 + },
1013 + }
1014 +
1015 + moduleStatus := make([]phase2CheckStatus, 0, len(modules))
1016 + overallPass := summary.ScenariosFailed == 0
1017 + for _, module := range modules {
1018 + status, err := runSelectionGroup(module.name, module.selections)
1019 + if err != nil {
1020 + return err
1021 + }
1022 + moduleStatus = append(moduleStatus, status)
1023 + if status.Status != "pass" {
1024 + overallPass = false
1025 + }
1026 + }
1027 +
1028 + reversePairQuality, err := runSelectionGroup("reverse_pair_quality", []testSelection{{
1029 + packagePath: "./plugin/go.d/collector/snmp",
1030 + tests: []string{
1031 + "TestTopologyCache_LldpSnapshot",
1032 + "TestTopologyCache_CdpSnapshot",
1033 + "TestTopologyCache_CdpSnapshotHexAddress",
1034 + "TestTopologyCache_CdpSnapshotRawAddressWithoutIP",
1035 + "TestTopologyCache_SnapshotBidirectionalPairMetadata",
1036 + },
1037 + }})
1038 + if err != nil {
1039 + return err
1040 + }
1041 + if reversePairQuality.Status != "pass" {
1042 + overallPass = false
1043 + }
1044 +
1045 + identityMergeQuality, err := runSelectionGroup("identity_merge_quality", []testSelection{{
1046 + packagePath: "./plugin/go.d/collector/snmp",
1047 + tests: []string{
1048 + "TestTopologyCache_SnapshotMergesRemoteIdentityAcrossProtocols",
1049 + },
1050 + }})
1051 + if err != nil {
1052 + return err
1053 + }
1054 + if identityMergeQuality.Status != "pass" {
1055 + overallPass = false
1056 + }
1057 +
1058 + assertionCoverage, err := computePhase2AssertionCoverage(opts.evidencePath)
1059 + if err != nil {
1060 + return err
1061 + }
1062 + if assertionCoverage.Status != "pass" {
1063 + overallPass = false
1064 + }
1065 +
1066 + report := phase2Report{
1067 + Version: "v1",
1068 + GeneratedAtUTC: time.Now().UTC().Format(time.RFC3339),
1069 + Status: "pass",
1070 + Suite: phase2SuiteSummary{
1071 + FixtureScenarios: summary.FixtureScenarios,
1072 + FixtureFiles: summary.FixtureFiles,
1073 + TotalScenarios: summary.TotalScenarios,
1074 + ScenariosPassed: summary.ScenariosPassed,
1075 + ScenariosFailed: summary.ScenariosFailed,
1076 + TotalTestsMapped: summary.TotalTestsMapped,
1077 + TotalTestsInventory: summary.TotalTestsInventory,
1078 + TotalAssertionsMapped: summary.TotalAssertionsMapped,
1079 + TotalAssertionsTotal: summary.TotalAssertionsTotal,
1080 + ProtocolCounts: append([]protocolSummary(nil), summary.ProtocolCounts...),
1081 + },
1082 + ModuleParity: moduleStatus,
1083 + ReversePairQuality: reversePairQuality,
1084 + IdentityMergeQuality: identityMergeQuality,
1085 + AssertionCoverage: assertionCoverage,
1086 + DeferredGaps: deferredOfficeGaps(opts.evidencePath),
1087 + }
1088 +
1089 + if !overallPass {
1090 + report.Status = "fail"
1091 + }
1092 +
1093 + reportBytes, err := json.MarshalIndent(report, "", " ")
1094 + if err != nil {
1095 + return fmt.Errorf("marshal phase2 report json: %w", err)
1096 + }
1097 + reportBytes = append(reportBytes, '\n')
1098 + if err := os.MkdirAll(filepath.Dir(opts.phase2Report), 0o755); err != nil {
1099 + return fmt.Errorf("create phase2 report directory: %w", err)
1100 + }
1101 + if err := os.WriteFile(opts.phase2Report, reportBytes, 0o644); err != nil {
1102 + return fmt.Errorf("write phase2 report %q: %w", opts.phase2Report, err)
1103 + }
1104 +
1105 + gapReport := buildPhase2GapReportMarkdown(report)
1106 + if err := os.MkdirAll(filepath.Dir(opts.phase2Gap), 0o755); err != nil {
1107 + return fmt.Errorf("create phase2 gap report directory: %w", err)
1108 + }
1109 + if err := os.WriteFile(opts.phase2Gap, []byte(gapReport), 0o644); err != nil {
1110 + return fmt.Errorf("write phase2 gap report %q: %w", opts.phase2Gap, err)
1111 + }
1112 +
1113 + fmt.Printf("phase2 report complete\n")
1114 + fmt.Printf("report file: %s\n", opts.phase2Report)
1115 + fmt.Printf("gap report: %s\n", opts.phase2Gap)
1116 + fmt.Printf("status: %s\n", report.Status)
1117 + fmt.Printf("in-scope assertion coverage: %d/%d ported (not-applicable=%d unmapped=%d)\n",
1118 + assertionCoverage.InScopePorted,
1119 + assertionCoverage.InScopeTotal,
1120 + assertionCoverage.InScopeNotApplicable,
1121 + assertionCoverage.InScopeUnmapped)
1122 +
1123 + if report.Status != "pass" {
1124 + return fmt.Errorf("phase2 report contains failing gates")
1125 + }
1126 + return nil
1127 +}
1128 +
1129 +func runSelectionGroup(name string, selections []testSelection) (phase2CheckStatus, error) {
1130 + status := phase2CheckStatus{
1131 + Name: name,
1132 + Status: "pass",
1133 + Commands: make([]string, 0, len(selections)),
1134 + }
1135 +
1136 + for _, selection := range selections {
1137 + result, err := runGoTestSelection(selection)
1138 + if err != nil {
1139 + return phase2CheckStatus{}, err
1140 + }
1141 + status.Commands = append(status.Commands, result.command)
1142 + status.ChecksTotal += len(selection.tests)
1143 + status.ChecksPassed += len(result.passed)
1144 + status.Failed = append(status.Failed, result.failed...)
1145 + status.Missing = append(status.Missing, result.missing...)
1146 + if result.commandError != "" {
1147 + status.Errors = append(status.Errors, result.commandError)
1148 + }
1149 + }
1150 +
1151 + status.Failed = uniqueSortedStrings(status.Failed)
1152 + status.Missing = uniqueSortedStrings(status.Missing)
1153 + status.Errors = uniqueSortedStrings(status.Errors)
1154 +
1155 + if len(status.Failed) > 0 || len(status.Missing) > 0 || len(status.Errors) > 0 {
1156 + status.Status = "fail"
1157 + }
1158 + return status, nil
1159 +}
1160 +
1161 +func runGoTestSelection(selection testSelection) (goTestSelectionResult, error) {
1162 + if strings.TrimSpace(selection.packagePath) == "" {
1163 + return goTestSelectionResult{}, fmt.Errorf("test selection package path is empty")
1164 + }
1165 + if len(selection.tests) == 0 {
1166 + return goTestSelectionResult{}, fmt.Errorf("test selection for %s has no tests", selection.packagePath)
1167 + }
1168 +
1169 + topLevelTests := topLevelTestNames(selection.tests)
1170 + regex := buildGoTestNameRegex(topLevelTests)
1171 + args := []string{"test", "-json", selection.packagePath, "-run", regex, "-count=1"}
1172 + command := "go " + strings.Join(args, " ")
1173 +
1174 + cmd := exec.Command("go", args...)
1175 + output, err := cmd.CombinedOutput()
1176 +
1177 + passedSet := make(map[string]struct{}, len(selection.tests))
1178 + failedSet := make(map[string]struct{}, len(selection.tests))
1179 + scanner := bufio.NewScanner(bytes.NewReader(output))
1180 + scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
1181 + for scanner.Scan() {
1182 + line := scanner.Bytes()
1183 + var event goTestEvent
1184 + if json.Unmarshal(line, &event) != nil {
1185 + continue
1186 + }
1187 + if strings.TrimSpace(event.Test) == "" {
1188 + continue
1189 + }
1190 + switch event.Action {
1191 + case "pass":
1192 + passedSet[event.Test] = struct{}{}
1193 + case "fail":
1194 + failedSet[event.Test] = struct{}{}
1195 + }
1196 + }
1197 + if scanErr := scanner.Err(); scanErr != nil {
1198 + return goTestSelectionResult{}, fmt.Errorf("scan go test json output: %w", scanErr)
1199 + }
1200 +
1201 + result := goTestSelectionResult{
1202 + command: command,
1203 + passed: make([]string, 0, len(selection.tests)),
1204 + failed: make([]string, 0, len(selection.tests)),
1205 + missing: make([]string, 0, len(selection.tests)),
1206 + }
1207 + for _, testName := range selection.tests {
1208 + if _, failed := failedSet[testName]; failed {
1209 + result.failed = append(result.failed, testName)
1210 + continue
1211 + }
1212 + if _, passed := passedSet[testName]; passed {
1213 + result.passed = append(result.passed, testName)
1214 + continue
1215 + }
1216 + result.missing = append(result.missing, testName)
1217 + }
1218 +
1219 + sort.Strings(result.passed)
1220 + sort.Strings(result.failed)
1221 + sort.Strings(result.missing)
1222 +
1223 + if err != nil {
1224 + result.commandError = truncateWhitespace(string(output), 2048)
1225 + }
1226 + return result, nil
1227 +}
1228 +
1229 +func buildGoTestNameRegex(testNames []string) string {
1230 + parts := make([]string, 0, len(testNames))
1231 + for _, name := range testNames {
1232 + name = strings.TrimSpace(name)
1233 + if name == "" {
1234 + continue
1235 + }
1236 + parts = append(parts, regexp.QuoteMeta(name))
1237 + }
1238 + sort.Strings(parts)
1239 + return "^(" + strings.Join(parts, "|") + ")$"
1240 +}
1241 +
1242 +func topLevelTestNames(testNames []string) []string {
1243 + names := make([]string, 0, len(testNames))
1244 + for _, name := range testNames {
1245 + name = strings.TrimSpace(name)
1246 + if name == "" {
1247 + continue
1248 + }
1249 + if idx := strings.IndexByte(name, '/'); idx > 0 {
1250 + name = name[:idx]
1251 + }
1252 + names = append(names, name)
1253 + }
1254 + return uniqueSortedStrings(names)
1255 +}
1256 +
1257 +type assertionScopeRow struct {
1258 + AssertionID string
1259 + Scope string
1260 +}
1261 +
1262 +func computePhase2AssertionCoverage(evidencePath string) (phase2AssertionCoverage, error) {
1263 + inventoryPath := filepath.Join(evidencePath, defaultAssertionInventory)
1264 + mappingPath := filepath.Join(evidencePath, defaultAssertionMapping)
1265 +
1266 + assertions, err := readAssertionScopeRows(inventoryPath)
1267 + if err != nil {
1268 + return phase2AssertionCoverage{}, err
1269 + }
1270 + statusByAssertion, err := readMappingStatusByAssertion(mappingPath)
1271 + if err != nil {
1272 + return phase2AssertionCoverage{}, err
1273 + }
1274 +
1275 + inScopeProtocols := map[string]struct{}{
1276 + "lldp": {},
1277 + "cdp": {},
1278 + "bridge_fdb": {},
1279 + "arp_nd": {},
1280 + }
1281 +
1282 + coverage := phase2AssertionCoverage{}
1283 + for _, assertion := range assertions {
1284 + status, ok := statusByAssertion[assertion.AssertionID]
1285 + if _, inScope := inScopeProtocols[assertion.Scope]; inScope {
1286 + coverage.InScopeTotal++
1287 + if !ok {
1288 + coverage.InScopeUnmapped++
1289 + continue
1290 + }
1291 + switch status {
1292 + case "ported":
1293 + coverage.InScopePorted++
1294 + case "not-applicable-approved":
1295 + coverage.InScopeNotApplicable++
1296 + default:
1297 + return phase2AssertionCoverage{}, fmt.Errorf("unsupported status %q for in-scope assertion %q", status, assertion.AssertionID)
1298 + }
1299 + continue
1300 + }
1301 +
1302 + if !ok {
1303 + continue
1304 + }
1305 + switch status {
1306 + case "ported":
1307 + coverage.OutOfScopePorted++
1308 + case "not-applicable-approved":
1309 + coverage.OutOfScopeNotApplicable++
1310 + default:
1311 + return phase2AssertionCoverage{}, fmt.Errorf("unsupported status %q for out-of-scope assertion %q", status, assertion.AssertionID)
1312 + }
1313 + }
1314 +
1315 + coverage.Status = "pass"
1316 + if coverage.InScopeNotApplicable > 0 || coverage.InScopeUnmapped > 0 || coverage.InScopePorted != coverage.InScopeTotal {
1317 + coverage.Status = "fail"
1318 + }
1319 + return coverage, nil
1320 +}
1321 +
1322 +func readAssertionScopeRows(path string) ([]assertionScopeRow, error) {
1323 + f, err := os.Open(path)
1324 + if err != nil {
1325 + return nil, fmt.Errorf("open assertion inventory %q: %w", path, err)
1326 + }
1327 + defer f.Close()
1328 +
1329 + r := csv.NewReader(f)
1330 + records, err := r.ReadAll()
1331 + if err != nil {
1332 + return nil, fmt.Errorf("read assertion inventory %q: %w", path, err)
1333 + }
1334 + if len(records) == 0 {
1335 + return nil, fmt.Errorf("assertion inventory %q is empty", path)
1336 + }
1337 +
1338 + header := strings.Join(records[0], ",")
1339 + expectedHeader := "class,method,assertion_id,source_file,line,assert_call,protocol_scope"
1340 + if header != expectedHeader {
1341 + return nil, fmt.Errorf("unexpected assertion inventory header in %q: %q", path, header)
1342 + }
1343 +
1344 + rows := make([]assertionScopeRow, 0, len(records)-1)
1345 + for i := 1; i < len(records); i++ {
1346 + rec := records[i]
1347 + if len(rec) != 7 {
1348 + return nil, fmt.Errorf("assertion inventory %q line %d: expected 7 columns, got %d", path, i+1, len(rec))
1349 + }
1350 + rows = append(rows, assertionScopeRow{
1351 + AssertionID: strings.TrimSpace(rec[2]),
1352 + Scope: strings.TrimSpace(rec[6]),
1353 + })
1354 + }
1355 + return rows, nil
1356 +}
1357 +
1358 +func readMappingStatusByAssertion(path string) (map[string]string, error) {
1359 + f, err := os.Open(path)
1360 + if err != nil {
1361 + return nil, fmt.Errorf("open mapping csv %q: %w", path, err)
1362 + }
1363 + defer f.Close()
1364 +
1365 + r := csv.NewReader(f)
1366 + records, err := r.ReadAll()
1367 + if err != nil {
1368 + return nil, fmt.Errorf("read mapping csv %q: %w", path, err)
1369 + }
1370 + if len(records) == 0 {
1371 + return nil, fmt.Errorf("mapping csv %q is empty", path)
1372 + }
1373 +
1374 + header := strings.Join(records[0], ",")
1375 + expectedHeader := "upstream_class,upstream_method,upstream_assert_id,local_test,local_assert,status"
1376 + if header != expectedHeader {
1377 + return nil, fmt.Errorf("unexpected mapping header in %q: %q", path, header)
1378 + }
1379 +
1380 + out := make(map[string]string, len(records)-1)
1381 + for i := 1; i < len(records); i++ {
1382 + rec := records[i]
1383 + if len(rec) != 6 {
1384 + return nil, fmt.Errorf("mapping csv %q line %d: expected 6 columns, got %d", path, i+1, len(rec))
1385 + }
1386 + assertionID := strings.TrimSpace(rec[2])
1387 + status := strings.TrimSpace(rec[5])
1388 + if status != "ported" && status != "not-applicable-approved" {
1389 + return nil, fmt.Errorf("mapping csv %q line %d: unsupported status %q", path, i+1, status)
1390 + }
1391 + out[assertionID] = status
1392 + }
1393 + return out, nil
1394 +}
1395 +
1396 +func deferredOfficeGaps(evidenceDir string) []phase2DeferredGap {
1397 + officeReportPath := filepath.Join(evidenceDir, defaultOfficeReportFile)
1398 + if _, err := os.Stat(officeReportPath); err == nil {
1399 + return []phase2DeferredGap{}
1400 + }
1401 +
1402 + return []phase2DeferredGap{
1403 + {
1404 + ID: "gap-live-office-validation",
1405 + Description: "Live office `topology:snmp` sanity validation is still pending.",
1406 + Reason: "Repository test fixtures do not include the live office runtime environment.",
1407 + Evidence: "TODO-topology-library-phase2-direct-port.md Track 3/T4 runtime gate",
1408 + },
1409 + }
1410 +}
1411 +
1412 +func buildPhase2GapReportMarkdown(report phase2Report) string {
1413 + var b strings.Builder
1414 + b.WriteString("# Topology Library Phase 2 Gap Report\n\n")
1415 + b.WriteString(fmt.Sprintf("- Generated at (UTC): `%s`\n", report.GeneratedAtUTC))
1416 + b.WriteString(fmt.Sprintf("- Overall status: `%s`\n", report.Status))
1417 + b.WriteString(fmt.Sprintf("- Scenario parity: `%d/%d` passed\n", report.Suite.ScenariosPassed, report.Suite.TotalScenarios))
1418 + b.WriteString(fmt.Sprintf("- Assertion parity: `%d/%d` mapped\n\n", report.Suite.TotalAssertionsMapped, report.Suite.TotalAssertionsTotal))
1419 +
1420 + b.WriteString("## What Matches Enlinkd (In Scope)\n\n")
1421 + for _, module := range report.ModuleParity {
1422 + b.WriteString(fmt.Sprintf("- `%s`: `%d/%d` checks passed (status: `%s`).\n",
1423 + module.Name, module.ChecksPassed, module.ChecksTotal, module.Status))
1424 + }
1425 + b.WriteString(fmt.Sprintf("- In-scope assertion coverage: `%d/%d` ported, `%d` not-applicable-approved, `%d` unmapped.\n\n",
1426 + report.AssertionCoverage.InScopePorted,
1427 + report.AssertionCoverage.InScopeTotal,
1428 + report.AssertionCoverage.InScopeNotApplicable,
1429 + report.AssertionCoverage.InScopeUnmapped))
1430 +
1431 + b.WriteString("## Runtime Quality Checks\n\n")
1432 + b.WriteString(fmt.Sprintf("- Reverse pair quality: `%d/%d` checks passed (status: `%s`).\n",
1433 + report.ReversePairQuality.ChecksPassed,
1434 + report.ReversePairQuality.ChecksTotal,
1435 + report.ReversePairQuality.Status))
1436 + b.WriteString(fmt.Sprintf("- Identity merge quality: `%d/%d` checks passed (status: `%s`).\n\n",
1437 + report.IdentityMergeQuality.ChecksPassed,
1438 + report.IdentityMergeQuality.ChecksTotal,
1439 + report.IdentityMergeQuality.Status))
1440 +
1441 + b.WriteString("## Intentionally Deferred Gaps\n\n")
1442 + if len(report.DeferredGaps) == 0 {
1443 + b.WriteString("- none\n")
1444 + } else {
1445 + for _, gap := range report.DeferredGaps {
1446 + b.WriteString(fmt.Sprintf("- `%s`: %s\n", gap.ID, gap.Description))
1447 + b.WriteString(fmt.Sprintf(" - Reason: %s\n", gap.Reason))
1448 + b.WriteString(fmt.Sprintf(" - Evidence: %s\n", gap.Evidence))
1449 + }
1450 + }
1451 + b.WriteString("\n")
1452 +
1453 + b.WriteString("## Command Evidence\n\n")
1454 + for _, module := range report.ModuleParity {
1455 + for _, command := range module.Commands {
1456 + b.WriteString(fmt.Sprintf("- `%s`\n", command))
1457 + }
1458 + }
1459 + for _, command := range report.ReversePairQuality.Commands {
1460 + b.WriteString(fmt.Sprintf("- `%s`\n", command))
1461 + }
1462 + for _, command := range report.IdentityMergeQuality.Commands {
1463 + b.WriteString(fmt.Sprintf("- `%s`\n", command))
1464 + }
1465 + return b.String()
1466 +}
1467 +
1468 +func uniqueSortedStrings(values []string) []string {
1469 + if len(values) == 0 {
1470 + return nil
1471 + }
1472 + set := make(map[string]struct{}, len(values))
1473 + for _, value := range values {
1474 + value = strings.TrimSpace(value)
1475 + if value == "" {
1476 + continue
1477 + }
1478 + set[value] = struct{}{}
1479 + }
1480 + out := make([]string, 0, len(set))
1481 + for value := range set {
1482 + out = append(out, value)
1483 + }
1484 + sort.Strings(out)
1485 + return out
1486 +}
1487 +
1488 +func buildSuiteSummary(opts options) (paritySummary, error) {
1489 + localRows, err := verifyFixtureInventory(opts)
1490 + if err != nil {
1491 + return paritySummary{}, err
1492 + }
1493 +
1494 + mapStats, err := collectMappingStats(opts.evidencePath)
1495 + if err != nil {
1496 + return paritySummary{}, err
1497 + }
1498 +
1499 + scenarioResults, protocolCounts, err := collectScenarioSummaries(opts.manifestRoot)
1500 + if err != nil {
1501 + return paritySummary{}, err
1502 + }
1503 +
1504 + passed := 0
1505 + for _, result := range scenarioResults {
1506 + if result.Passed {
1507 + passed++
1508 + }
1509 + }
1510 +
1511 + return paritySummary{
1512 + Version: "v1",
1513 + FixtureScenarios: countDistinctScenarios(localRows),
1514 + FixtureFiles: len(localRows),
1515 + TotalScenarios: len(scenarioResults),
1516 + ScenariosPassed: passed,
1517 + ScenariosFailed: len(scenarioResults) - passed,
1518 + TotalTestsMapped: mapStats.MappedMethods,
1519 + TotalTestsInventory: mapStats.TotalMethods,
1520 + TotalAssertionsMapped: mapStats.MappedAssertions,
1521 + TotalAssertionsTotal: mapStats.TotalAssertions,
1522 + ProtocolCounts: protocolCounts,
1523 + ScenarioResults: scenarioResults,
1524 + GoTests: runRequiredGoTests(),
1525 + }, nil
1526 +}
1527 +
1528 +func marshalSummaryJSON(summary paritySummary) ([]byte, error) {
1529 + payload, err := json.MarshalIndent(summary, "", " ")
1530 + if err != nil {
1531 + return nil, fmt.Errorf("marshal summary json: %w", err)
1532 + }
1533 + return append(payload, '\n'), nil
1534 +}
1535 +
1536 +func runRequiredGoTests() []goTestSummary {
1537 + type testCmd struct {
1538 + packageLabel string
1539 + args []string
1540 + }
1541 +
1542 + commands := []testCmd{
1543 + {
1544 + packageLabel: "./pkg/topology/engine/parity",
1545 + args: []string{"test", "./pkg/topology/engine/parity"},
1546 + },
1547 + {
1548 + packageLabel: "./pkg/topology/engine",
1549 + args: []string{"test", "./pkg/topology/engine"},
1550 + },
1551 + {
1552 + packageLabel: "./tools/topology-parity-evidence",
1553 + args: []string{"test", "./tools/topology-parity-evidence"},
1554 + },
1555 + {
1556 + packageLabel: "./plugin/go.d/collector/snmp -run ^TestTopology",
1557 + args: []string{"test", "./plugin/go.d/collector/snmp", "-run", "^TestTopology"},
1558 + },
1559 + }
1560 +
1561 + results := make([]goTestSummary, 0, len(commands))
1562 + for _, tc := range commands {
1563 + cmd := exec.Command("go", tc.args...)
1564 + output, err := cmd.CombinedOutput()
1565 + if err != nil {
1566 + results = append(results, goTestSummary{
1567 + Package: tc.packageLabel,
1568 + Passed: false,
1569 + Error: truncateWhitespace(string(output), 2048),
1570 + })
1571 + continue
1572 + }
1573 + results = append(results, goTestSummary{
1574 + Package: tc.packageLabel,
1575 + Passed: true,
1576 + })
1577 + }
1578 + return results
1579 +}
1580 +
1581 +func collectScenarioSummaries(manifestRoot string) ([]scenarioSummary, []protocolSummary, error) {
1582 + pattern := filepath.Join(manifestRoot, "*/manifest.yaml")
1583 + manifestPaths, err := filepath.Glob(pattern)
1584 + if err != nil {
1585 + return nil, nil, fmt.Errorf("glob manifests %q: %w", pattern, err)
1586 + }
1587 + sort.Strings(manifestPaths)
1588 + if len(manifestPaths) == 0 {
1589 + return nil, nil, fmt.Errorf("no manifests found under %q", manifestRoot)
1590 + }
1591 +
1592 + results := make([]scenarioSummary, 0, 64)
1593 + protocolCounts := map[string]protocolSummary{
1594 + "lldp": {Protocol: "lldp"},
1595 + "cdp": {Protocol: "cdp"},
1596 + "bridge_fdb": {Protocol: "bridge_fdb"},
1597 + "arp_nd": {Protocol: "arp_nd"},
1598 + }
1599 +
1600 + for _, manifestPath := range manifestPaths {
1601 + manifest, err := parity.LoadManifest(manifestPath)
1602 + if err != nil {
1603 + return nil, nil, err
1604 + }
1605 +
1606 + scenarios := append([]parity.ManifestScenario(nil), manifest.Scenarios...)
1607 + sort.Slice(scenarios, func(i, j int) bool {
1608 + return scenarios[i].ID < scenarios[j].ID
1609 + })
1610 +
1611 + for _, scenario := range scenarios {
1612 + result := evaluateScenario(manifestPath, scenario)
1613 + results = append(results, result)
1614 +
1615 + for _, protocol := range result.Protocols {
1616 + count := protocolCounts[protocol]
1617 + count.Total++
1618 + if result.Passed {
1619 + count.Passed++
1620 + } else {
1621 + count.Failed++
1622 + }
1623 + protocolCounts[protocol] = count
1624 + }
1625 + }
1626 + }
1627 +
1628 + orderedProtocols := []string{"lldp", "cdp", "bridge_fdb", "arp_nd"}
1629 + summary := make([]protocolSummary, 0, len(orderedProtocols))
1630 + for _, protocol := range orderedProtocols {
1631 + summary = append(summary, protocolCounts[protocol])
1632 + }
1633 + return results, summary, nil
1634 +}
1635 +
1636 +func evaluateScenario(manifestPath string, scenario parity.ManifestScenario) scenarioSummary {
1637 + out := scenarioSummary{
1638 + ID: scenario.ID,
1639 + Manifest: filepath.ToSlash(manifestPath),
1640 + Protocols: enabledProtocols(scenario.Protocols),
1641 + }
1642 +
1643 + failures := make([]string, 0, 4)
1644 +
1645 + resolved, err := parity.ResolveScenario(manifestPath, scenario)
1646 + if err != nil {
1647 + out.Failures = []string{err.Error()}
1648 + return out
1649 + }
1650 +
1651 + if err := parity.ValidateCache(resolved.GoldenYAML, resolved.GoldenJSON); err != nil {
1652 + failures = append(failures, err.Error())
1653 + }
1654 +
1655 + golden, err := parity.LoadGoldenYAML(resolved.GoldenYAML)
1656 + if err != nil {
1657 + failures = append(failures, err.Error())
1658 + out.Failures = failures
1659 + return out
1660 + }
1661 +
1662 + walks, err := parity.LoadScenarioWalks(resolved)
1663 + if err != nil {
1664 + failures = append(failures, err.Error())
1665 + out.Failures = failures
1666 + return out
1667 + }
1668 +
1669 + result, err := parity.BuildL2ResultFromWalks(walks, parity.BuildOptions{
1670 + EnableLLDP: scenario.Protocols.LLDP,
1671 + EnableCDP: scenario.Protocols.CDP,
1672 + EnableBridge: scenario.Protocols.Bridge,
1673 + EnableARP: scenario.Protocols.ARPND,
1674 + })
1675 + if err != nil {
1676 + failures = append(failures, err.Error())
1677 + out.Failures = failures
1678 + return out
1679 + }
1680 +
1681 + if len(result.Devices) != golden.Expectations.Devices {
1682 + failures = append(failures, fmt.Sprintf("devices mismatch: expected %d got %d", golden.Expectations.Devices, len(result.Devices)))
1683 + }
1684 + if len(result.Adjacencies) != golden.Expectations.DirectionalAdjacencies {
1685 + failures = append(failures, fmt.Sprintf("directional adjacencies mismatch: expected %d got %d", golden.Expectations.DirectionalAdjacencies, len(result.Adjacencies)))
1686 + }
1687 +
1688 + expectedAdjacencies := goldenAdjacencyKeySet(golden.Adjacencies)
1689 + actualAdjacencies := resultAdjacencyKeySet(result.Adjacencies)
1690 + if !stringSetEqual(expectedAdjacencies, actualAdjacencies) {
1691 + failures = append(failures, fmt.Sprintf("adjacency set mismatch: expected %d keys got %d", len(expectedAdjacencies), len(actualAdjacencies)))
1692 + }
1693 +
1694 + out.Passed = len(failures) == 0
1695 + out.Failures = failures
1696 + return out
1697 +}
1698 +
1699 +func enabledProtocols(protocols parity.ManifestProtocols) []string {
1700 + out := make([]string, 0, 4)
1701 + if protocols.LLDP {
1702 + out = append(out, "lldp")
1703 + }
1704 + if protocols.CDP {
1705 + out = append(out, "cdp")
1706 + }
1707 + if protocols.Bridge {
1708 + out = append(out, "bridge_fdb")
1709 + }
1710 + if protocols.ARPND {
1711 + out = append(out, "arp_nd")
1712 + }
1713 + return out
1714 +}
1715 +
1716 +func resultAdjacencyKeySet(adjacencies []engine.Adjacency) map[string]struct{} {
1717 + out := make(map[string]struct{}, len(adjacencies))
1718 + for _, adj := range adjacencies {
1719 + out[fmt.Sprintf("%s|%s|%s|%s|%s", adj.Protocol, adj.SourceID, adj.SourcePort, adj.TargetID, adj.TargetPort)] = struct{}{}
1720 + }
1721 + return out
1722 +}
1723 +
1724 +func goldenAdjacencyKeySet(adjacencies []parity.GoldenAdjacency) map[string]struct{} {
1725 + out := make(map[string]struct{}, len(adjacencies))
1726 + for _, adj := range adjacencies {
1727 + out[fmt.Sprintf("%s|%s|%s|%s|%s", adj.Protocol, adj.SourceDevice, adj.SourcePort, adj.TargetDevice, adj.TargetPort)] = struct{}{}
1728 + }
1729 + return out
1730 +}
1731 +
1732 +func stringSetEqual(a, b map[string]struct{}) bool {
1733 + if len(a) != len(b) {
1734 + return false
1735 + }
1736 + for key := range a {
1737 + if _, ok := b[key]; !ok {
1738 + return false
1739 + }
1740 + }
1741 + return true
1742 +}
1743 +
1744 +func collectMappingStats(evidencePath string) (mappingStats, error) {
1745 + mappingFile := filepath.Join(evidencePath, defaultAssertionMapping)
1746 + methodInventoryFile := filepath.Join(evidencePath, defaultMethodInventory)
1747 + assertionInventoryFile := filepath.Join(evidencePath, defaultAssertionInventory)
1748 +
1749 + mappedAssertions, mappedMethods, err := readMappingCoverage(mappingFile)
1750 + if err != nil {
1751 + return mappingStats{}, err
1752 + }
1753 + assertionMethods, err := readAssertionMethodSet(assertionInventoryFile)
1754 + if err != nil {
1755 + return mappingStats{}, err
1756 + }
1757 + methodCoverage, err := readMethodCoverage(methodInventoryFile, mappedMethods, assertionMethods)
1758 + if err != nil {
1759 + return mappingStats{}, err
1760 + }
1761 + totalAssertions, err := readAssertionInventoryCount(assertionInventoryFile)
1762 + if err != nil {
1763 + return mappingStats{}, err
1764 + }
1765 +
1766 + return mappingStats{
1767 + MappedAssertions: mappedAssertions,
1768 + TotalAssertions: totalAssertions,
1769 + MappedMethods: methodCoverage.mappedMethods,
1770 + TotalMethods: methodCoverage.totalMethods,
1771 + MappedTestFiles: len(methodCoverage.mappedFiles),
1772 + TotalTestFiles: methodCoverage.totalFiles,
1773 + }, nil
1774 +}
1775 +
1776 +type methodCoverageInfo struct {
1777 + totalMethods int
1778 + mappedMethods int
1779 + totalFiles int
1780 + mappedFiles map[string]struct{}
1781 +}
1782 +
1783 +func readMappingCoverage(path string) (int, map[string]struct{}, error) {
1784 + f, err := os.Open(path)
1785 + if err != nil {
1786 + return 0, nil, fmt.Errorf("open mapping csv %q: %w", path, err)
1787 + }
1788 + defer f.Close()
1789 +
1790 + r := csv.NewReader(f)
1791 + records, err := r.ReadAll()
1792 + if err != nil {
1793 + return 0, nil, fmt.Errorf("read mapping csv %q: %w", path, err)
1794 + }
1795 + if len(records) == 0 {
1796 + return 0, nil, fmt.Errorf("mapping csv %q is empty", path)
1797 + }
1798 +
1799 + header := strings.Join(records[0], ",")
1800 + expectedHeader := "upstream_class,upstream_method,upstream_assert_id,local_test,local_assert,status"
1801 + if header != expectedHeader {
1802 + return 0, nil, fmt.Errorf("unexpected mapping header in %q: %q", path, header)
1803 + }
1804 +
1805 + methods := make(map[string]struct{}, len(records))
1806 + for i := 1; i < len(records); i++ {
1807 + rec := records[i]
1808 + if len(rec) != 6 {
1809 + return 0, nil, fmt.Errorf("mapping csv %q line %d: expected 6 columns, got %d", path, i+1, len(rec))
1810 + }
1811 + status := strings.TrimSpace(rec[5])
1812 + if status != "ported" && status != "not-applicable-approved" {
1813 + return 0, nil, fmt.Errorf("mapping csv %q line %d: unsupported status %q", path, i+1, status)
1814 + }
1815 + methods[rec[0]+"#"+rec[1]] = struct{}{}
1816 + }
1817 + return len(records) - 1, methods, nil
1818 +}
1819 +
1820 +func readMethodCoverage(path string, mappedMethods map[string]struct{}, assertionMethods map[string]struct{}) (methodCoverageInfo, error) {
1821 + f, err := os.Open(path)
1822 + if err != nil {
1823 + return methodCoverageInfo{}, fmt.Errorf("open method inventory %q: %w", path, err)
1824 + }
1825 + defer f.Close()
1826 +
1827 + r := csv.NewReader(f)
1828 + records, err := r.ReadAll()
1829 + if err != nil {
1830 + return methodCoverageInfo{}, fmt.Errorf("read method inventory %q: %w", path, err)
1831 + }
1832 + if len(records) == 0 {
1833 + return methodCoverageInfo{}, fmt.Errorf("method inventory %q is empty", path)
1834 + }
1835 +
1836 + header := strings.Join(records[0], ",")
1837 + expectedHeader := "class,method,source_file,protocol_scope"
1838 + if header != expectedHeader {
1839 + return methodCoverageInfo{}, fmt.Errorf("unexpected method inventory header in %q: %q", path, header)
1840 + }
1841 +
1842 + allFiles := make(map[string]struct{}, len(records))
1843 + mappedFiles := make(map[string]struct{}, len(records))
1844 + mappedMethodCount := 0
1845 + for i := 1; i < len(records); i++ {
1846 + rec := records[i]
1847 + if len(rec) != 4 {
1848 + return methodCoverageInfo{}, fmt.Errorf("method inventory %q line %d: expected 4 columns, got %d", path, i+1, len(rec))
1849 + }
1850 + methodKey := rec[0] + "#" + rec[1]
1851 + allFiles[rec[2]] = struct{}{}
1852 + _, methodHasAssertions := assertionMethods[methodKey]
1853 + _, methodMappedByAssertion := mappedMethods[methodKey]
1854 + if methodMappedByAssertion || !methodHasAssertions {
1855 + mappedMethodCount++
1856 + mappedFiles[rec[2]] = struct{}{}
1857 + }
1858 + }
1859 +
1860 + return methodCoverageInfo{
1861 + totalMethods: len(records) - 1,
1862 + mappedMethods: mappedMethodCount,
1863 + totalFiles: len(allFiles),
1864 + mappedFiles: mappedFiles,
1865 + }, nil
1866 +}
1867 +
1868 +func readAssertionMethodSet(path string) (map[string]struct{}, error) {
1869 + f, err := os.Open(path)
1870 + if err != nil {
1871 + return nil, fmt.Errorf("open assertion inventory %q: %w", path, err)
1872 + }
1873 + defer f.Close()
1874 +
1875 + r := csv.NewReader(f)
1876 + records, err := r.ReadAll()
1877 + if err != nil {
1878 + return nil, fmt.Errorf("read assertion inventory %q: %w", path, err)
1879 + }
1880 + if len(records) == 0 {
1881 + return nil, fmt.Errorf("assertion inventory %q is empty", path)
1882 + }
1883 + header := strings.Join(records[0], ",")
1884 + expectedHeader := "class,method,assertion_id,source_file,line,assert_call,protocol_scope"
1885 + if header != expectedHeader {
1886 + return nil, fmt.Errorf("unexpected assertion inventory header in %q: %q", path, header)
1887 + }
1888 +
1889 + methods := make(map[string]struct{}, len(records))
1890 + for i := 1; i < len(records); i++ {
1891 + rec := records[i]
1892 + if len(rec) != 7 {
1893 + return nil, fmt.Errorf("assertion inventory %q line %d: expected 7 columns, got %d", path, i+1, len(rec))
1894 + }
1895 + methods[rec[0]+"#"+rec[1]] = struct{}{}
1896 + }
1897 + return methods, nil
1898 +}
1899 +
1900 +func readAssertionInventoryCount(path string) (int, error) {
1901 + f, err := os.Open(path)
1902 + if err != nil {
1903 + return 0, fmt.Errorf("open assertion inventory %q: %w", path, err)
1904 + }
1905 + defer f.Close()
1906 +
1907 + r := csv.NewReader(f)
1908 + records, err := r.ReadAll()
1909 + if err != nil {
1910 + return 0, fmt.Errorf("read assertion inventory %q: %w", path, err)
1911 + }
1912 + if len(records) == 0 {
1913 + return 0, fmt.Errorf("assertion inventory %q is empty", path)
1914 + }
1915 + header := strings.Join(records[0], ",")
1916 + expectedHeader := "class,method,assertion_id,source_file,line,assert_call,protocol_scope"
1917 + if header != expectedHeader {
1918 + return 0, fmt.Errorf("unexpected assertion inventory header in %q: %q", path, header)
1919 + }
1920 + return len(records) - 1, nil
1921 +}
1922 +
1923 +func truncateWhitespace(s string, maxLen int) string {
1924 + s = strings.TrimSpace(s)
1925 + if len(s) <= maxLen {
1926 + return s
1927 + }
1928 + return strings.TrimSpace(s[:maxLen]) + "...(truncated)"
1929 +}
1930 +
1931 +func requireDir(p string) error {
1932 + st, err := os.Stat(p)
1933 + if err != nil {
1934 + return err
1935 + }
1936 + if !st.IsDir() {
1937 + return fmt.Errorf("%q is not a directory", p)
1938 + }
1939 + return nil
1940 +}
1941 +
1942 +func syncFixtureMirror(srcRoot, dstRoot string) error {
1943 + if err := os.MkdirAll(dstRoot, 0o755); err != nil {
1944 + return fmt.Errorf("mkdir destination root: %w", err)
1945 + }
1946 +
1947 + sourceFiles := make(map[string]struct{}, 256)
1948 + sourceDirs := make(map[string]struct{}, 64)
1949 + sourceDirs["."] = struct{}{}
1950 +
1951 + if err := filepath.WalkDir(srcRoot, func(srcPath string, d fs.DirEntry, walkErr error) error {
1952 + if walkErr != nil {
1953 + return walkErr
1954 + }
1955 + rel, err := filepath.Rel(srcRoot, srcPath)
1956 + if err != nil {
1957 + return err
1958 + }
1959 + if rel == "." {
1960 + return nil
1961 + }
1962 + rel = filepath.Clean(rel)
1963 + dstPath := filepath.Join(dstRoot, rel)
1964 +
1965 + if d.IsDir() {
1966 + sourceDirs[rel] = struct{}{}
1967 + return os.MkdirAll(dstPath, 0o755)
1968 + }
1969 +
1970 + info, err := d.Info()
1971 + if err != nil {
1972 + return err
1973 + }
1974 + if !info.Mode().IsRegular() {
1975 + return nil
1976 + }
1977 + sourceFiles[rel] = struct{}{}
1978 + if err := copyFile(srcPath, dstPath, info.Mode()); err != nil {
1979 + return err
1980 + }
1981 + return nil
1982 + }); err != nil {
1983 + return err
1984 + }
1985 +
1986 + if err := pruneMirror(dstRoot, sourceFiles, sourceDirs); err != nil {
1987 + return err
1988 + }
1989 + return nil
1990 +}
1991 +
1992 +func copyFile(srcPath, dstPath string, mode fs.FileMode) error {
1993 + src, err := os.Open(srcPath)
1994 + if err != nil {
1995 + return fmt.Errorf("open source %q: %w", srcPath, err)
1996 + }
1997 + defer src.Close()
1998 +
1999 + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
2000 + return fmt.Errorf("mkdir parent for %q: %w", dstPath, err)
2001 + }
2002 +
2003 + dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode.Perm())
2004 + if err != nil {
2005 + return fmt.Errorf("open destination %q: %w", dstPath, err)
2006 + }
2007 + defer dst.Close()
2008 +
2009 + if _, err := io.Copy(dst, src); err != nil {
2010 + return fmt.Errorf("copy %q -> %q: %w", srcPath, dstPath, err)
2011 + }
2012 + return nil
2013 +}
2014 +
2015 +func pruneMirror(dstRoot string, sourceFiles, sourceDirs map[string]struct{}) error {
2016 + var allPaths []string
2017 + if err := filepath.WalkDir(dstRoot, func(dstPath string, d fs.DirEntry, walkErr error) error {
2018 + if walkErr != nil {
2019 + return walkErr
2020 + }
2021 + rel, err := filepath.Rel(dstRoot, dstPath)
2022 + if err != nil {
2023 + return err
2024 + }
2025 + if rel == "." {
2026 + return nil
2027 + }
2028 + allPaths = append(allPaths, filepath.Clean(rel))
2029 + return nil
2030 + }); err != nil {
2031 + return err
2032 + }
2033 +
2034 + // Remove files first, then directories deepest-first.
2035 + sort.Slice(allPaths, func(i, j int) bool {
2036 + di := strings.Count(allPaths[i], string(filepath.Separator))
2037 + dj := strings.Count(allPaths[j], string(filepath.Separator))
2038 + if di != dj {
2039 + return di > dj
2040 + }
2041 + return allPaths[i] > allPaths[j]
2042 + })
2043 +
2044 + for _, rel := range allPaths {
2045 + dstPath := filepath.Join(dstRoot, rel)
2046 + info, err := os.Lstat(dstPath)
2047 + if err != nil {
2048 + if errors.Is(err, os.ErrNotExist) {
2049 + continue
2050 + }
2051 + return err
2052 + }
2053 +
2054 + if info.IsDir() {
2055 + if _, ok := sourceDirs[rel]; ok {
2056 + continue
2057 + }
2058 + if err := os.Remove(dstPath); err != nil && !errors.Is(err, os.ErrNotExist) {
2059 + return fmt.Errorf("remove stale dir %q: %w", dstPath, err)
2060 + }
2061 + continue
2062 + }
2063 +
2064 + if _, ok := sourceFiles[rel]; ok {
2065 + continue
2066 + }
2067 + if err := os.Remove(dstPath); err != nil && !errors.Is(err, os.ErrNotExist) {
2068 + return fmt.Errorf("remove stale file %q: %w", dstPath, err)
2069 + }
2070 + }
2071 + return nil
2072 +}
2073 +
2074 +func collectFixtureInventory(root, upstreamRelPrefix string) ([]fixtureRow, error) {
2075 + rows := make([]fixtureRow, 0, 256)
2076 + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
2077 + if walkErr != nil {
2078 + return walkErr
2079 + }
2080 + if d.IsDir() {
2081 + return nil
2082 + }
2083 + info, err := d.Info()
2084 + if err != nil {
2085 + return err
2086 + }
2087 + if !info.Mode().IsRegular() {
2088 + return nil
2089 + }
2090 +
2091 + rel, err := filepath.Rel(root, p)
2092 + if err != nil {
2093 + return err
2094 + }
2095 + rel = filepath.ToSlash(filepath.Clean(rel))
2096 + parts := strings.Split(rel, "/")
2097 + if len(parts) < 2 {
2098 + return fmt.Errorf("unexpected fixture relative path %q", rel)
2099 + }
2100 +
2101 + hashValue, err := sha256File(p)
2102 + if err != nil {
2103 + return err
2104 + }
2105 +
2106 + rows = append(rows, fixtureRow{
2107 + Scenario: parts[0],
2108 + File: path.Base(rel),
2109 + RelativePath: rel,
2110 + SHA256: hashValue,
2111 + SizeBytes: info.Size(),
2112 + UpstreamPath: path.Join(filepath.ToSlash(upstreamRelPrefix), rel),
2113 + })
2114 + return nil
2115 + })
2116 + if err != nil {
2117 + return nil, err
2118 + }
2119 +
2120 + sort.Slice(rows, func(i, j int) bool {
2121 + if rows[i].RelativePath != rows[j].RelativePath {
2122 + return rows[i].RelativePath < rows[j].RelativePath
2123 + }
2124 + return rows[i].SHA256 < rows[j].SHA256
2125 + })
2126 + return rows, nil
2127 +}
2128 +
2129 +func sha256File(p string) (string, error) {
2130 + f, err := os.Open(p)
2131 + if err != nil {
2132 + return "", err
2133 + }
2134 + defer f.Close()
2135 +
2136 + h := sha256.New()
2137 + if _, err := io.Copy(h, f); err != nil {
2138 + return "", err
2139 + }
2140 + return fmt.Sprintf("%x", h.Sum(nil)), nil
2141 +}
2142 +
2143 +func listScopedTestFiles(enlinkdRoot string) ([]string, error) {
2144 + scopedRoots := []string{
2145 + filepath.Join(enlinkdRoot, defaultScopedTestsRelEn),
2146 + filepath.Join(enlinkdRoot, defaultScopedTestsRelNB),
2147 + }
2148 +
2149 + files := make([]string, 0, 32)
2150 + for _, root := range scopedRoots {
2151 + if _, err := os.Stat(root); errors.Is(err, os.ErrNotExist) {
2152 + continue
2153 + }
2154 + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
2155 + if walkErr != nil {
2156 + return walkErr
2157 + }
2158 + if d.IsDir() {
2159 + return nil
2160 + }
2161 + name := d.Name()
2162 + if !(testFileNameITRE.MatchString(name) || testFileNameTestR.MatchString(name)) {
2163 + return nil
2164 + }
2165 + rel, err := filepath.Rel(enlinkdRoot, p)
2166 + if err != nil {
2167 + return err
2168 + }
2169 + files = append(files, filepath.ToSlash(rel))
2170 + return nil
2171 + })
2172 + if err != nil {
2173 + return nil, err
2174 + }
2175 + }
2176 +
2177 + sort.Strings(files)
2178 + return files, nil
2179 +}
2180 +
2181 +func collectTestAndAssertionInventories(enlinkdRoot string, methodFiles, assertionFiles []string) ([]methodRow, []assertionRow, error) {
2182 + methods := make([]methodRow, 0, 256)
2183 + assertions := make([]assertionRow, 0, 5000)
2184 +
2185 + for _, rel := range methodFiles {
2186 + abs := filepath.Join(enlinkdRoot, filepath.FromSlash(rel))
2187 + fileMethods, _, err := parseJavaTestFile(abs, rel)
2188 + if err != nil {
2189 + return nil, nil, fmt.Errorf("parse %q: %w", rel, err)
2190 + }
2191 + methods = append(methods, fileMethods...)
2192 + }
2193 +
2194 + for _, rel := range assertionFiles {
2195 + abs := filepath.Join(enlinkdRoot, filepath.FromSlash(rel))
2196 + _, fileAssertions, err := parseJavaTestFile(abs, rel)
2197 + if err != nil {
2198 + return nil, nil, fmt.Errorf("parse assertions in %q: %w", rel, err)
2199 + }
2200 + assertions = append(assertions, fileAssertions...)
2201 + }
2202 +
2203 + sort.Slice(methods, func(i, j int) bool {
2204 + if methods[i].Class != methods[j].Class {
2205 + return methods[i].Class < methods[j].Class
2206 + }
2207 + if methods[i].Method != methods[j].Method {
2208 + return methods[i].Method < methods[j].Method
2209 + }
2210 + return methods[i].SourceFile < methods[j].SourceFile
2211 + })
2212 +
2213 + sort.Slice(assertions, func(i, j int) bool {
2214 + if assertions[i].Class != assertions[j].Class {
2215 + return assertions[i].Class < assertions[j].Class
2216 + }
2217 + if assertions[i].Method != assertions[j].Method {
2218 + return assertions[i].Method < assertions[j].Method
2219 + }
2220 + if assertions[i].Line != assertions[j].Line {
2221 + return assertions[i].Line < assertions[j].Line
2222 + }
2223 + return assertions[i].AssertionID < assertions[j].AssertionID
2224 + })
2225 +
2226 + return methods, assertions, nil
2227 +}
2228 +
2229 +func listScopedJavaFiles(enlinkdRoot string) ([]string, error) {
2230 + scopedRoots := []string{
2231 + filepath.Join(enlinkdRoot, defaultScopedTestsRelEn),
2232 + filepath.Join(enlinkdRoot, defaultScopedTestsRelNB),
2233 + }
2234 +
2235 + files := make([]string, 0, 64)
2236 + for _, root := range scopedRoots {
2237 + if _, err := os.Stat(root); errors.Is(err, os.ErrNotExist) {
2238 + continue
2239 + }
2240 + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
2241 + if walkErr != nil {
2242 + return walkErr
2243 + }
2244 + if d.IsDir() {
2245 + return nil
2246 + }
2247 + if filepath.Ext(d.Name()) != ".java" {
2248 + return nil
2249 + }
2250 + rel, err := filepath.Rel(enlinkdRoot, p)
2251 + if err != nil {
2252 + return err
2253 + }
2254 + files = append(files, filepath.ToSlash(rel))
2255 + return nil
2256 + })
2257 + if err != nil {
2258 + return nil, err
2259 + }
2260 + }
2261 + sort.Strings(files)
2262 + return files, nil
2263 +}
2264 +
2265 +func parseJavaTestFile(absPath, relPath string) ([]methodRow, []assertionRow, error) {
2266 + data, err := os.ReadFile(absPath)
2267 + if err != nil {
2268 + return nil, nil, err
2269 + }
2270 +
2271 + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
2272 + if len(lines) == 0 {
2273 + return nil, nil, nil
2274 + }
2275 +
2276 + packageName := ""
2277 + className := ""
2278 + methods := make([]methodRow, 0, 16)
2279 + ranges := make([]methodRange, 0, 16)
2280 + candidates := make([]assertionCandidate, 0, 128)
2281 +
2282 + inBlockComment := false
2283 + pendingTest := false
2284 + gatheringSignature := false
2285 + signatureStartLine := 0
2286 + signatureBuilder := strings.Builder{}
2287 + inMethod := false
2288 + methodName := ""
2289 + methodStartLine := 0
2290 + methodScope := ""
2291 + braceDepth := 0
2292 + var methodLines []string
2293 + var methodLineNumbers []int
2294 +
2295 + for i, rawLine := range lines {
2296 + lineNo := i + 1
2297 + cleanLine, nextInBlockComment := stripJavaLine(rawLine, inBlockComment)
2298 + inBlockComment = nextInBlockComment
2299 + trimmed := strings.TrimSpace(cleanLine)
2300 +
2301 + if matches := assertionCallRE.FindAllStringSubmatchIndex(cleanLine, -1); len(matches) > 0 {
2302 + for _, m := range matches {
2303 + if len(m) < 4 {
2304 + continue
2305 + }
2306 + candidates = append(candidates, assertionCandidate{
2307 + Line: lineNo,
2308 + Call: cleanLine[m[2]:m[3]],
2309 + })
2310 + }
2311 + }
2312 +
2313 + if packageName == "" {
2314 + if m := packageRE.FindStringSubmatch(trimmed); len(m) == 2 {
2315 + packageName = m[1]
2316 + }
2317 + }
2318 + if className == "" {
2319 + if m := classRE.FindStringSubmatch(trimmed); len(m) == 2 {
2320 + className = m[1]
2321 + }
2322 + }
2323 +
2324 + if inMethod {
2325 + methodLines = append(methodLines, rawLine)
2326 + methodLineNumbers = append(methodLineNumbers, lineNo)
2327 + braceDepth += countBraces(cleanLine)
2328 +
2329 + if braceDepth <= 0 {
2330 + fqcn := buildClassName(packageName, className)
2331 + methods = append(methods, methodRow{
2332 + Class: fqcn,
2333 + Method: methodName,
2334 + SourceFile: relPath,
2335 + ProtocolScope: methodScope,
2336 + })
2337 + ranges = append(ranges, methodRange{
2338 + Name: methodName,
2339 + Start: methodStartLine,
2340 + End: lineNo,
2341 + Scope: methodScope,
2342 + })
2343 +
2344 + inMethod = false
2345 + methodName = ""
2346 + methodStartLine = 0
2347 + methodScope = ""
2348 + braceDepth = 0
2349 + methodLines = nil
2350 + methodLineNumbers = nil
2351 + }
2352 + continue
2353 + }
2354 +
2355 + if testAnnotationRE.MatchString(trimmed) {
2356 + pendingTest = true
2357 + gatheringSignature = false
2358 + signatureBuilder.Reset()
2359 + signatureStartLine = 0
2360 + continue
2361 + }
2362 +
2363 + if !pendingTest {
2364 + continue
2365 + }
2366 +
2367 + if trimmed == "" {
2368 + continue
2369 + }
2370 + if strings.HasPrefix(trimmed, "@") {
2371 + // Additional annotations between @Test and method signature.
2372 + continue
2373 + }
2374 +
2375 + if !gatheringSignature {
2376 + if !looksLikeMethodDeclarationStart(trimmed) {
2377 + // Skip annotation trailers like "})" that can appear after @Test annotations.
2378 + continue
2379 + }
2380 + gatheringSignature = true
2381 + signatureStartLine = lineNo
2382 + }
2383 + if signatureBuilder.Len() > 0 {
2384 + signatureBuilder.WriteByte(' ')
2385 + }
2386 + signatureBuilder.WriteString(trimmed)
2387 +
2388 + if !strings.Contains(cleanLine, "{") {
2389 + continue
2390 + }
2391 +
2392 + name := extractMethodName(signatureBuilder.String())
2393 + if name == "" {
2394 + return nil, nil, fmt.Errorf("unable to parse test method name in %s near line %d", relPath, signatureStartLine)
2395 + }
2396 +
2397 + methodName = name
2398 + methodStartLine = signatureStartLine
2399 + methodScope = detectProtocolScope(methodName, relPath, signatureBuilder.String())
2400 + braceDepth = countBraces(signatureBuilder.String())
2401 + methodLines = []string{rawLine}
2402 + methodLineNumbers = []int{lineNo}
2403 + inMethod = true
2404 + pendingTest = false
2405 + gatheringSignature = false
2406 + signatureBuilder.Reset()
2407 + signatureStartLine = 0
2408 +
2409 + if braceDepth <= 0 {
2410 + // Single-line method body.
2411 + fqcn := buildClassName(packageName, className)
2412 + methods = append(methods, methodRow{
2413 + Class: fqcn,
2414 + Method: methodName,
2415 + SourceFile: relPath,
2416 + ProtocolScope: methodScope,
2417 + })
2418 + ranges = append(ranges, methodRange{
2419 + Name: methodName,
2420 + Start: methodStartLine,
2421 + End: lineNo,
2422 + Scope: methodScope,
2423 + })
2424 + inMethod = false
2425 + methodName = ""
2426 + methodStartLine = 0
2427 + methodScope = ""
2428 + braceDepth = 0
2429 + methodLines = nil
2430 + methodLineNumbers = nil
2431 + }
2432 +
2433 + _ = methodStartLine
2434 + }
2435 +
2436 + if inMethod {
2437 + return nil, nil, fmt.Errorf("unterminated method %q in %s near line %d", methodName, relPath, methodStartLine)
2438 + }
2439 + fqcn := buildClassName(packageName, className)
2440 + assertions := collectAssertionsForFile(fqcn, relPath, candidates, ranges)
2441 + return methods, assertions, nil
2442 +}
2443 +
2444 +func looksLikeMethodDeclarationStart(trimmed string) bool {
2445 + if strings.HasPrefix(trimmed, "public ") || strings.HasPrefix(trimmed, "protected ") || strings.HasPrefix(trimmed, "private ") {
2446 + return true
2447 + }
2448 + // Some files may use package-private visibility for tests.
2449 + return strings.Contains(trimmed, "(") && !strings.HasPrefix(trimmed, "}") && !strings.HasPrefix(trimmed, ")")
2450 +}
2451 +
2452 +func stripJavaLine(line string, inBlockComment bool) (string, bool) {
2453 + var out strings.Builder
2454 + escaped := false
2455 + inString := false
2456 + inChar := false
2457 +
2458 + for i := 0; i < len(line); i++ {
2459 + ch := line[i]
2460 +
2461 + if inBlockComment {
2462 + if ch == '*' && i+1 < len(line) && line[i+1] == '/' {
2463 + inBlockComment = false
2464 + i++
2465 + }
2466 + continue
2467 + }
2468 +
2469 + if inString {
2470 + if escaped {
2471 + escaped = false
2472 + continue
2473 + }
2474 + if ch == '\\' {
2475 + escaped = true
2476 + continue
2477 + }
2478 + if ch == '"' {
2479 + inString = false
2480 + }
2481 + continue
2482 + }
2483 +
2484 + if inChar {
2485 + if escaped {
2486 + escaped = false
2487 + continue
2488 + }
2489 + if ch == '\\' {
2490 + escaped = true
2491 + continue
2492 + }
2493 + if ch == '\'' {
2494 + inChar = false
2495 + }
2496 + continue
2497 + }
2498 +
2499 + if ch == '/' && i+1 < len(line) {
2500 + next := line[i+1]
2501 + if next == '/' {
2502 + break
2503 + }
2504 + if next == '*' {
2505 + inBlockComment = true
2506 + i++
2507 + continue
2508 + }
2509 + }
2510 +
2511 + if ch == '"' {
2512 + inString = true
2513 + continue
2514 + }
2515 + if ch == '\'' {
2516 + inChar = true
2517 + continue
2518 + }
2519 + out.WriteByte(ch)
2520 + }
2521 +
2522 + return out.String(), inBlockComment
2523 +}
2524 +
2525 +func countBraces(s string) int {
2526 + delta := 0
2527 + for i := 0; i < len(s); i++ {
2528 + switch s[i] {
2529 + case '{':
2530 + delta++
2531 + case '}':
2532 + delta--
2533 + }
2534 + }
2535 + return delta
2536 +}
2537 +
2538 +func extractMethodName(signature string) string {
2539 + idx := strings.Index(signature, "(")
2540 + if idx <= 0 {
2541 + return ""
2542 + }
2543 + before := strings.TrimSpace(signature[:idx])
2544 + fields := strings.Fields(before)
2545 + if len(fields) == 0 {
2546 + return ""
2547 + }
2548 + name := fields[len(fields)-1]
2549 + if !identifierRE.MatchString(name) {
2550 + return ""
2551 + }
2552 + switch name {
2553 + case "if", "for", "while", "switch", "catch", "new", "return", "try":
2554 + return ""
2555 + }
2556 + return name
2557 +}
2558 +
2559 +func buildClassName(packageName, className string) string {
2560 + if packageName == "" {
2561 + return className
2562 + }
2563 + if className == "" {
2564 + return packageName
2565 + }
2566 + return packageName + "." + className
2567 +}
2568 +
2569 +func detectProtocolScope(methodName, sourceFile, material string) string {
2570 + s := strings.ToLower(methodName + " " + sourceFile + " " + material)
2571 +
2572 + scopes := make([]string, 0, 4)
2573 + add := func(scope string) {
2574 + if slices.Contains(scopes, scope) {
2575 + return
2576 + }
2577 + scopes = append(scopes, scope)
2578 + }
2579 +
2580 + if hasAny(s, "lldp", "chassis", "portid", "remport", "lldpre") {
2581 + add("lldp")
2582 + }
2583 + if hasAny(s, "cdp", "cisco") {
2584 + add("cdp")
2585 + }
2586 + if hasAny(s, "bridge", "fdb", "dot1d", "sharedsegment", "broadcastdomain", "stp", "vlan", "bridgemac") {
2587 + add("bridge_fdb")
2588 + }
2589 + if hasAny(s, "arp", "ipnettomedia", "neighbor", "neighbour", "ndp", "iproute") {
2590 + add("arp_nd")
2591 + }
2592 +
2593 + if len(scopes) == 0 {
2594 + return "other"
2595 + }
2596 + sort.Strings(scopes)
2597 + return strings.Join(scopes, "|")
2598 +}
2599 +
2600 +func hasAny(s string, tokens ...string) bool {
2601 + for _, token := range tokens {
2602 + if strings.Contains(s, token) {
2603 + return true
2604 + }
2605 + }
2606 + return false
2607 +}
2608 +
2609 +func collectAssertionsForFile(className, sourceFile string, candidates []assertionCandidate, ranges []methodRange) []assertionRow {
2610 + rows := make([]assertionRow, 0, len(candidates))
2611 + counters := make(map[string]int, len(ranges))
2612 + for _, c := range candidates {
2613 + methodName := ""
2614 + scope := ""
2615 + for _, r := range ranges {
2616 + if c.Line >= r.Start && c.Line <= r.End {
2617 + methodName = r.Name
2618 + scope = r.Scope
2619 + break
2620 + }
2621 + }
2622 +
2623 + // Keep assertion inventory scoped to discovered @Test methods only.
2624 + // Assertions in helpers/non-test code are not directly mappable to
2625 + // method inventory and create false parity gaps.
2626 + if methodName == "" {
2627 + continue
2628 + }
2629 +
2630 + counters[methodName]++
2631 + rows = append(rows, assertionRow{
2632 + Class: className,
2633 + Method: methodName,
2634 + AssertionID: fmt.Sprintf("%s#%s#A%04d", className, methodName, counters[methodName]),
2635 + SourceFile: sourceFile,
2636 + Line: c.Line,
2637 + AssertCall: c.Call,
2638 + ProtocolScope: scope,
2639 + })
2640 + }
2641 + return rows
2642 +}
2643 +
2644 +func writeFixtureInventoryCSV(outPath string, rows []fixtureRow) error {
2645 + f, err := os.Create(outPath)
2646 + if err != nil {
2647 + return fmt.Errorf("create %q: %w", outPath, err)
2648 + }
2649 + defer f.Close()
2650 +
2651 + w := csv.NewWriter(f)
2652 + if err := w.Write([]string{"scenario", "file", "relative_path", "sha256", "size_bytes", "upstream_path"}); err != nil {
2653 + return err
2654 + }
2655 + for _, row := range rows {
2656 + record := []string{
2657 + row.Scenario,
2658 + row.File,
2659 + row.RelativePath,
2660 + row.SHA256,
2661 + strconv.FormatInt(row.SizeBytes, 10),
2662 + row.UpstreamPath,
2663 + }
2664 + if err := w.Write(record); err != nil {
2665 + return err
2666 + }
2667 + }
2668 + w.Flush()
2669 + if err := w.Error(); err != nil {
2670 + return fmt.Errorf("write %q: %w", outPath, err)
2671 + }
2672 + return nil
2673 +}
2674 +
2675 +func writeMethodInventoryCSV(outPath string, rows []methodRow) error {
2676 + f, err := os.Create(outPath)
2677 + if err != nil {
2678 + return fmt.Errorf("create %q: %w", outPath, err)
2679 + }
2680 + defer f.Close()
2681 +
2682 + w := csv.NewWriter(f)
2683 + if err := w.Write([]string{"class", "method", "source_file", "protocol_scope"}); err != nil {
2684 + return err
2685 + }
2686 + for _, row := range rows {
2687 + record := []string{row.Class, row.Method, row.SourceFile, row.ProtocolScope}
2688 + if err := w.Write(record); err != nil {
2689 + return err
2690 + }
2691 + }
2692 + w.Flush()
2693 + if err := w.Error(); err != nil {
2694 + return fmt.Errorf("write %q: %w", outPath, err)
2695 + }
2696 + return nil
2697 +}
2698 +
2699 +func writeAssertionInventoryCSV(outPath string, rows []assertionRow) error {
2700 + f, err := os.Create(outPath)
2701 + if err != nil {
2702 + return fmt.Errorf("create %q: %w", outPath, err)
2703 + }
2704 + defer f.Close()
2705 +
2706 + w := csv.NewWriter(f)
2707 + if err := w.Write([]string{"class", "method", "assertion_id", "source_file", "line", "assert_call", "protocol_scope"}); err != nil {
2708 + return err
2709 + }
2710 + for _, row := range rows {
2711 + record := []string{
2712 + row.Class,
2713 + row.Method,
2714 + row.AssertionID,
2715 + row.SourceFile,
2716 + strconv.Itoa(row.Line),
2717 + row.AssertCall,
2718 + row.ProtocolScope,
2719 + }
2720 + if err := w.Write(record); err != nil {
2721 + return err
2722 + }
2723 + }
2724 + w.Flush()
2725 + if err := w.Error(); err != nil {
2726 + return fmt.Errorf("write %q: %w", outPath, err)
2727 + }
2728 + return nil
2729 +}
2730 +
2731 +func readFixtureInventoryCSV(inPath string) ([]fixtureRow, error) {
2732 + f, err := os.Open(inPath)
2733 + if err != nil {
2734 + return nil, fmt.Errorf("open %q: %w", inPath, err)
2735 + }
2736 + defer f.Close()
2737 +
2738 + r := csv.NewReader(f)
2739 + records, err := r.ReadAll()
2740 + if err != nil {
2741 + return nil, fmt.Errorf("read %q: %w", inPath, err)
2742 + }
2743 + if len(records) == 0 {
2744 + return nil, fmt.Errorf("%q is empty", inPath)
2745 + }
2746 + header := strings.Join(records[0], ",")
2747 + expectedHeader := "scenario,file,relative_path,sha256,size_bytes,upstream_path"
2748 + if header != expectedHeader {
2749 + return nil, fmt.Errorf("unexpected header in %q: %q", inPath, header)
2750 + }
2751 +
2752 + rows := make([]fixtureRow, 0, len(records)-1)
2753 + for i := 1; i < len(records); i++ {
2754 + rec := records[i]
2755 + if len(rec) != 6 {
2756 + return nil, fmt.Errorf("%q line %d: expected 6 columns, got %d", inPath, i+1, len(rec))
2757 + }
2758 + size, err := strconv.ParseInt(rec[4], 10, 64)
2759 + if err != nil {
2760 + return nil, fmt.Errorf("%q line %d: invalid size_bytes %q: %w", inPath, i+1, rec[4], err)
2761 + }
2762 + rows = append(rows, fixtureRow{
2763 + Scenario: rec[0],
2764 + File: rec[1],
2765 + RelativePath: rec[2],
2766 + SHA256: rec[3],
2767 + SizeBytes: size,
2768 + UpstreamPath: rec[5],
2769 + })
2770 + }
2771 +
2772 + sort.Slice(rows, func(i, j int) bool {
2773 + if rows[i].RelativePath != rows[j].RelativePath {
2774 + return rows[i].RelativePath < rows[j].RelativePath
2775 + }
2776 + return rows[i].SHA256 < rows[j].SHA256
2777 + })
2778 + return rows, nil
2779 +}
2780 +
2781 +func compareFixtureInventories(expected, actual []fixtureRow) error {
2782 + if len(expected) != len(actual) {
2783 + return fmt.Errorf("row count mismatch: expected %d, got %d", len(expected), len(actual))
2784 + }
2785 +
2786 + for i := range expected {
2787 + e := expected[i]
2788 + a := actual[i]
2789 + if e.RelativePath != a.RelativePath {
2790 + return fmt.Errorf("relative_path mismatch at row %d: expected %q, got %q", i+1, e.RelativePath, a.RelativePath)
2791 + }
2792 + if e.SHA256 != a.SHA256 {
2793 + return fmt.Errorf("sha256 mismatch for %q: expected %s, got %s", e.RelativePath, e.SHA256, a.SHA256)
2794 + }
2795 + if e.SizeBytes != a.SizeBytes {
2796 + return fmt.Errorf("size mismatch for %q: expected %d, got %d", e.RelativePath, e.SizeBytes, a.SizeBytes)
2797 + }
2798 + if e.UpstreamPath != a.UpstreamPath {
2799 + return fmt.Errorf("upstream_path mismatch for %q: expected %q, got %q", e.RelativePath, e.UpstreamPath, a.UpstreamPath)
2800 + }
2801 + }
2802 + return nil
2803 +}
2804 +
2805 +func countDistinctScenarios(rows []fixtureRow) int {
2806 + set := make(map[string]struct{}, len(rows))
2807 + for _, row := range rows {
2808 + set[row.Scenario] = struct{}{}
2809 + }
2810 + return len(set)
2811 +}
2812 +
2813 +func countDistinctFiles(rows []methodRow) int {
2814 + set := make(map[string]struct{}, len(rows))
2815 + for _, row := range rows {
2816 + set[row.SourceFile] = struct{}{}
2817 + }
2818 + return len(set)
2819 +}
src/plugins.d/FUNCTION_UI_SCHEMA.json
+405 -2
@@ -11,6 +11,8 @@
11 "oneOf": [
12 { "$ref": "#/definitions/info_response" },
13 { "$ref": "#/definitions/data_response" },
14 + { "$ref": "#/definitions/topology_response" },
15 + { "$ref": "#/definitions/flows_response" },
16 { "$ref": "#/definitions/error_response" },
17 { "$ref": "#/definitions/not_modified_response" }
18 ],
@@ -66,6 +68,48 @@
68 "type": "string",
69 "enum": ["none", "range", "multiselect", "facet"]
70 },
71 + "facet_value": {
72 + "type": "object",
73 + "required": ["value"],
74 + "properties": {
75 + "value": { "type": ["string", "number", "boolean"] },
76 + "name": { "type": "string" }
77 + },
78 + "additionalProperties": true
79 + },
80 + "facet_field": {
81 + "type": "object",
82 + "required": ["field"],
83 + "properties": {
84 + "field": { "type": "string" },
85 + "name": { "type": "string" },
86 + "total_values": { "type": "integer", "minimum": 0 },
87 + "truncated": { "type": "boolean" },
88 + "autocomplete": { "type": "boolean" },
89 + "overflowed": { "type": "boolean" },
90 + "overflow_records": { "type": "integer", "minimum": 0 },
91 + "values": {
92 + "type": "array",
93 + "items": { "$ref": "#/definitions/facet_value" }
94 + }
95 + },
96 + "additionalProperties": true
97 + },
98 + "facets": {
99 + "type": "object",
100 + "properties": {
101 + "value_limit": { "type": "integer" },
102 + "excluded_fields": { "type": "array", "items": { "type": "string" } },
103 + "overflowed_fields": { "type": "integer" },
104 + "overflowed_records": { "type": "integer" },
105 + "fields": {
106 + "type": "array",
107 + "items": { "$ref": "#/definitions/facet_field" }
108 + },
109 + "auto": { "type": "object" }
110 + },
111 + "additionalProperties": true
112 + },
113 "value_options": {
114 "type": "object",
115 "properties": {
@@ -178,7 +222,8 @@
222 "v": { "type": "integer" },
223 "help": { "type": "string" },
224 "update_every": { "type": "integer" },
181 - "expires": { "type": "integer" }
225 + "expires": { "type": "integer" },
226 + "presentation": { "$ref": "#/definitions/topology_presentation" }
227 },
228 "allOf": [
229 { "not": { "required": ["columns"] } },
@@ -222,13 +267,371 @@
267 "items": { "type": "string" }
268 }
269 },
225 - "facets": { "type": ["array", "object"] },
270 + "facets": { "$ref": "#/definitions/facets" },
271 "histogram": { "type": "object" },
272 "items": { "type": "object" },
273 "pagination": { "type": "object" }
274 },
275 "additionalProperties": true
276 },
277 + "topology_presentation_actor_type": {
278 + "type": "object",
279 + "required": ["label", "color_slot"],
280 + "properties": {
281 + "label": { "type": "string" },
282 + "color_slot": { "type": "string" },
283 + "opacity": { "type": "number", "minimum": 0, "maximum": 1 },
284 + "border": { "type": "boolean" },
285 + "role": { "type": "string", "enum": ["actor", "endpoint"] },
286 + "size_by_links": { "type": "boolean" },
287 + "show_port_bullets": { "type": "boolean" },
288 + "icon_svg": { "type": "string" },
289 + "summary_fields": {
290 + "type": "array",
291 + "items": { "$ref": "#/definitions/topology_presentation_summary_field" }
292 + },
293 + "tables": {
294 + "type": "object",
295 + "additionalProperties": { "$ref": "#/definitions/topology_presentation_table" }
296 + },
297 + "modal_tabs": {
298 + "type": "array",
299 + "items": { "$ref": "#/definitions/topology_presentation_modal_tab" }
300 + }
301 + },
302 + "additionalProperties": true
303 + },
304 + "topology_presentation_link_type": {
305 + "type": "object",
306 + "required": ["label", "color_slot"],
307 + "properties": {
308 + "label": { "type": "string" },
309 + "color_slot": { "type": "string" },
310 + "opacity": { "type": "number", "minimum": 0, "maximum": 1 },
311 + "width": { "type": "number", "minimum": 0 },
312 + "dash": { "type": "boolean" }
313 + },
314 + "additionalProperties": true
315 + },
316 + "topology_presentation_port_type": {
317 + "type": "object",
318 + "required": ["label", "color_slot"],
319 + "properties": {
320 + "label": { "type": "string" },
321 + "color_slot": { "type": "string" },
322 + "opacity": { "type": "number", "minimum": 0, "maximum": 1 }
323 + },
324 + "additionalProperties": true
325 + },
326 + "topology_presentation_summary_field": {
327 + "type": "object",
328 + "required": ["key", "label", "sources"],
329 + "properties": {
330 + "key": { "type": "string" },
331 + "label": { "type": "string" },
332 + "sources": {
333 + "type": "array",
334 + "items": { "type": "string" }
335 + }
336 + },
337 + "additionalProperties": true
338 + },
339 + "topology_presentation_table_column": {
340 + "type": "object",
341 + "required": ["key", "label"],
342 + "properties": {
343 + "key": { "type": "string" },
344 + "label": { "type": "string" },
345 + "type": { "type": "string" }
346 + },
347 + "additionalProperties": true
348 + },
349 + "topology_presentation_table": {
350 + "type": "object",
351 + "required": ["label", "source", "columns"],
352 + "properties": {
353 + "label": { "type": "string" },
354 + "source": {
355 + "type": "string",
356 + "enum": ["data", "links"]
357 + },
358 + "bullet_source": { "type": "boolean" },
359 + "columns": {
360 + "type": "array",
361 + "items": { "$ref": "#/definitions/topology_presentation_table_column" }
362 + }
363 + },
364 + "additionalProperties": true
365 + },
366 + "topology_presentation_modal_tab": {
367 + "type": "object",
368 + "required": ["id", "label"],
369 + "properties": {
370 + "id": { "type": "string" },
371 + "label": { "type": "string" },
372 + "type": { "type": "string" }
373 + },
374 + "additionalProperties": true
375 + },
376 + "topology_presentation_legend_entry": {
377 + "type": "object",
378 + "required": ["type", "label"],
379 + "properties": {
380 + "type": { "type": "string" },
381 + "label": { "type": "string" }
382 + },
383 + "additionalProperties": true
384 + },
385 + "topology_presentation_legend": {
386 + "type": "object",
387 + "required": ["actors", "links"],
388 + "properties": {
389 + "actors": {
390 + "type": "array",
391 + "items": { "$ref": "#/definitions/topology_presentation_legend_entry" }
392 + },
393 + "links": {
394 + "type": "array",
395 + "items": { "$ref": "#/definitions/topology_presentation_legend_entry" }
396 + },
397 + "ports": {
398 + "type": "array",
399 + "items": { "$ref": "#/definitions/topology_presentation_legend_entry" }
400 + }
401 + },
402 + "additionalProperties": true
403 + },
404 + "topology_presentation": {
405 + "type": "object",
406 + "required": ["actor_types", "link_types", "legend", "actor_click_behavior"],
407 + "properties": {
408 + "actor_types": {
409 + "type": "object",
410 + "additionalProperties": { "$ref": "#/definitions/topology_presentation_actor_type" }
411 + },
412 + "link_types": {
413 + "type": "object",
414 + "additionalProperties": { "$ref": "#/definitions/topology_presentation_link_type" }
415 + },
416 + "port_types": {
417 + "type": "object",
418 + "additionalProperties": { "$ref": "#/definitions/topology_presentation_port_type" }
419 + },
420 + "legend": { "$ref": "#/definitions/topology_presentation_legend" },
421 + "actor_click_behavior": {
422 + "type": "string",
423 + "enum": ["highlight_connections", "highlight_path"]
424 + }
425 + },
426 + "additionalProperties": true
427 + },
428 + "topology_match": {
429 + "type": "object",
430 + "properties": {
431 + "chassis_ids": { "type": "array", "items": { "type": "string" } },
432 + "mac_addresses": { "type": "array", "items": { "type": "string" } },
433 + "ip_addresses": { "type": "array", "items": { "type": "string" } },
434 + "hostnames": { "type": "array", "items": { "type": "string" } },
435 + "dns_names": { "type": "array", "items": { "type": "string" } },
436 + "sys_object_id": { "type": "string" },
437 + "sys_name": { "type": "string" },
438 + "netdata_node_id": { "type": "string" },
439 + "netdata_machine_guid": { "type": "string" },
440 + "cloud_instance_id": { "type": "string" },
441 + "cloud_account_id": { "type": "string" },
442 + "container_ids": { "type": "array", "items": { "type": "string" } },
443 + "pod_names": { "type": "array", "items": { "type": "string" } },
444 + "namespace_ids": { "type": "array", "items": { "type": "string" } }
445 + },
446 + "additionalProperties": true
447 + },
448 + "topology_actor": {
449 + "type": "object",
450 + "required": ["actor_type", "layer", "source", "match"],
451 + "properties": {
452 + "actor_id": { "type": "string" },
453 + "actor_type": { "type": "string" },
454 + "layer": { "type": "string" },
455 + "source": { "type": "string" },
456 + "match": { "$ref": "#/definitions/topology_match" },
457 + "parent_match": { "$ref": "#/definitions/topology_match" },
458 + "attributes": { "type": "object" },
459 + "derived": { "type": "object" },
460 + "labels": { "type": "object" },
461 + "tables": {
462 + "type": "object",
463 + "additionalProperties": {
464 + "type": "array",
465 + "items": { "type": "object" }
466 + }
467 + }
468 + },
469 + "additionalProperties": true
470 + },
471 + "topology_link_endpoint": {
472 + "type": "object",
473 + "properties": {
474 + "match": { "$ref": "#/definitions/topology_match" },
475 + "attributes": { "type": "object" }
476 + },
477 + "additionalProperties": true
478 + },
479 + "topology_link": {
480 + "type": "object",
481 + "required": ["layer", "protocol", "src", "dst"],
482 + "properties": {
483 + "layer": { "type": "string" },
484 + "protocol": { "type": "string" },
485 + "link_type": { "type": "string" },
486 + "direction": { "type": "string" },
487 + "state": { "type": "string" },
488 + "src_actor_id": { "type": "string" },
489 + "dst_actor_id": { "type": "string" },
490 + "src": { "$ref": "#/definitions/topology_link_endpoint" },
491 + "dst": { "$ref": "#/definitions/topology_link_endpoint" },
492 + "discovered_at": { "type": "string", "format": "date-time" },
493 + "last_seen": { "type": "string", "format": "date-time" },
494 + "metrics": { "type": "object" }
495 + },
496 + "additionalProperties": true
497 + },
498 + "topology_flow_exporter": {
499 + "type": "object",
500 + "properties": {
501 + "ip": { "type": "string" },
502 + "name": { "type": "string" },
503 + "sampling_rate": { "type": "integer" },
504 + "flow_version": { "type": "string" }
505 + },
506 + "additionalProperties": true
507 + },
508 + "topology_flow": {
509 + "type": "object",
510 + "properties": {
511 + "timestamp": { "type": "string", "format": "date-time" },
512 + "duration_sec": { "type": "integer" },
513 + "exporter": { "$ref": "#/definitions/topology_flow_exporter" },
514 + "src": { "$ref": "#/definitions/topology_link_endpoint" },
515 + "dst": { "$ref": "#/definitions/topology_link_endpoint" },
516 + "key": { "type": "object" },
517 + "metrics": { "type": "object" }
518 + },
519 + "additionalProperties": true
520 + },
521 + "topology_ip_policy": {
522 + "type": "object",
523 + "properties": {
524 + "public_allowlist": { "type": "array", "items": { "type": "string" } },
525 + "live_top_n": {
526 + "type": "object",
527 + "properties": {
528 + "enabled": { "type": "boolean" },
529 + "limit": { "type": "integer" },
530 + "sort_by": { "type": "string" }
531 + },
532 + "additionalProperties": true
533 + }
534 + },
535 + "additionalProperties": true
536 + },
537 + "topology_response": {
538 + "type": "object",
539 + "required": ["status", "type", "data"],
540 + "properties": {
541 + "status": { "type": "integer" },
542 + "type": { "const": "topology" },
543 + "data": {
544 + "type": "object",
545 + "required": ["schema_version", "agent_id", "collected_at", "actors", "links"],
546 + "properties": {
547 + "schema_version": { "type": "string" },
548 + "source": { "type": "string" },
549 + "layer": { "type": "string" },
550 + "agent_id": { "type": "string" },
551 + "collected_at": { "type": "string", "format": "date-time" },
552 + "view": { "type": "string" },
553 + "group_by": {
554 + "type": "array",
555 + "items": { "type": "string" }
556 + },
557 + "columns": { "$ref": "#/definitions/columns" },
558 + "metric": { "type": "string" },
559 + "chart": { "type": "object" },
560 + "ip_policy": { "$ref": "#/definitions/topology_ip_policy" },
561 + "actors": {
562 + "type": "array",
563 + "items": { "$ref": "#/definitions/topology_actor" }
564 + },
565 + "links": {
566 + "type": "array",
567 + "items": { "$ref": "#/definitions/topology_link" }
568 + },
569 + "flows": {
570 + "type": "array",
571 + "items": { "$ref": "#/definitions/topology_flow" }
572 + },
573 + "stats": { "type": "object" },
574 + "metrics": { "type": "object" }
575 + },
576 + "additionalProperties": true
577 + },
578 + "has_history": { "type": "boolean" },
579 + "accepted_params": { "$ref": "#/definitions/accepted_params" },
580 + "required_params": { "$ref": "#/definitions/required_params" },
581 + "help": { "type": "string" },
582 + "update_every": { "type": "integer" },
583 + "expires": { "type": "integer" }
584 + },
585 + "additionalProperties": true
586 + },
587 + "flows_response": {
588 + "type": "object",
589 + "required": ["status", "type", "data"],
590 + "properties": {
591 + "status": { "type": "integer" },
592 + "type": { "const": "flows" },
593 + "data": {
594 + "type": "object",
595 + "required": ["schema_version", "agent_id", "collected_at"],
596 + "properties": {
597 + "schema_version": { "type": "string" },
598 + "source": { "type": "string" },
599 + "layer": { "type": "string" },
600 + "agent_id": { "type": "string" },
601 + "collected_at": { "type": "string", "format": "date-time" },
602 + "view": { "type": "string" },
603 + "ip_policy": { "$ref": "#/definitions/topology_ip_policy" },
604 + "actors": {
605 + "type": "array",
606 + "items": { "$ref": "#/definitions/topology_actor" }
607 + },
608 + "links": {
609 + "type": "array",
610 + "items": { "$ref": "#/definitions/topology_link" }
611 + },
612 + "flows": {
613 + "type": "array",
614 + "items": { "$ref": "#/definitions/topology_flow" }
615 + },
616 + "stats": { "type": "object" },
617 + "metrics": { "type": "object" },
618 + "warnings": { "type": ["array", "object"] },
619 + "facets": { "$ref": "#/definitions/facets" },
620 + "histogram": { "type": "object" },
621 + "visualizations": { "type": "object" },
622 + "pagination": { "type": "object" }
623 + },
624 + "additionalProperties": true
625 + },
626 + "has_history": { "type": "boolean" },
627 + "accepted_params": { "$ref": "#/definitions/accepted_params" },
628 + "required_params": { "$ref": "#/definitions/required_params" },
629 + "help": { "type": "string" },
630 + "update_every": { "type": "integer" },
631 + "expires": { "type": "integer" }
632 + },
633 + "additionalProperties": true
634 + },
635 "error_response": {
636 "type": "object",
637 "required": ["status", "errorMessage"],