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"],