@cryptotaxi247 / netdata-1 / commits / 850853cfe

refactor(snmp): clean up profile projection and topology tests (#22433)

Ilya Mashchenko committed May 7, 2026 at 00:17 UTC 850853cfec5eac455c081bc4e553a8be1a6dc3e2
69 files changed +3661 -1478
.agents/skills/project-snmp-profiles-authoring/SKILL.md
+22
@@ -22,6 +22,14 @@ Use this skill before editing files under:
22 - use `index` for one index component;
23 - use `index_transform` for multiple components;
24 - use `symbol.format` only for final formatting such as `ip_address`, `mac_address`, or `hex`.
25 +6. Put SNMP topology rows under top-level `topology:` with a required closed
26 + `kind`. Do not mark topology rows by naming metrics `_topology_*`.
27 +7. Do not use chart/export-only value fields on topology row anchor symbols:
28 + `chart_meta`, `metric_type`, `mapping`, `transform`, `scale_factor`,
29 + `format`, or `constant_value_one`.
30 +8. Keep regular `systemUptime` rows under `metrics:`. Do not model uptime as a
31 + topology row kind; topology-specific uptime acquisition belongs in collector
32 + code, not profile topology schema.
33
34 ## Index Rules
35
@@ -86,6 +94,20 @@ rg -n 'name:[[:space:]]*(dot1qTpFdbAddress|ipNetToPhysicalIfIndex|ipNetToPhysica
94
95 Any hit must be reviewed. It is valid only when the tag is index-derived and does not declare `symbol.OID` for a `not-accessible` object.
96
97 +When adding a new topology kind, update all three parts together:
98 +
99 +- profile YAML using `topology: - kind: <kind>`;
100 +- the Go `TopologyKind` enum and validation;
101 +- the topology cache handler registry and tests.
102 +
103 +Verify that topology rows are delivered through `ProfileMetrics.TopologyMetrics`,
104 +not through underscore-prefixed `HiddenMetrics`.
105 +
106 +When adding or refactoring SNMP profile, parser, or topology tests, prefer
107 +table-driven cases using `map[string]struct{}` keyed by test-case name when
108 +the cases share setup and assertion shape. Use separate test functions only for
109 +materially different setup or assertions.
110 +
111 ## Validation
112
113 Run the narrow suites for the changed area:
.agents/skills/project-writing-collectors/SKILL.md
+6
@@ -101,6 +101,12 @@ Source test data based on what you're collecting:
101
102 Don't fabricate test data the parser passes by accident. Don't skip tests "because this protocol can't be tested locally" — that's exactly when fixtures matter most. Standard go.d test-function names: `Test_testDataIsValid`, `TestCollector_ConfigurationSerialize`, `TestCollector_Init`, `TestCollector_Check`, `TestCollector_Collect` — match the convention in adjacent collectors. Functions get a dedicated validator at `src/go/tools/functions-validation/` (E2E plus schema checks).
103
104 +For Go tests, prefer table-driven tests using `map[string]struct{}` keyed by
105 +test-case name when cases share setup and assertion shape. Use separate test
106 +functions only when setup or assertions are materially different. Prefer map
107 +keys over a `name` field in `[]struct{}` so case names stay prominent and
108 +order-independent.
109 +
110 ### 2.2 Hot-path discipline
111
112 `Collect()` runs every `update_every` seconds. It must:
.agents/sow/done/SOW-0012-20260506-snmp-profile-projection.md new
+649
@@ -0,0 +1,649 @@
1 +# SOW-0012 - SNMP profile projection and topology row schema
2 +
3 +## Status
4 +
5 +Status: completed
6 +
7 +Sub-state: Implementation, focused validation, final review, and commit prep completed.
8 +
9 +## Requirements
10 +
11 +### Purpose
12 +
13 +Replace the hacky SNMP metrics-vs-topology profile split with a clean, schema-driven profile catalog, resolver, and projection model. Regular SNMP compatibility matters. SNMP topology is WIP/nightly-only, so topology behavior can change to reach a clean end state.
14 +
15 +### User Request
16 +
17 +User request summary:
18 +
19 +- Recently added SNMP topology works but was added in a hacky way.
20 +- Focus on how profiles are loaded for regular metrics versus topology.
21 +- Review `src/go/plugin/go.d/collector/snmp/ddsnmp` and `src/go/plugin/go.d/collector/snmp_topology`.
22 +- Prefer clean end state over low churn.
23 +- Topology backward compatibility is not a constraint because it is WIP/nightly-only.
24 +- Use independent AI reviews to avoid missing issues.
25 +
26 +Detailed design source:
27 +
28 +- `src/go/plugin/go.d/TODO-snmp-profile-loading-topology.md`
29 +
30 +### Assistant Understanding
31 +
32 +Facts:
33 +
34 +- Regular SNMP and SNMP topology currently use the same physical profile loader in `collector/snmp/ddsnmp/load.go`.
35 +- `collector/snmp/ddsnmp/profile.go:25-71` selects profiles by `sysObjectID`/`sysDescr`, or by `manual_profiles` only when `sysObjectID` is empty.
36 +- Regular SNMP calls `ddsnmp.FindProfiles()` and then strips topology data via `collector/snmp/topology_profile_filter.go`.
37 +- SNMP topology calls `ddsnmp.FindProfiles()` and then keeps topology data via `collector/snmp_topology/profile_filter.go`.
38 +- Topology classification is hardcoded in `collector/snmp/ddsnmp/topology_classify.go`.
39 +- Topology ingestion dispatch is hardcoded by metric name in `collector/snmp_topology/topology_cache_metric_dispatch.go`.
40 +- VLAN-context topology bypasses the resolver and hardcodes topology profile filenames in `collector/snmp_topology/topology_vlan_context_collect.go`.
41 +- Current `_topology_*` rows are hidden through the generic underscore-prefix `HiddenMetrics` path in `collector/snmp/ddsnmp/ddsnmpcollector/collector.go:122-123`.
42 +- `HiddenMetrics` is not topology-owned. It is a general delivery container and must not be deleted or redefined without auditing non-topology users.
43 +
44 +Inferences:
45 +
46 +- The root issue is not physical profile loading. The root issue is the lack of an explicit consumer/topology contract in the profile schema and resolver output.
47 +- A single catalog plus explicit projection preserves the strong shared-profile model while removing name/prefix heuristics.
48 +- A top-level `topology:` list is cleaner than embedding topology rows in `metrics[]` because topology rows are not regular chart metrics and should not share metric merge/dedup ambiguity.
49 +
50 +Unknowns:
51 +
52 +- No product/design unknowns remain for this SOW. User decisions 1.D, 2.B, 3.C, 4.A, 5.B, 6.A, 7.A, and 8.A are recorded in the TODO and summarized below.
53 +- Some implementation details will be discovered while refactoring tests and moving per-profile mutations to load-time plus matched-set deduplication to resolve-time, but they are bounded by the gate and validation plan.
54 +
55 +### Acceptance Criteria
56 +
57 +- Regular SNMP metrics collection uses the new catalog/resolver/projection path and remains behaviorally equivalent to the current `selectCollectionProfiles(FindProfiles(...))` path.
58 +- SNMP topology uses top-level `topology:` rows, `TopologyKind`, and `ProfileMetrics.TopologyMetrics`; it no longer depends on `_topology_*` metric names or underscore-prefix `HiddenMetrics`.
59 +- VLAN-context topology uses `Project(ConsumerTopology).FilterByKind(vlanScopableKinds)` instead of hardcoded `LoadProfileByName()` calls.
60 +- `HiddenMetrics` remains available as a generic non-topology underscore-prefixed metric delivery container, and the existing preservation test continues to pass.
61 +- Old topology classifier/filter code and dead hardcoded profile-name constants are removed.
62 +- Profile-format documentation, project SNMP profile authoring skill, and a new SOW spec describe the shipped contract.
63 +
64 +## Analysis
65 +
66 +Sources checked:
67 +
68 +- `src/go/plugin/go.d/TODO-snmp-profile-loading-topology.md`
69 +- `collector/snmp/ddsnmp/load.go`
70 +- `collector/snmp/ddsnmp/profile.go`
71 +- `collector/snmp/ddsnmp/topology_classify.go`
72 +- `collector/snmp/ddsnmp/ddprofiledefinition/profile_definition.go`
73 +- `collector/snmp/ddsnmp/ddprofiledefinition/metrics.go`
74 +- `collector/snmp/ddsnmp/ddprofiledefinition/validation.go`
75 +- `collector/snmp/ddsnmp/ddsnmpcollector/collector.go`
76 +- `collector/snmp/ddsnmp/ddsnmpcollector/collector_scalar.go`
77 +- `collector/snmp/ddsnmp/ddsnmpcollector/collector_table.go`
78 +- `collector/snmp/ddsnmp/ddsnmpcollector/metric_builder.go`
79 +- `collector/snmp/profile_sets.go`
80 +- `collector/snmp/topology_profile_filter.go`
81 +- `collector/snmp_topology/collector.go`
82 +- `collector/snmp_topology/profile_filter.go`
83 +- `collector/snmp_topology/topology_cache_metric_dispatch.go`
84 +- `collector/snmp_topology/topology_vlan_context_collect.go`
85 +- `collector/snmp_topology/topology_cache_ingest.go`
86 +- `config/go.d/snmp.profiles/default/_system-base.yaml`
87 +- `config/go.d/snmp.profiles/default/_std-topology-lldp-mib.yaml`
88 +- `config/go.d/snmp.profiles/default/_std-cdp-mib.yaml`
89 +- `collector/snmp/profile-format.md`
90 +- `.agents/skills/project-snmp-profiles-authoring/SKILL.md`
91 +- `.agents/sow/pending/SOW-0002-20260501-unified-multi-layered-topology-schema.md`
92 +- `.agents/sow/specs/go-v2-host-scope.md`
93 +- `.agents/sow/specs/sensitive-data-discipline.md`
94 +
95 +Current state:
96 +
97 +- Profile loading is shared, but consumer projection is implemented by mirrored package-private filters.
98 +- Topology row identity is encoded in metric names instead of profile schema.
99 +- Topology dispatch is tied to metric-name constants.
100 +- VLAN-context topology has a separate hardcoded profile load path.
101 +- Post-load profile mutations exist in resolver/collector paths and must move before shared immutable projections are safe.
102 +
103 +Risks:
104 +
105 +- Regular SNMP regression if metrics projection changes selected metrics, metadata, global tags, virtual metrics, or manual profile behavior.
106 +- Topology row loss if `TopologyKind` is not threaded from profile rows through scalar/table collectors into topology ingest.
107 +- `HiddenMetrics` regression if implementation treats it as topology-only and breaks other underscore-prefixed delivery consumers.
108 +- Profile merge regressions if inherited topology rows do not use a precise identity key.
109 +- Validation churn from adding `topology:` and consumer fields to the profile schema.
110 +- Documentation drift if profile-format docs and project SNMP authoring skill are not updated with the new contract.
111 +
112 +## Pre-Implementation Gate
113 +
114 +Status: ready
115 +
116 +Problem / root-cause model:
117 +
118 +- The profile loader is not the primary problem. The loader already centralizes profile directories, YAML parsing, embedded defaults, `extends`, and caching.
119 +- The root cause is that resolved profiles contain a mixed bag of regular metric rows, topology rows, metadata, and tags without an explicit schema-level consumer contract.
120 +- Regular SNMP and SNMP topology compensate with mirrored filters and a hardcoded classifier based on `_topology_*` names and tag/metadata prefixes.
121 +- VLAN-context topology worsens the split by directly loading hardcoded mixins and bypassing resolver semantics.
122 +
123 +Evidence reviewed:
124 +
125 +- `collector/snmp/ddsnmp/topology_classify.go:16-30` exact topology metric name allowlist.
126 +- `collector/snmp/ddsnmp/topology_classify.go:43-65` prefix-based topology identifier heuristic.
127 +- `collector/snmp/topology_profile_filter.go:10-50` regular SNMP strips topology data.
128 +- `collector/snmp_topology/profile_filter.go:10-47` topology keeps topology data.
129 +- `collector/snmp_topology/topology_vlan_context_collect.go:16-28` hardcoded VLAN-context profile loads.
130 +- `collector/snmp/ddsnmp/ddsnmpcollector/collector.go:122-123` generic underscore-prefixed hidden metrics bucketing.
131 +- `collector/snmp_topology/collector.go:220-224` topology currently ingests hidden and regular metric slices.
132 +- `collector/snmp_topology/topology_cache_ingest.go:11-30` existing profile tag hook that reads `pm.DeviceMetadata`.
133 +- `collector/snmp/ddsnmp/profile.go:194-252` current regular metric merge identity.
134 +- `collector/snmp/ddsnmp/profile.go:375-440` enrichment/dedup currently only covers `Definition.Metrics` and `Definition.VirtualMetrics`.
135 +
136 +Affected contracts and surfaces:
137 +
138 +- Profile schema: `ProfileDefinition`, `MetricsConfig`, metadata fields, top-level/global metric tags, virtual metrics validation.
139 +- ddsnmp public API: catalog, resolve request, manual profile policy, resolved profile set, projected views.
140 +- ddsnmpcollector output: `Metric.TopologyKind`, `ProfileMetrics.TopologyMetrics`, preservation of `HiddenMetrics`.
141 +- SNMP collector metrics path: profile selection/projection and chart labels.
142 +- SNMP topology path: profile selection, VLAN-context, topology ingest, handler registry.
143 +- Default SNMP profile YAMLs under `config/go.d/snmp.profiles/default/`.
144 +- Tests under `collector/snmp`, `collector/snmp/ddsnmp`, `collector/snmp/ddsnmp/ddprofiledefinition`, `collector/snmp/ddsnmp/ddsnmpcollector`, and `collector/snmp_topology`.
145 +- Documentation and durable artifacts: `collector/snmp/profile-format.md`, `.agents/skills/project-snmp-profiles-authoring/SKILL.md`, `.agents/sow/specs/snmp-profile-projection.md`.
146 +
147 +Existing patterns to reuse:
148 +
149 +- Keep the shared loader/resolver model from `collector/snmp/ddsnmp/load.go` and `profile.go`.
150 +- Reuse SNMP row shape from `MetricsConfig` for topology rows via `TopologyConfig`.
151 +- Reuse existing table/scalar collection logic but thread parent topology row metadata into emitted metrics.
152 +- Reuse `updateTopologyProfileTags` as the hook for Decision 8.A.
153 +- Reuse existing profile merge identity concepts: scalar name/OID and table identity plus symbol name.
154 +- Reuse existing validation style in `ddprofiledefinition/validation.go`.
155 +- Reuse focused Go package tests and profile fixture tests instead of broad full-repo validation.
156 +
157 +Risk and blast radius:
158 +
159 +- Regular SNMP blast radius is broad because default profiles affect production metric collection. Metrics projection must be parity-protected.
160 +- Topology blast radius is acceptable for topology behavior because the feature is WIP/nightly-only, but topology must still produce coherent data.
161 +- `HiddenMetrics` has cross-PR/non-topology risk. It must remain a general-purpose delivery path until separately audited and refactored.
162 +- Moving per-profile mutations to load-time and matched-set deduplication to resolve-time changes pointer and clone assumptions. Tests must prove projections cannot mutate shared catalog state.
163 +- YAML migration touches topology profile mixins and `_std-cdp-mib.yaml`; profile load validation must catch mistakes early.
164 +- Validation rejecting topology-row chart/export fields may expose existing accidental fields during migration.
165 +
166 +Sensitive data handling plan:
167 +
168 +- This SOW and implementation should reference profile filenames, metric names, OIDs, struct fields, and tests only.
169 +- Do not write raw SNMP communities, SNMPv3 credentials, bearer tokens, passwords, customer hostnames, customer sysName/sysDescr values, private endpoints, non-private customer-identifying IPs, customer names, or personal data into SOWs, specs, docs, skills, tests, fixtures, or code comments.
170 +- Any real SNMP fixture used later must be sanitized before committing. Use neutral device names and placeholder values.
171 +- Existing profile YAMLs contain public OIDs and generic vendor/device metadata, not credentials.
172 +
173 +Implementation plan:
174 +
175 +1. Schema and API surface, no behavior change.
176 + - Add `Topology []TopologyConfig` to `ProfileDefinition`.
177 + - Add a closed 18-value `TopologyKind` enum for current topology row shapes. `systemUptime` is not a topology kind.
178 + - Add `MetadataField.Consumers`.
179 + - Add validation for unknown topology kinds and metrics-only fields under topology row anchor symbols.
180 + - Add `Catalog`, `Resolve`, `Project`, and `FilterByKind` API surface without cutting over call sites.
181 +
182 +2. Split mutation handling between load-time/catalog compilation and resolve-time matched-set processing.
183 + - Move per-profile/idempotent mutation to load-time: `enrichProfiles` and `handleCrossTableTagsWithoutMetrics`.
184 + - Extend cross-table-tag synthesis to scan both `Definition.Metrics` and `Definition.Topology`, placing synthesized entries on the owning slice.
185 + - Keep `deduplicateMetricsAcrossProfiles` at resolve-time inside `Catalog.Resolve()` because it needs the matched, sorted profile set.
186 + - Extend resolve-time deduplication to `Definition.Topology`.
187 + - Preserve metrics-path parity with current `FindProfiles`.
188 +
189 +3. Profile YAML migration.
190 + - Move `_topology_*` rows from `metrics:` to top-level `topology:` in topology mixins.
191 + - Split `_std-cdp-mib.yaml` into real metrics and `_std-topology-cdp-mib.yaml`.
192 + - Add `kind:` to every topology row.
193 + - Keep metadata/global tag annotations minimal per Decision 4.A.
194 + - Do not modify `_system-base.yaml`; `systemUptime` remains a regular metric.
195 + - Phases 3-6 are one logical topology cutover and must not ship as a broken intermediate topology state.
196 +
197 +4. Catalog/Resolve/Project introduction with parity tests.
198 + - Keep old `FindProfiles` temporarily.
199 + - Prove `Catalog.Resolve(...).Project(ConsumerMetrics)` matches current `selectCollectionProfiles(FindProfiles(...))`.
200 + - Prove `Project(ConsumerTopology)` matches current topology selection after YAML migration.
201 +
202 +5. Plumb topology collection through `ddsnmpcollector`.
203 + - Add `Metric.TopologyKind`.
204 + - Add `ProfileMetrics.TopologyMetrics`.
205 + - Prefer a topology collection wrapper that stamps `TopologyKind` after existing scalar/table emission; do not widen regular builders unless the SOW records why.
206 + - Add explicit topology collection from `Definition.Topology`.
207 + - Do not map regular `systemUptime` metrics into topology projection. Topology queries uptime through `pkg/snmputils.GetSysUptime`.
208 + - Remove topology's dependency on underscore-prefix hidden metrics.
209 + - Preserve generic `HiddenMetrics` behavior for non-topology underscore-prefixed metrics.
210 + - Enforce that topology rows cannot be delivered through both `pm.HiddenMetrics` and `pm.TopologyMetrics` in one poll.
211 +
212 +6. Cut over call sites and dispatch.
213 + - Switch `collector/snmp/profile_sets.go`, `collector/snmp_topology/collector.go`, and `collector/snmp_topology/topology_vlan_context_collect.go`.
214 + - Replace metric-name dispatch switch with handler registry keyed by `TopologyKind`.
215 + - Extend `updateTopologyProfileTags` to apply `pm.Tags` as local device/profile labels.
216 + - Add side-by-side fixture-level runtime parity before deleting the old path.
217 +
218 +7. Delete dead code and update artifacts.
219 + - Delete topology classifier/filter code, dead topology profile constants, and obsolete metric-name dispatch constants; remove topology runtime reliance on `FinalizeProfiles`.
220 + - Update `collector/snmp/profile-format.md`.
221 + - Update `.agents/skills/project-snmp-profiles-authoring/SKILL.md`.
222 + - Create `.agents/sow/specs/snmp-profile-projection.md`.
223 +
224 +Validation plan:
225 +
226 +- Resolver parity: for every default profile, `Catalog.Resolve(...).Project(ConsumerMetrics)` matches current `selectCollectionProfiles(FindProfiles(...))`.
227 +- Topology parity: `Project(ConsumerTopology)` matches current `selectTopologyRefreshProfiles(FindProfiles(...))` after YAML migration.
228 +- VLAN-context equivalence: `Project(ConsumerTopology).FilterByKind(vlanScopableKinds)` matches today's hardcoded VLAN-context loader.
229 +- Manual policy: regular metrics with `manual_profiles` plus matching `sysObjectID` do not augment; topology does augment.
230 +- Topology extends merge: a vendor/root profile extending a topology mixin inherits `Definition.Topology` rows through `Profile.merge().mergeTopology(base)`.
231 +- Topology-to-topology merge: topology rows require explicit `kind`; matching rows with conflicting explicit kinds are rejected.
232 +- Clone coverage: `ProfileDefinition.Clone()`, `TopologyConfig.Clone()`, `MetadataField.Clone()`, and consumer-set clone paths do not share mutable state.
233 +- Validation rejects unknown `TopologyKind`, metrics-only fields on `TopologyConfig`, underscore-prefixed topology row anchor names, and mixed-consumer virtual metrics.
234 +- Top-level/global `metric_tags`: values in `pm.Tags` reach topology local device/profile labels through `updateTopologyProfileTags`, not per-row dispatch tags.
235 +- HiddenMetrics non-topology preservation: `TestCollector_Collect_PreservesHiddenMetrics` in `collector/snmp/ddsnmp/ddsnmpcollector/collector_test.go` continues to pass.
236 +- Topology double-bucketing guard: a topology row must not appear in both `pm.HiddenMetrics` and `pm.TopologyMetrics` in the same poll.
237 +- `systemUptime`: `_system-base.yaml` remains metrics-only; topology receives uptime through `pkg/snmputils.GetSysUptime` without a topology kind or YAML topology row.
238 +- VLAN-context kind flow: `vlanScopableKinds` contains exactly `KindIfName`, `KindBridgePortIfIndex`, `KindFdbEntry`, and `KindStpPort`, and VLAN-context synthetic metrics carry `TopologyKind`.
239 +- Side-by-side runtime parity: representative default-profile fixture emits equivalent regular SNMP `ProfileMetrics` before old path deletion.
240 +- Mutation isolation: mutating one projection cannot affect another after resolve.
241 +- `Metric.TopologyKind`: every topology kind is emitted correctly from fixture collection.
242 +- Run narrow suites:
243 + - `go test ./plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition`
244 + - `go test ./plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector`
245 + - `go test ./plugin/go.d/collector/snmp/...`
246 + - `go test ./plugin/go.d/collector/snmp_topology/...`
247 +
248 +Artifact impact plan:
249 +
250 +- AGENTS.md: no expected change; existing SOW/process and SNMP skill triggers are sufficient.
251 +- Runtime project skills: update `.agents/skills/project-snmp-profiles-authoring/SKILL.md` with `topology:` row and `TopologyKind` guidance.
252 +- Specs: create `.agents/sow/specs/snmp-profile-projection.md`.
253 +- End-user/operator docs: update `collector/snmp/profile-format.md`; check whether SNMP topology docs/examples need updates.
254 +- End-user/operator skills: no expected public skill update unless docs/spec changes are mirrored into public skill artifacts.
255 +- SOW lifecycle: SOW remains `open` in `pending/` until implementation starts; move to `current/` and mark `in-progress` only when implementation begins.
256 +
257 +Open-source reference evidence:
258 +
259 +- No external mirrored open-source repositories were checked. The work concerns Netdata's internal SNMP profile schema and collector implementation; local repo code, profile YAMLs, tests, and project skills are the relevant ground truth for this SOW.
260 +
261 +Open decisions:
262 +
263 +- None. User decisions and implementation sub-decisions are recorded below.
264 +
265 +## Implications And Decisions
266 +
267 +User decisions:
268 +
269 +1. Manual profile policy: use internal policy. Regular metrics use fallback-only manual profiles; topology uses augment.
270 +2. Topology merge behavior: topology rows require explicit `kind`; explicit derived topology wins for matching row identities; conflicting explicit topology kinds are rejected. Earlier "derived omits kind" inheritance wording is unreachable because validation rejects persisted topology rows without `kind`.
271 +3. Virtual metric projection: reject mixed-consumer virtual metrics at validation.
272 +4. Metadata/global tag defaults: metadata fields and top-level/global `metric_tags` default to both `metrics` and `topology`, with explicit narrowing when needed.
273 +5. Topology row schema: use a separate top-level `topology:` list for topology rows instead of embedding topology rows in `metrics[]`.
274 +6. TopologyKind granularity: define one closed `TopologyKind` per current topology row shape, including LLDP management-address rows.
275 +7. `sysUptime` path: superseded during implementation. Remove `KindSysUptime`; topology uptime acquisition uses `pkg/snmputils.GetSysUptime`.
276 +8. Global metric tags in topology: apply top-level/global `metric_tags` as local device/profile labels, not as dispatch keys on every topology row.
277 +9. `HiddenMetrics`: treat as a general delivery container, not topology-owned. Do not delete or redefine it without auditing non-topology consumers.
278 +10. Before implementation starts, write a step-by-step implementation plan detailed enough for external readiness review. Ask Claude to judge the plan as `READY TO WRITE CODE` or `NEEDS ADJUSTMENTS`; do not start code until that review is accepted or required adjustments are recorded.
279 +11. Fifth readiness review adjustments are accepted: B1-B6 and S1-S6. Phases 3-6 are one logical topology cutover; the original `KindSysUptime` projection/collector mapping from existing regular `systemUptime` rows was later superseded by Decision 14.
280 +12. Sixth readiness review adjustments are accepted: N1 and N2-N5. `Profile.merge()` must merge `Definition.Topology` during `extends:` loading, clone targets are explicit, VLAN-context synthetic metrics carry `TopologyKind`, `vlanScopableKinds` is pinned to existing VLAN ingest kinds, and new fields follow existing YAML/JSON tag conventions.
281 +13. Seventh readiness review verdict is `READY TO WRITE CODE`. Non-blocking hygiene tightenings NB1-NB4 are accepted: expanded stale-helper scans, `Catalog`/`Resolve`/`Project` live in `collector/snmp/ddsnmp`, top-level/global metric tags use a wrapper for `Consumers`, and post-cutover topology ingest loops over `pm.TopologyMetrics`. The original regular `systemUptime` metrics mapping was later superseded by Decision 16.
282 +14. User correction during implementation: remove `KindSysUptime`. `systemUptime` is not a topology row kind. A direct `pkg/snmputils` uptime helper was proposed and later accepted in Decision 16.
283 +15. Regular SNMP metrics must keep uptime collection through existing metrics profile rows. Do not move or remove uptime from `_system-base.yaml`.
284 +16. Topology uptime acquisition after removing `KindSysUptime`: use option A. Add `pkg/snmputils.GetSysUptime(gosnmp.Handler)` with the same three OIDs and scale rules as `_system-base.yaml`, and call it from `snmp_topology` during refresh. Regular SNMP uptime metrics remain profile-driven.
285 +17. Public profile-format documentation should describe the current topology schema without mentioning the old underscore-prefixed topology metric-name implementation.
286 +
287 +Resolved implementation decision:
288 +
289 +1. Topology uptime acquisition after removing `KindSysUptime`. Resolved: A.
290 + - Evidence:
291 + - `config/go.d/snmp.profiles/default/_system-base.yaml:11-44` defines the three regular uptime fallback rows: `snmpEngineTime` in seconds, `hrSystemUptime` scaled by `0.01`, and `sysUpTime` scaled by `0.01`.
292 + - `collector/snmp_topology/topology_cache_ingest.go:52-67` stores uptime into the local topology device and the `sys_uptime` label.
293 + - `collector/snmp_topology/topology_local_actor_attrs.go:32-34` exports `sys_uptime` as local actor attributes when present.
294 + - `collector/snmp/ddsnmp/profile_catalog.go:271-320` currently keeps a special topology projection path for `systemUptime`/`sysUpTime`; this should disappear with `KindSysUptime`.
295 + - A. Add `pkg/snmputils.GetSysUptime(gosnmp.Handler)` with the same three OIDs and scale rules, and call it from `snmp_topology` during refresh.
296 + - Pros: removes `KindSysUptime`; keeps profile topology projection pure; keeps regular SNMP metrics unchanged; localizes the hardcoded fallback to one SNMP utility helper.
297 + - Cons: duplicates uptime OID/scale knowledge from `_system-base.yaml`; future uptime fallback changes need the helper updated too; adds one SNMP GET per topology refresh.
298 + - B. Keep using ddsnmp regular metric collection for uptime, but identify `systemUptime`/`sysUpTime` by name in `snmp_topology`.
299 + - Pros: no extra SNMP GET; reuses profile scaling and fallback behavior.
300 + - Cons: keeps metric-name special casing and forces topology projection to retain a regular metric row.
301 + - C. Stop collecting uptime for topology.
302 + - Pros: simplest implementation; no duplicate OIDs or extra SNMP request.
303 + - Cons: topology output loses `sys_uptime` on local actors; existing tests and output paths already treat it as useful local device enrichment.
304 + - Recommendation: A.
305 +
306 +Implementation sub-decisions:
307 +
308 +1. Add `ProfileMetrics.TopologyMetrics []Metric` and remove topology's dependency on underscore-prefix `HiddenMetrics`.
309 +2. Use `(kind, table_identity, symbol_name)` as topology row identity for both load-time `extends:` merge through `Profile.merge().mergeTopology(base)` and resolve-time matched-set deduplication.
310 +3. Extend `updateTopologyProfileTags` to read `pm.Tags` for Decision 8.A.
311 +4. Reject metrics-only fields on topology row anchor `symbol` / `symbols`, while keeping `metric_tags` extraction fields valid.
312 +5. Split mutation timing: move per-profile enrichment and cross-table-tag synthesis to load-time/catalog compilation; keep `deduplicateMetricsAcrossProfiles()` at resolve-time inside `Catalog.Resolve()` on catalog-cloned matched profiles and extend it to topology rows.
313 +
314 +## Plan
315 +
316 +1. Keep this SOW in `pending/` until user explicitly approves implementation.
317 +2. Before implementation starts, run a read-only Claude readiness review against the step-by-step implementation plan below.
318 +3. If Claude says `NEEDS ADJUSTMENTS`, record each accepted/rejected point in the SOW and TODO before code.
319 +4. On implementation start, move this SOW to `.agents/sow/current/`, set `Status: in-progress`, and execute the 7-phase implementation plan from the Pre-Implementation Gate.
320 +5. Maintain the SOW execution log after each implementation phase.
321 +6. Close only after implementation, docs/spec/skill updates, validation, artifact maintenance gate, and follow-up mapping are complete.
322 +
323 +### Step-by-Step Implementation Plan
324 +
325 +This is the pre-code checklist that must be reviewed before implementation. It is intentionally more detailed than the phase list so ordering bugs, hidden coupling, and missing validation can be found before edits begin.
326 +
327 +#### 0. Activate SOW And Capture Baseline
328 +
329 +1. Move this SOW from `.agents/sow/pending/` to `.agents/sow/current/`.
330 +2. Change status from `open` to `in-progress`.
331 +3. Record the branch name and current dirty files in the execution log.
332 +4. Run baseline focused tests before code changes where practical:
333 + - From `src/go`: `go test ./plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition`
334 + - From `src/go`: `go test ./plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector`
335 + - From `src/go`: `go test ./plugin/go.d/collector/snmp/...`
336 + - From `src/go`: `go test ./plugin/go.d/collector/snmp_topology/...`
337 +5. If any baseline test fails before edits, record the exact package/test and failure class. Do not count pre-existing failures as validation of the new implementation.
338 +
339 +#### 1. Add Schema And API Surface Without Behavior Change
340 +
341 +1. Add canonical `ProfileConsumer` values for `metrics` and `topology`.
342 +2. Add a single canonical closed `TopologyKind` definition with the 18 accepted topology row kinds. Do not include `sys_uptime`.
343 +3. Avoid duplicated string constants. If multiple packages need the type, use one definition plus aliases/imports rather than parallel enums.
344 +4. Add `Topology []TopologyConfig` to `ProfileDefinition`.
345 +5. Add `TopologyConfig` as a wrapper around the existing metric row shape plus required `kind`.
346 +6. Add `Consumers` to metadata fields.
347 +7. Add a top-level/global metric-tag wrapper that carries `Consumers`. Do not add consumer semantics to per-row `MetricTagConfig`; per-row metric tags inherit their row's consumer.
348 +8. Follow the existing schema tag convention for every new persisted field: matching `yaml` and `json` names, `omitempty` for optional fields, and `yaml:"-" json:"-"` for runtime-only fields.
349 +9. Update clone/deep-copy paths explicitly: `ProfileDefinition.Clone()` must clone `Topology`; `TopologyConfig.Clone()` must clone the embedded row config; `MetadataField.Clone()` must clone/copy `Consumers`; top-level/global metric-tag wrapper clone paths must clone/copy `Consumers`.
350 +10. Add validation for unknown topology kinds.
351 +11. Add validation for metrics-only fields inside topology row anchor `symbol`/`symbols`: `Options`, `ChartMeta`, `MetricType`, `Mapping`, `Transform`, `ScaleFactor`, `Format`, and `ConstantValueOne`.
352 +12. Extend the existing symbol traversal context, either with `TopologyScalarSymbol`/`TopologyColumnSymbol` or a topology-row flag, so value-symbol validation can reject topology-only-invalid fields without breaking `MetricTagSymbol`.
353 +13. Extend `validateEnrichVirtualMetrics` to accept topology rows and reject any virtual metric source that resolves to a `topology:` row.
354 +14. Reject underscore-prefixed `name:` values on topology row anchor symbols so topology rows cannot also flow into generic `HiddenMetrics`.
355 +15. Keep metric-tag extraction fields valid under topology rows where they extract tags from indexes/values.
356 +16. Do not reject existing `_topology_*` rows in `metrics:` until the YAML migration and cutover are complete; add final rejection only in cleanup.
357 +17. Add parse/clone/validation tests in `ddprofiledefinition`.
358 +
359 +#### 2. Split Profile Mutations By Required Context
360 +
361 +1. Inventory every mutation that currently happens after profile load: `handleCrossTableTagsWithoutMetrics`, `enrichProfiles`, `deduplicateMetricsAcrossProfiles`, and any resolver-time mutation found while editing.
362 +2. Move `handleCrossTableTagsWithoutMetrics` out of `ddsnmpcollector.New()` and into catalog/profile compilation before projections share profile pointers.
363 +3. Extend `Profile.merge()` with `mergeTopology(base)` and call it during `extends:` loading.
364 +4. `mergeTopology(base)` must use topology row identity `(kind, table_identity, symbol_name)` and Decision 2.B merge semantics.
365 +5. Add `profile_test.go` coverage proving a profile extending a topology mixin inherits `Definition.Topology` rows.
366 +6. Extend cross-table-tag synthesis to scan both `Definition.Metrics` and `Definition.Topology`; place synthesized entries on the slice that owns the consuming row so the correct collection path walks them.
367 +7. Move `enrichProfiles()` to load/catalog compilation and extend it to process `Definition.Topology`.
368 +8. Keep `deduplicateMetricsAcrossProfiles()` at resolve-time inside `Catalog.Resolve()` because it needs the already-matched, specificity-sorted profile set. It must operate on catalog-cloned profiles before projections are returned.
369 +9. Extend resolve-time deduplication to process `Definition.Topology` with topology row identity `(kind, table_identity, symbol_name)`.
370 +10. Preserve `Definition.Metrics` behavior exactly for regular SNMP.
371 +11. Treat the suspected `removeConstantMetrics()` value-copy issue as separate unless touched by this refactor. Do not extend `removeConstantMetrics()` to topology because topology row validation rejects `ConstantValueOne`; if this changes or the existing value-copy bug becomes relevant, either fix it with a narrow test or record a separate follow-up SOW.
372 +12. Add mutation-isolation tests showing one projected view cannot mutate another or the catalog. The minimum assertion is: resolve a device twice, mutate a nested map/slice in view 1, including nested state under `Definition.Topology`, then assert view 2 and a fresh resolve from the same catalog do not contain that mutation.
373 +
374 +#### 3. Migrate Profile YAML
375 +
376 +1. Move topology rows from `metrics:` to top-level `topology:` in the topology mixins.
377 +2. Split `_std-cdp-mib.yaml` into regular CDP metrics and `_std-topology-cdp-mib.yaml` topology rows.
378 +3. Add `kind:` to every topology row using the accepted `TopologyKind` enum.
379 +4. Rename topology row anchor symbol names away from `_topology_*`; dispatch must use `kind`, not the old hidden-metric name.
380 +5. Preserve OIDs, table identities, symbols, and metric_tags unless the move exposes a concrete bug.
381 +6. Do not modify `_system-base.yaml`; `systemUptime` remains a regular metrics row. Topology obtains uptime through `pkg/snmputils.GetSysUptime`.
382 +7. If any symbol/tag OID is added or changed, run the SNMP profile authoring MAX-ACCESS checks and record evidence. Pure row moves with unchanged OIDs should record that no readable-symbol semantics changed.
383 +8. Update profile extender references so vendors that previously extended mixed CDP/topology content still get the intended regular and topology rows.
384 +9. Inventory every profile extending `_std-cdp-mib.yaml` and update each profile that should retain CDP topology to also extend `_std-topology-cdp-mib.yaml`. Current default extenders are `_cisco-base.yaml` and `cisco-sb.yaml`.
385 +10. Add or update profile load tests for the migrated files.
386 +11. Treat phases 3-6 as one logical topology cutover. Do not ship a state where topology YAML has moved to `topology:` but topology runtime still reads only `_topology_*` metrics from `metrics:`.
387 +12. Record that topology mixins may become topology-only/abstract after migration; validation/tests must not assume every topology mixin produces regular `metrics:` rows when loaded as a root.
388 +
389 +#### 4. Introduce Catalog, Resolve, Project, And Filter
390 +
391 +1. Add `Catalog`, `ResolveRequest`, `ManualProfilePolicy`, `ResolvedProfileSet`, and projected view types.
392 +2. Implement the catalog/resolver/projection API in `collector/snmp/ddsnmp`, next to the existing profile loader, `FindProfiles()`, and profile model.
393 +3. Implement manual profile policies: metrics call sites use fallback-only; topology call sites use augment.
394 +4. Implement `Project(ConsumerMetrics)` and `Project(ConsumerTopology)`.
395 +5. Implement `FilterByKind(map[TopologyKind]bool)` for topology projections.
396 +6. Make projections non-mutating. Prefer immutable catalog-owned profiles plus read-only projected slices or precomputed buckets over per-call deep clone.
397 +7. Keep temporary wrappers such as `FindProfiles()` only as needed to prove parity during the same SOW; remove or simplify them during cleanup.
398 +8. Add resolver parity tests for regular SNMP.
399 +9. Add topology projection parity tests against the current topology filter behavior after YAML migration.
400 +10. Add manual-policy tests covering matching `sysObjectID` plus `manual_profiles` for both metrics and topology.
401 +
402 +#### 5. Plumb Topology Collection Through ddsnmpcollector
403 +
404 +1. Add `TopologyKind` to emitted `ddsnmp.Metric`.
405 +2. Add `TopologyMetrics []Metric` to `ProfileMetrics`.
406 +3. Preserve `HiddenMetrics []Metric` as the generic underscore-prefixed non-topology delivery container.
407 +4. Add explicit topology collection from `Definition.Topology`, parallel to the existing scalar/table collection path.
408 +5. Prefer a topology collection wrapper that calls the existing scalar/table collection helpers and stamps `TopologyKind` after emit. Do not widen regular scalar/table builder signatures unless the wrapper proves insufficient and the SOW records why.
409 +6. Add `pkg/snmputils.GetSysUptime(gosnmp.Handler)` with the `_system-base.yaml` uptime OIDs and scale rules, and call it from `snmp_topology` refresh. Do not add duplicate `systemUptime` YAML topology rows or topology kinds.
410 +7. Stop relying on underscore-prefix metric names for topology delivery.
411 +8. Keep `collectHiddenMetrics()` behavior for non-topology underscore-prefixed regular metrics.
412 +9. Enforce the invariant that a topology row lives in exactly one delivery slice for a single poll: not both `pm.HiddenMetrics` and `pm.TopologyMetrics`.
413 +10. Ensure `TestCollector_Collect_PreservesHiddenMetrics` continues to pass.
414 +11. Add tests proving topology rows populate `pm.TopologyMetrics` with the correct `Metric.TopologyKind`.
415 +12. Add a double-bucketing assertion test that fails if a `_topology_*` or topology-kind row appears in both `pm.HiddenMetrics` and `pm.TopologyMetrics`.
416 +
417 +#### 6. Cut Over SNMP And Topology Call Sites
418 +
419 +1. Switch regular SNMP profile selection to `DefaultCatalog().Resolve(...).Project(ConsumerMetrics)`.
420 +2. Switch SNMP topology profile selection to `DefaultCatalog().Resolve(...).Project(ConsumerTopology)`.
421 +3. Replace VLAN-context hardcoded `LoadProfileByName()` calls with `Project(ConsumerTopology).FilterByKind(vlanScopableKinds)`.
422 +4. Define `vlanScopableKinds` in topology Go code, not profile YAML. Pin it to the current VLAN-context ingest set: `KindIfName`, `KindBridgePortIfIndex`, `KindFdbEntry`, and `KindStpPort`.
423 +5. Replace metric-name topology dispatch with a handler registry keyed by `TopologyKind`.
424 +6. Make topology row handlers receive the full `ddsnmp.Metric`; uptime is handled by the explicit `snmputils` helper path, not by the topology handler registry.
425 +7. Register each topology cache handler from its domain file.
426 +8. Extend `updateTopologyProfileTags` to read `pm.Tags` and apply them as local device/profile labels, not per-row dispatch keys.
427 +9. Update `ingestTopologyVLANContextMetrics()` so any synthetic metric passed to kind-keyed dispatch carries/preserves `TopologyKind`.
428 +10. Update `ingestTopologyProfileMetrics` so the post-cutover loop dispatches only `pm.TopologyMetrics` by `TopologyKind`; regular `pm.Metrics` are not part of topology projection.
429 +11. Add dispatch parity tests and VLAN-context equivalence tests, including `TopologyKind` population for VLAN-context synthetic metrics.
430 +12. Add a side-by-side fixture-level runtime parity test before deleting the old path. Use a representative default-profile fixture such as the existing Cisco Nexus profile path in `collector/snmp/ddsnmp/profile_test.go:178`, and compare emitted `ProfileMetrics` fields that must remain stable for regular SNMP: `Tags`, `DeviceMetadata`, `Metrics`, ordering, and metric tags.
431 +
432 +#### 7. Delete Dead Code And Update Artifacts
433 +
434 +1. Search for stale references before deleting: `_topology_`, `IsTopologyMetric`, `LooksLikeTopologyIdentifier`, `MetricConfigContainsTopologyData`, `MetricTagConfigContainsTopologyData`, `MetadataFieldContainsTopologyData`, `MetadataContainsTopologyData`, `SysobjectIDMetadataContainsTopologyData`, `ProfileContainsTopologyData`, `ProfileHasCollectionData`, `TopologySysUptime`, `LoadProfileByName`, and `HiddenMetrics`.
435 +2. Delete dead topology classifier/filter code only after call sites and tests no longer depend on it.
436 +3. Delete `FinalizeProfiles()` only after VLAN-context and other callers no longer require it.
437 +4. Delete only topology-specific use of hidden metrics; do not delete generic `HiddenMetrics`.
438 +5. Update `collector/snmp/profile-format.md`.
439 +6. Update `.agents/skills/project-snmp-profiles-authoring/SKILL.md`.
440 +7. Create `.agents/sow/specs/snmp-profile-projection.md`.
441 +8. Run the focused validation suites.
442 +9. Run same-failure/stale-reference scans and record results in this SOW.
443 +10. Complete the artifact maintenance gate and follow-up mapping.
444 +11. Explicit test impact:
445 + - Delete/rewrite `collector/snmp/ddsnmp/topology_classify_test.go`.
446 + - Rewrite `collector/snmp/profile_sets_test.go` around projection.
447 + - Rewrite `collector/snmp_topology/profile_filter_test.go` around projection.
448 + - Rewrite `collector/snmp_topology/topology_profiles_test.go` if hardcoded topology profile constants disappear.
449 + - Rewrite `collector/snmp/ddsnmp/ddsnmpcollector/topology_profile_index_test.go` if it no longer needs `LoadProfileByName()`.
450 + - Rewrite topology fixtures in `collector/snmp_topology/topology_cache_test.go:1327-1371` and `collector/snmp_topology/collector_refresh_test.go:123-152` to use `TopologyMetrics`, not topology `HiddenMetrics`.
451 + - Preserve `collector/snmp/ddsnmp/ddsnmpcollector/collector_test.go:153` `TestCollector_Collect_PreservesHiddenMetrics`.
452 +
453 +## Execution Log
454 +
455 +### 2026-05-06
456 +
457 +- Created pending SOW from the reviewed design in `src/go/plugin/go.d/TODO-snmp-profile-loading-topology.md`.
458 +- Filled Pre-Implementation Gate with accepted decisions, implementation sub-decisions, risks, validation, and artifact impact plan.
459 +- Added step-by-step implementation plan for external readiness review before code.
460 +- Reconciled fifth readiness review: accepted B1-B6 and S1-S6, split load-time vs resolve-time mutation handling, made phases 3-6 one logical topology cutover, kept `_system-base.yaml` unchanged, and added validation/test gates.
461 +- Reconciled sixth readiness review: accepted N1 and N2-N5 plus actionable minor notes, added `Profile.merge().mergeTopology(base)` to the plan, enumerated clone/tag conventions, pinned VLAN-context kinds, required VLAN-context synthetic metrics to carry `TopologyKind`, kept `removeConstantMetrics()` metrics-only, and added CDP extender inventory.
462 +- Reconciled seventh readiness review: verdict `READY TO WRITE CODE`; folded in non-blocking NB1-NB4 hygiene around stale-helper scans, API package location, global metric-tag consumer wrapper, and post-cutover topology ingest loop shape.
463 +- User approved proceeding with implementation.
464 +- Activated SOW on branch `snmp-profile-projection`.
465 +- Dirty files at activation: `src/go/plugin/go.d/TODO-snmp-profile-loading-topology.md` and `.agents/sow/current/SOW-0012-20260506-snmp-profile-projection.md`.
466 +- Phase 0 baseline checks started before implementation code edits.
467 +- Phase 0 baseline checks completed:
468 + - `go test ./plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition`: passed from cache.
469 + - `go test ./plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector`: passed.
470 + - `go test ./plugin/go.d/collector/snmp/...`: passed.
471 + - `go test ./plugin/go.d/collector/snmp_topology/...`: initial sandbox run failed because the Go build cache was not writable; rerun outside sandbox passed.
472 +- Phase 1 schema/API surface completed:
473 + - Added canonical profile consumers, 18-value topology kind enum, `ProfileDefinition.Topology`, `TopologyConfig`, metadata `Consumers`, and top-level/global metric-tag consumer wrapper.
474 + - Added clone support for `Topology`, metadata consumers, and global metric-tag consumers.
475 + - Added validation for unknown topology kinds, topology-row metrics-only fields, underscore-prefixed topology symbols, invalid/duplicate consumers, and virtual metrics that source topology rows.
476 + - Kept per-row metric tags on the existing `MetricTagConfig` type; only top-level/global `metric_tags` use the consumer wrapper.
477 + - Updated transitional callers/tests that consume top-level/global metric tags.
478 +- Phase 1 validation:
479 + - `go test ./collector/snmp/ddsnmp/ddprofiledefinition`: passed.
480 + - `go test ./collector/snmp/ddsnmp/ddsnmpcollector`: passed.
481 + - `go test ./collector/snmp/...`: passed.
482 + - `go test ./collector/snmp_topology/...`: passed.
483 +- Phase 2 mutation split completed:
484 + - Moved cross-table synthetic row preparation from `ddsnmpcollector.New()` into `ddsnmp` load/profile preparation.
485 + - Kept resolve-time cross-profile dedup in `deduplicateMetricsAcrossProfiles()` and extended it to `Definition.Topology`.
486 + - Added `Profile.merge().mergeTopology(base)` with topology identity `(kind, table_identity, symbol_name)` and conflict rejection for same row/different kind.
487 + - Extended load-time `enrichProfile()` mapping-ref handling to `Definition.Topology`.
488 + - Extended cross-table synthesis to scan both `Definition.Metrics` and `Definition.Topology`, placing synthetic rows on the owning slice.
489 + - Preserved `ddsnmp.FinalizeProfiles()` as the temporary programmatic preparation path until its planned cleanup.
490 + - Projection mutation-isolation coverage remains tied to Phase 4 because the `Catalog.Resolve().Project()` view does not exist yet.
491 +- Phase 2 validation:
492 + - `go test ./collector/snmp/ddsnmp/...`: passed.
493 + - `go test ./collector/snmp/...`: passed.
494 + - `go test ./collector/snmp_topology/...`: passed.
495 +- Phase 3 profile YAML migration completed:
496 + - Moved topology rows from `metrics:` to top-level `topology:` in `_std-topology-lldp-mib.yaml`, `_std-topology-fdb-arp-mib.yaml`, `_std-topology-q-bridge-mib.yaml`, `_std-topology-stp-mib.yaml`, and `_std-topology-cisco-vtp-mib.yaml`.
497 + - Split CDP topology into `_std-topology-cdp-mib.yaml` and updated `_cisco-base.yaml` plus `cisco-sb.yaml` to extend it.
498 + - Renamed topology row anchor symbols away from `_topology_*` and assigned explicit `kind:` values.
499 + - `_system-base.yaml` was not modified; `systemUptime` remains a regular metric.
500 +- Phase 4 catalog/projection completed:
501 + - Added `Catalog.Resolve()`, manual profile policies, `ResolvedProfileSet.Project()`, and `ProjectedView.FilterByKind()`.
502 + - Regular SNMP projection keeps regular metrics and virtual metrics; topology projection keeps topology rows and drops regular metrics.
503 + - Added projection separation and mutation-isolation tests.
504 +- Phase 5 topology collection completed:
505 + - Added `Metric.TopologyKind` and `ProfileMetrics.TopologyMetrics`.
506 + - Added topology collection wrapper over existing scalar/table collectors without widening regular builder signatures.
507 + - Preserved generic `HiddenMetrics` for non-topology underscore-prefixed metrics.
508 + - Added a double-bucketing guard test proving topology rows are delivered through `TopologyMetrics`, not `HiddenMetrics`.
509 +- Phase 6 call-site cutover completed:
510 + - Regular SNMP uses `DefaultCatalog().Resolve(...).Project(ConsumerMetrics)`.
511 + - SNMP topology uses `DefaultCatalog().Resolve(...).Project(ConsumerTopology)`.
512 + - VLAN-context topology uses projection plus `FilterByKind(vlanScopableKinds)` instead of hardcoded mixin filename loads.
513 + - Topology dispatch now uses `TopologyKind`.
514 + - `updateTopologyProfileTags` applies `pm.Tags` as local device/profile labels.
515 +- User selected option A for topology uptime after removing `KindSysUptime`.
516 +- Removed `KindSysUptime`; topology now queries uptime through `pkg/snmputils.GetSysUptime`, while regular SNMP uptime remains profile-driven in `_system-base.yaml`.
517 +- Reconciled final Claude close-out review:
518 + - Accepted B1 and filled `Outcome` plus `Lessons Extracted`.
519 + - Accepted NF1 as a prose-only correction because `kind` is required by validation, so kind inheritance from an omitted derived kind is unreachable.
520 + - Accepted NF2 by recording that existing resolver/profile fixture tests are the parity proxy rather than a now-impossible side-by-side old-path test.
521 + - Accepted NF7 by mapping retained `LoadProfileByName` and `FinalizeProfiles` as explicit retained APIs rather than hidden deferred cleanup.
522 + - Rejected NF5 as stale because the `KindSysUptime`/`IsTopologySysUptimeMetric` predicates no longer exist after the uptime-helper change.
523 +- Removed old-implementation wording about underscore-prefixed topology metric names from `collector/snmp/profile-format.md`; public docs now describe only the current `topology:` schema.
524 +- Phase 7 cleanup/artifacts completed:
525 + - Deleted the topology classifier, regular/topology filter files, legacy topology profile constants, and obsolete metric-name dispatch constants.
526 + - Rewrote/deleted affected tests around projection and topology-kind delivery.
527 + - Updated `collector/snmp/profile-format.md`.
528 + - Updated `.agents/skills/project-snmp-profiles-authoring/SKILL.md`.
529 + - Created `.agents/sow/specs/snmp-profile-projection.md`.
530 + - Runtime topology no longer calls `LoadProfileByName()`. The helper remains for intentional abstract-profile tests/programmatic checks because the normal catalog skips `_std-*` abstract roots.
531 + - `ddsnmp.FinalizeProfiles()` remains because synthetic `ddsnmpcollector` table tests still require programmatic profile preparation outside the catalog path; topology runtime no longer depends on it.
532 +
533 +## Validation
534 +
535 +Acceptance criteria evidence:
536 +
537 +- Regular SNMP now selects profiles through `DefaultCatalog().Resolve(...ManualProfileFallback).Project(ConsumerMetrics)` in `collector/snmp/profile_sets.go`.
538 +- SNMP topology now selects profiles through `DefaultCatalog().Resolve(...ManualProfileAugment).Project(ConsumerTopology)` in `collector/snmp_topology/collector.go`.
539 +- VLAN-context topology now calls projection plus `FilterByKind(vlanScopableKinds)` in `collector/snmp_topology/topology_vlan_context_collect.go`.
540 +- Topology rows are emitted through `ProfileMetrics.TopologyMetrics` and carry `Metric.TopologyKind`.
541 +- Topology uptime is queried through `pkg/snmputils.GetSysUptime`; it is not a `TopologyKind`.
542 +- Generic non-topology hidden metrics are preserved by `TestCollector_Collect_PreservesHiddenMetrics`.
543 +- Topology double-bucketing is guarded by `TestCollector_Collect_SeparatesTopologyMetricsFromHiddenMetrics`.
544 +- Old classifier/filter source files and hardcoded topology profile constants were deleted.
545 +
546 +Tests or equivalent validation:
547 +
548 +- `go test -count=1 ./pkg/snmputils`: passed.
549 +- `go test -count=1 ./collector/snmp/ddsnmp/...`: passed.
550 +- `go test -count=1 ./collector/snmp/...`: passed.
551 +- `go test -count=1 ./collector/snmp_topology/...`: passed.
552 +- `go test -race -count=1 ./pkg/snmputils`: passed.
553 +- `go test -race -count=1 ./collector/snmp/ddsnmp/...`: passed.
554 +- `go test -race -count=1 ./collector/snmp/...`: passed.
555 +- `go test -race -count=1 ./collector/snmp_topology/...`: passed.
556 +- `git diff --check`: passed.
557 +- Same-failure search over code/artifacts confirmed no code references to `KindSysUptime`, `IsTopologySysUptimeMetric`, `updateTopologyScalarMetric`, or `ingestTopologySysUptimeMetricSet` remain.
558 +- The planned pre-deletion side-by-side old/new runtime parity test was not added because the old classifier/filter path is now removed. Existing `FindProfiles`/catalog resolver tests, profile merge tests, Cisco Nexus fixture coverage, and focused collector suites are the retained parity proxy.
559 +
560 +Real-use evidence:
561 +
562 +- No live SNMP device was used. This SOW changes profile schema/loading/dispatch internals; validation used default profile loading, synthetic SNMP handler tests, and topology cache/collector unit tests.
563 +
564 +Reviewer findings:
565 +
566 +- Multiple read-only AI reviews were summarized and reconciled in `src/go/plugin/go.d/TODO-snmp-profile-loading-topology.md`.
567 +- Fourth readiness review recorded `READY TO PROCEED` after the `HiddenMetrics` constraint and implementation sub-decisions were added.
568 +- Fifth readiness review recorded `NEEDS ADJUSTMENTS`; accepted blocking adjustments B1-B6 and suggestions S1-S6 are reflected in this SOW before implementation starts.
569 +- Sixth readiness review recorded `NEEDS ADJUSTMENTS`; accepted blocker N1, tightenings N2-N5, and actionable minor notes are reflected in this SOW before implementation starts.
570 +- Seventh readiness review recorded `READY TO WRITE CODE`; non-blocking hygiene tightenings NB1-NB4 are reflected in this SOW.
571 +
572 +Same-failure scan:
573 +
574 +- `rg` found no stale classifier/filter/helper references for `IsTopologyMetric`, `LooksLikeTopologyIdentifier`, `MetricConfigContainsTopologyData`, `MetricTagConfigContainsTopologyData`, `MetadataFieldContainsTopologyData`, `MetadataContainsTopologyData`, `SysobjectIDMetadataContainsTopologyData`, `ProfileContainsTopologyData`, `ProfileHasCollectionData`, `selectTopologyRefreshProfiles`, `selectCollectionProfiles`, or legacy topology metric-name constants.
575 +- `_topology_` remains only in topology chart/tag names, build tags, and validation tests that intentionally reject underscore-prefixed topology row names.
576 +- `HiddenMetrics` remains only in the generic collector path and non-topology hidden-metric tests.
577 +- `LoadProfileByName` remains as an abstract-profile helper; runtime topology call sites no longer use it.
578 +
579 +Sensitive data gate:
580 +
581 +- This SOW contains only file paths, public OIDs/metric names, struct/function names, and design decisions. It contains no raw credentials, SNMP communities, bearer tokens, customer names, personal data, customer-identifying IPs, private endpoints, or proprietary incident details.
582 +
583 +Artifact maintenance gate:
584 +
585 +- AGENTS.md: no update needed; existing SOW/process and SNMP skill triggers were sufficient.
586 +- Runtime project skills: updated `.agents/skills/project-snmp-profiles-authoring/SKILL.md` with top-level `topology:` and `TopologyKind` authoring rules.
587 +- Specs: created `.agents/sow/specs/snmp-profile-projection.md`.
588 +- End-user/operator docs: updated `collector/snmp/profile-format.md` with topology rows, consumers, topology kinds, and hidden-metric guidance.
589 +- End-user/operator skills: no public/operator skill update needed; this changes profile authoring guidance, which is covered by the runtime project skill and profile-format doc.
590 +- SOW lifecycle: completed and moved to `done/` during commit prep; status/directory are consistent.
591 +
592 +Specs update:
593 +
594 +- Created `.agents/sow/specs/snmp-profile-projection.md`.
595 +
596 +Project skills update:
597 +
598 +- Updated `.agents/skills/project-snmp-profiles-authoring/SKILL.md`.
599 +
600 +End-user/operator docs update:
601 +
602 +- Updated `collector/snmp/profile-format.md`.
603 +
604 +End-user/operator skills update:
605 +
606 +- No update required; no public skill artifact describes SNMP profile schema authoring.
607 +
608 +Lessons:
609 +
610 +- Normal catalog resolution intentionally excludes abstract `_std-*` profiles. Tests or programmatic checks that need a specific abstract mixin still need an explicit by-name loader; topology runtime should not use that path.
611 +- `FinalizeProfiles()` is still useful for synthetic collector tests that construct profiles outside the catalog. Treat it as programmatic preparation, not a topology runtime dependency.
612 +
613 +Follow-up mapping:
614 +
615 +- The probable `removeConstantMetrics()` value-copy bug in `collector/snmp/ddsnmp/profile.go` was not touched by this SOW because topology validation rejects `constant_value_one` rows and regular metric behavior did not require changing that path.
616 +- `LoadProfileByName` is intentionally retained as a programmatic abstract-profile loader for tests/checks such as `topology_profile_index_test.go`; topology runtime no longer calls it.
617 +- `FinalizeProfiles` is intentionally retained as a programmatic profile preparation helper for synthetic `ddsnmpcollector` tests that build profiles outside the catalog path; topology runtime no longer calls it.
618 +
619 +## Outcome
620 +
621 +Implementation is ready to commit.
622 +
623 +- Regular SNMP profile selection now uses the catalog resolver with metrics projection and fallback-only manual-profile semantics.
624 +- SNMP topology now uses topology projection, explicit `TopologyKind` row dispatch, `ProfileMetrics.TopologyMetrics`, and VLAN-context kind filtering instead of hardcoded topology profile filenames.
625 +- Topology profile YAML rows moved to top-level `topology:` with explicit `kind`; CDP regular metrics and topology rows are split.
626 +- `HiddenMetrics` remains a generic non-topology underscore-prefixed delivery path.
627 +- `KindSysUptime` was removed; topology queries uptime through `pkg/snmputils.GetSysUptime`, and regular SNMP uptime remains profile-driven in `_system-base.yaml`.
628 +- Docs, runtime SNMP profile authoring skill, and the SNMP projection spec were updated.
629 +
630 +## Lessons Extracted
631 +
632 +- Required topology `kind` makes "inherit base kind when derived omits kind" unreachable; durable design text must say explicit kind plus conflict rejection.
633 +- `LoadProfileByName` and `FinalizeProfiles` are no longer topology runtime dependencies, but they remain useful programmatic test/preparation helpers until those synthetic paths are redesigned.
634 +- A direct `snmputils` helper is cleaner for topology-only uptime enrichment than keeping a fake topology kind or retaining regular metrics in topology projection.
635 +- Readiness reviews can go stale quickly during active edits; final close-out review points must be checked against the current diff before accepting them.
636 +
637 +## Followup
638 +
639 +- Track the probable `removeConstantMetrics()` value-copy bug separately if regular metric behavior requires touching that path.
640 +- Consider a validation rule against duplicate `(table, symbol name)` topology collection rows with different kinds if real profiles ever create an ambiguous `TopologyKind` stamping case.
641 +- Consider strict YAML/schema validation for unknown keys in per-row `metric_tags` if author typo detection becomes important.
642 +- Consider a dedicated VLAN-context projection unit test beyond current transitive topology suite coverage.
643 +- Keep `LoadProfileByName` and `FinalizeProfiles` only as programmatic helpers; revisit cleanup if synthetic tests no longer need them.
644 +
645 +## Regression Log
646 +
647 +None yet.
648 +
649 +Append regression entries here only after this SOW was completed or closed and later testing or use found broken behavior. Use a dated `## Regression - YYYY-MM-DD` heading at the end of the file. Never prepend regression content above the original SOW narrative.
.agents/sow/specs/snmp-profile-projection.md new
+152
@@ -0,0 +1,152 @@
1 +# SNMP Profile Projection
2 +
3 +## Purpose
4 +
5 +SNMP profiles are one catalog with explicit projections for their consumers.
6 +Regular SNMP metric collection and SNMP topology use the same profile loading,
7 +matching, inheritance, metadata, and tag machinery, but they consume different
8 +profile views.
9 +
10 +## Consumers
11 +
12 +The supported profile consumers are:
13 +
14 +- `metrics` - regular SNMP charted metrics and virtual metrics.
15 +- `topology` - SNMP topology observations.
16 +
17 +Profile metadata fields and top-level `metric_tags` default to both consumers.
18 +They may narrow their visibility with:
19 +
20 +```yaml
21 +consumers: [metrics]
22 +consumers: [topology]
23 +```
24 +
25 +Metric rows under top-level `metrics:` are regular metric rows. They are
26 +metrics-only.
27 +
28 +Topology rows live under top-level `topology:` and must declare a closed
29 +`kind`.
30 +
31 +## Topology Rows
32 +
33 +Topology rows reuse the regular `MetricsConfig` scalar/table shape:
34 +
35 +```yaml
36 +topology:
37 + - kind: lldp_rem
38 + table:
39 + OID: 1.0.8802.1.1.2.1.4.1
40 + name: lldpRemTable
41 + symbols:
42 + - OID: 1.0.8802.1.1.2.1.4.1.1.6
43 + name: lldp_rem
44 + metric_tags:
45 + - tag: lldp_loc_port_num
46 + index: 2
47 +```
48 +
49 +Topology row symbol names must not be underscore-prefixed. The historical
50 +`_topology_*` naming convention is not a classifier.
51 +
52 +Topology rows must not set regular metric chart/export fields on the row value
53 +symbol: `chart_meta`, `metric_type`, `mapping`, `transform`, `scale_factor`,
54 +`format`, or `constant_value_one`.
55 +
56 +`systemUptime` remains in `metrics:` for regular SNMP collection. It is not a
57 +topology kind. Topology-specific uptime acquisition is collector code, not
58 +profile topology schema.
59 +
60 +## Topology Kinds
61 +
62 +The closed topology kind set is:
63 +
64 +- `lldp_loc_port`
65 +- `lldp_loc_man_addr`
66 +- `lldp_rem`
67 +- `lldp_rem_man_addr`
68 +- `lldp_rem_man_addr_compat`
69 +- `cdp_cache`
70 +- `if_name`
71 +- `if_status`
72 +- `if_duplex`
73 +- `ip_if_index`
74 +- `bridge_port_if_index`
75 +- `fdb_entry`
76 +- `qbridge_fdb_entry`
77 +- `qbridge_vlan_entry`
78 +- `stp_port`
79 +- `vtp_vlan`
80 +- `arp_entry`
81 +- `arp_legacy_entry`
82 +
83 +## Resolve And Projection
84 +
85 +`ddsnmp.Catalog.Resolve()` resolves profiles by `sysObjectID`, `sysDescr`, and
86 +manual profile policy. The regular SNMP collector uses manual-profile fallback
87 +semantics. The topology collector uses manual-profile augment semantics.
88 +
89 +`ResolvedProfileSet.Project(metrics)` returns the regular metrics view:
90 +
91 +- keeps `metrics`;
92 +- keeps `virtual_metrics`;
93 +- drops `topology`;
94 +- filters metadata and top-level metric tags by `consumers`.
95 +
96 +`ResolvedProfileSet.Project(topology)` returns the topology view:
97 +
98 +- keeps `topology`;
99 +- drops regular `metrics`;
100 +- drops `virtual_metrics`;
101 +- filters metadata and top-level metric tags by `consumers`.
102 +
103 +`ProjectedView.FilterByKind()` is a topology view filter. VLAN-context topology
104 +uses it with the VLAN-scopable kind set instead of hardcoded topology mixin
105 +filenames.
106 +
107 +## Inheritance And Merge Rules
108 +
109 +Profile inheritance must merge `topology:` rows in addition to `metrics:`,
110 +`virtual_metrics`, metadata, global metric tags, and static tags.
111 +
112 +Topology row identity is:
113 +
114 +```text
115 +kind + table identity + symbol name
116 +```
117 +
118 +The table identity is the table name when set, otherwise the table OID. Scalar
119 +topology rows use kind plus scalar symbol name and OID.
120 +
121 +When a derived topology row overrides an inherited row with the same identity,
122 +the derived row wins. Conflicting topology kinds for the same table/symbol
123 +identity are load errors.
124 +
125 +Cross-profile deduplication runs after profile matching because it depends on
126 +matched-profile specificity. It must deduplicate both regular metrics and
127 +topology rows in the resolved matched set.
128 +
129 +## Delivery
130 +
131 +Regular metrics are emitted through `ProfileMetrics.Metrics`.
132 +
133 +Topology rows are emitted through `ProfileMetrics.TopologyMetrics` and carry
134 +`Metric.TopologyKind`.
135 +
136 +`ProfileMetrics.HiddenMetrics` remains a generic delivery container for
137 +underscore-prefixed non-topology metrics. SNMP topology must not depend on
138 +hidden metrics.
139 +
140 +Top-level `metric_tags` on topology projections are profile/device labels. They
141 +are applied through topology profile-tag ingestion and are not topology row
142 +dispatch keys.
143 +
144 +## Validation Guarantees
145 +
146 +Profile validation rejects:
147 +
148 +- unknown topology kinds;
149 +- underscore-prefixed topology row value symbol names;
150 +- regular metric chart/export-only fields on topology row value symbols;
151 +- invalid `consumers` values;
152 +- virtual metrics whose sources resolve to topology rows.
AGENTS.md
+9
@@ -347,6 +347,15 @@ Output/reference skills:
347 - Do not claim full-project validation from a narrow subsystem command.
348 - Existing local helper scripts such as `install.sh` may exist in this working copy; inspect before use and do not assume they are tracked project interfaces.
349
350 +### Go test style
351 +
352 +- Prefer table-driven tests using `map[string]struct{}` keyed by test-case name
353 + when cases share setup and assertion shape.
354 +- Use separate test functions only when setup or assertions are materially
355 + different.
356 +- Prefer map keys over a `name` field in `[]struct{}` so case names are
357 + prominent and order-independent.
358 +
359 ### Project-specific overrides
360
361 All existing project-specific instructions in this file remain active. The SOW framework adds durable work tracking; it does not weaken the root-cause, collector consistency, C code, naming, local-output, or secret-handling rules below.
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/consumers.go new
+33
@@ -0,0 +1,33 @@
1 +// Unless explicitly stated otherwise all files in this repository are licensed
2 +// under the Apache License Version 2.0.
3 +// This product includes software developed at Datadog (https://www.datadoghq.com/).
4 +
5 +package ddprofiledefinition
6 +
7 +import "slices"
8 +
9 +type ProfileConsumer string
10 +
11 +const (
12 + ConsumerMetrics ProfileConsumer = "metrics"
13 + ConsumerTopology ProfileConsumer = "topology"
14 +)
15 +
16 +type ConsumerSet []ProfileConsumer
17 +
18 +func (s ConsumerSet) Clone() ConsumerSet {
19 + return slices.Clone(s)
20 +}
21 +
22 +func (s ConsumerSet) Contains(consumer ProfileConsumer) bool {
23 + for _, c := range s {
24 + if c == consumer {
25 + return true
26 + }
27 + }
28 + return false
29 +}
30 +
31 +func (s ConsumerSet) IsEmpty() bool {
32 + return len(s) == 0
33 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/metadata.go
+8 -6
@@ -32,17 +32,19 @@ func (c MetadataResourceConfig) Clone() MetadataResourceConfig {
32
33 // MetadataField holds configs for a metadata field
34 type MetadataField struct {
35 - Symbol SymbolConfig `yaml:"symbol,omitempty" json:"symbol"`
36 - Symbols []SymbolConfig `yaml:"symbols,omitempty" json:"symbols,omitempty"`
37 - Value string `yaml:"value,omitempty" json:"value,omitempty"`
35 + Symbol SymbolConfig `yaml:"symbol,omitempty" json:"symbol"`
36 + Symbols []SymbolConfig `yaml:"symbols,omitempty" json:"symbols,omitempty"`
37 + Value string `yaml:"value,omitempty" json:"value,omitempty"`
38 + Consumers ConsumerSet `yaml:"consumers,omitempty" json:"consumers,omitempty"`
39 }
40
41 // Clone duplicates this MetadataField
42 func (c MetadataField) Clone() MetadataField {
43 return MetadataField{
43 - Symbol: c.Symbol.Clone(),
44 - Symbols: cloneSlice(c.Symbols),
45 - Value: c.Value,
44 + Symbol: c.Symbol.Clone(),
45 + Symbols: cloneSlice(c.Symbols),
46 + Value: c.Value,
47 + Consumers: c.Consumers.Clone(),
48 }
49 }
50
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/metadata_test.go
+2 -1
@@ -20,7 +20,8 @@ func makeMetadata() MetadataConfig {
20 "device": MetadataResourceConfig{
21 Fields: map[string]MetadataField{
22 "name": {
23 - Value: "hey",
23 + Value: "hey",
24 + Consumers: ConsumerSet{ConsumerMetrics, ConsumerTopology},
25 Symbol: SymbolConfig{
26 OID: "1.2.3",
27 Name: "someSymbol",
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/metrics.go
+12
@@ -208,6 +208,18 @@ func (m MetricTagConfig) Clone() MetricTagConfig {
208 return m2
209 }
210
211 +type GlobalMetricTagConfig struct {
212 + MetricTagConfig `yaml:",inline" json:",inline"`
213 + Consumers ConsumerSet `yaml:"consumers,omitempty" json:"consumers,omitempty"`
214 +}
215 +
216 +func (m GlobalMetricTagConfig) Clone() GlobalMetricTagConfig {
217 + return GlobalMetricTagConfig{
218 + MetricTagConfig: m.MetricTagConfig.Clone(),
219 + Consumers: m.Consumers.Clone(),
220 + }
221 +}
222 +
223 type StaticMetricTagConfig struct {
224 Tag string `yaml:"tag" json:"tag"`
225 Value string `yaml:"value" json:"value"`
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/profile_definition.go
+3 -1
@@ -14,7 +14,8 @@ type ProfileDefinition struct {
14 Metadata MetadataConfig `yaml:"metadata,omitempty" json:"metadata,omitempty"`
15 SysobjectIDMetadata []SysobjectIDMetadataEntryConfig `yaml:"sysobjectid_metadata,omitempty"`
16 Metrics []MetricsConfig `yaml:"metrics,omitempty" json:"metrics,omitempty"`
17 - MetricTags []MetricTagConfig `yaml:"metric_tags,omitempty" json:"metric_tags,omitempty"`
17 + Topology []TopologyConfig `yaml:"topology,omitempty" json:"topology,omitempty"`
18 + MetricTags []GlobalMetricTagConfig `yaml:"metric_tags,omitempty" json:"metric_tags,omitempty"`
19 StaticTags []StaticMetricTagConfig `yaml:"static_tags,omitempty" json:"static_tags,omitempty"`
20
21 VirtualMetrics []VirtualMetricConfig `yaml:"virtual_metrics,omitempty" json:"virtual_metrics,omitempty"`
@@ -37,6 +38,7 @@ func (p *ProfileDefinition) Clone() *ProfileDefinition {
38 MetricTags: cloneSlice(p.MetricTags),
39 StaticTags: slices.Clone(p.StaticTags),
40 Metrics: cloneSlice(p.Metrics),
41 + Topology: cloneSlice(p.Topology),
42 VirtualMetrics: cloneSlice(p.VirtualMetrics),
43 }
44 }
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/topology.go new
+66
@@ -0,0 +1,66 @@
1 +// Unless explicitly stated otherwise all files in this repository are licensed
2 +// under the Apache License Version 2.0.
3 +// This product includes software developed at Datadog (https://www.datadoghq.com/).
4 +
5 +package ddprofiledefinition
6 +
7 +type TopologyKind string
8 +
9 +const (
10 + KindLldpLocPort TopologyKind = "lldp_loc_port"
11 + KindLldpLocManAddr TopologyKind = "lldp_loc_man_addr"
12 + KindLldpRem TopologyKind = "lldp_rem"
13 + KindLldpRemManAddr TopologyKind = "lldp_rem_man_addr"
14 + KindLldpRemManAddrCompat TopologyKind = "lldp_rem_man_addr_compat"
15 + KindCdpCache TopologyKind = "cdp_cache"
16 + KindIfName TopologyKind = "if_name"
17 + KindIfStatus TopologyKind = "if_status"
18 + KindIfDuplex TopologyKind = "if_duplex"
19 + KindIpIfIndex TopologyKind = "ip_if_index"
20 + KindBridgePortIfIndex TopologyKind = "bridge_port_if_index"
21 + KindFdbEntry TopologyKind = "fdb_entry"
22 + KindQbridgeFdbEntry TopologyKind = "qbridge_fdb_entry"
23 + KindQbridgeVlanEntry TopologyKind = "qbridge_vlan_entry"
24 + KindStpPort TopologyKind = "stp_port"
25 + KindVtpVlan TopologyKind = "vtp_vlan"
26 + KindArpEntry TopologyKind = "arp_entry"
27 + KindArpLegacyEntry TopologyKind = "arp_legacy_entry"
28 +)
29 +
30 +var validTopologyKinds = map[TopologyKind]struct{}{
31 + KindLldpLocPort: {},
32 + KindLldpLocManAddr: {},
33 + KindLldpRem: {},
34 + KindLldpRemManAddr: {},
35 + KindLldpRemManAddrCompat: {},
36 + KindCdpCache: {},
37 + KindIfName: {},
38 + KindIfStatus: {},
39 + KindIfDuplex: {},
40 + KindIpIfIndex: {},
41 + KindBridgePortIfIndex: {},
42 + KindFdbEntry: {},
43 + KindQbridgeFdbEntry: {},
44 + KindQbridgeVlanEntry: {},
45 + KindStpPort: {},
46 + KindVtpVlan: {},
47 + KindArpEntry: {},
48 + KindArpLegacyEntry: {},
49 +}
50 +
51 +func IsValidTopologyKind(kind TopologyKind) bool {
52 + _, ok := validTopologyKinds[kind]
53 + return ok
54 +}
55 +
56 +type TopologyConfig struct {
57 + Kind TopologyKind `yaml:"kind" json:"kind"`
58 + MetricsConfig `yaml:",inline" json:",inline"`
59 +}
60 +
61 +func (c TopologyConfig) Clone() TopologyConfig {
62 + return TopologyConfig{
63 + Kind: c.Kind,
64 + MetricsConfig: c.MetricsConfig.Clone(),
65 + }
66 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/topology_test.go new
+260
@@ -0,0 +1,260 @@
1 +// Unless explicitly stated otherwise all files in this repository are licensed
2 +// under the Apache License Version 2.0.
3 +// This product includes software developed at Datadog (https://www.datadoghq.com/).
4 +
5 +package ddprofiledefinition
6 +
7 +import (
8 + "testing"
9 +
10 + "github.com/stretchr/testify/assert"
11 + "github.com/stretchr/testify/require"
12 + "gopkg.in/yaml.v2"
13 +)
14 +
15 +func TestProfileDefinition_UnmarshalTopologyAndConsumers(t *testing.T) {
16 + var profile ProfileDefinition
17 +
18 + err := yaml.Unmarshal([]byte(`
19 +metadata:
20 + device:
21 + fields:
22 + lldp_loc_chassis_id:
23 + consumers: [topology]
24 + symbol:
25 + OID: 1.0.8802.1.1.2.1.3.2.0
26 + name: lldpLocChassisId
27 +metric_tags:
28 + - tag: vendor
29 + consumers: [metrics, topology]
30 + symbol:
31 + OID: 1.3.6.1.2.1.1.1.0
32 + name: sysDescr
33 +topology:
34 + - kind: lldp_rem
35 + MIB: LLDP-MIB
36 + table:
37 + OID: 1.0.8802.1.1.2.1.4.1
38 + name: lldpRemTable
39 + symbols:
40 + - OID: 1.0.8802.1.1.2.1.4.1.1.6
41 + name: lldpRemPortIdSubtype
42 + metric_tags:
43 + - tag: lldp_rem_index
44 + index: 1
45 +`), &profile)
46 +
47 + require.NoError(t, err)
48 + require.Len(t, profile.Topology, 1)
49 + assert.Equal(t, KindLldpRem, profile.Topology[0].Kind)
50 + assert.Equal(t, "lldpRemTable", profile.Topology[0].Table.Name)
51 + require.Len(t, profile.Topology[0].Symbols, 1)
52 + assert.Equal(t, "lldpRemPortIdSubtype", profile.Topology[0].Symbols[0].Name)
53 + require.Len(t, profile.Topology[0].MetricTags, 1)
54 + assert.Equal(t, "lldp_rem_index", profile.Topology[0].MetricTags[0].Tag)
55 + assert.Equal(t, ConsumerSet{ConsumerTopology}, profile.Metadata["device"].Fields["lldp_loc_chassis_id"].Consumers)
56 + require.Len(t, profile.MetricTags, 1)
57 + assert.Equal(t, ConsumerSet{ConsumerMetrics, ConsumerTopology}, profile.MetricTags[0].Consumers)
58 +}
59 +
60 +func TestProfileDefinition_CloneTopologyAndConsumers(t *testing.T) {
61 + profile := &ProfileDefinition{
62 + Metadata: MetadataConfig{
63 + "device": {
64 + Fields: map[string]MetadataField{
65 + "vendor": {
66 + Value: "Cisco",
67 + Consumers: ConsumerSet{ConsumerMetrics, ConsumerTopology},
68 + },
69 + },
70 + },
71 + },
72 + MetricTags: []GlobalMetricTagConfig{
73 + {
74 + MetricTagConfig: MetricTagConfig{Tag: "vendor"},
75 + Consumers: ConsumerSet{ConsumerMetrics, ConsumerTopology},
76 + },
77 + },
78 + Topology: []TopologyConfig{
79 + {
80 + Kind: KindLldpRem,
81 + MetricsConfig: MetricsConfig{
82 + Table: SymbolConfig{
83 + OID: "1.0.8802.1.1.2.1.4.1",
84 + Name: "lldpRemTable",
85 + },
86 + Symbols: []SymbolConfig{
87 + {OID: "1.0.8802.1.1.2.1.4.1.1.6", Name: "lldpRemPortIdSubtype"},
88 + },
89 + MetricTags: MetricTagConfigList{
90 + {
91 + Tag: "lldp_rem_index",
92 + IndexTransform: []MetricIndexTransform{
93 + {Start: 1},
94 + },
95 + },
96 + },
97 + },
98 + },
99 + },
100 + }
101 +
102 + cloned := profile.Clone()
103 + require.Equal(t, profile, cloned)
104 +
105 + cloned.Metadata["device"].Fields["vendor"] = MetadataField{
106 + Value: "Cisco",
107 + Consumers: ConsumerSet{ConsumerTopology},
108 + }
109 + cloned.MetricTags[0].Consumers[0] = ConsumerTopology
110 + cloned.Topology[0].MetricTags[0].IndexTransform[0].Start = 2
111 +
112 + assert.Equal(t, ConsumerSet{ConsumerMetrics, ConsumerTopology}, profile.Metadata["device"].Fields["vendor"].Consumers)
113 + assert.Equal(t, ConsumerSet{ConsumerMetrics, ConsumerTopology}, profile.MetricTags[0].Consumers)
114 + assert.Equal(t, uint(1), profile.Topology[0].MetricTags[0].IndexTransform[0].Start)
115 +}
116 +
117 +func TestValidateEnrichProfile_Topology(t *testing.T) {
118 + tests := map[string]struct {
119 + profile ProfileDefinition
120 + wantErrContains []string
121 + }{
122 + "valid topology row": {
123 + profile: ProfileDefinition{
124 + Topology: []TopologyConfig{
125 + {
126 + Kind: KindLldpRem,
127 + MetricsConfig: MetricsConfig{
128 + Table: SymbolConfig{
129 + OID: "1.0.8802.1.1.2.1.4.1",
130 + Name: "lldpRemTable",
131 + },
132 + Symbols: []SymbolConfig{
133 + {OID: "1.0.8802.1.1.2.1.4.1.1.6", Name: "lldpRemPortIdSubtype"},
134 + },
135 + MetricTags: MetricTagConfigList{
136 + {Tag: "lldp_rem_index", Index: 1},
137 + },
138 + },
139 + },
140 + },
141 + },
142 + },
143 + "unknown topology kind": {
144 + profile: ProfileDefinition{
145 + Topology: []TopologyConfig{
146 + {
147 + Kind: "typo",
148 + MetricsConfig: MetricsConfig{
149 + Symbol: SymbolConfig{OID: "1.2.3.0", Name: "topologyValue"},
150 + },
151 + },
152 + },
153 + },
154 + wantErrContains: []string{`topology[0]: invalid kind "typo"`},
155 + },
156 + "metrics-only topology fields": {
157 + profile: ProfileDefinition{
158 + Topology: []TopologyConfig{
159 + {
160 + Kind: KindIfStatus,
161 + MetricsConfig: MetricsConfig{
162 + Options: MetricsConfigOption{Placement: 1},
163 + Symbol: SymbolConfig{
164 + OID: "1.3.6.1.2.1.2.2.1.8.0",
165 + Name: "_topology_if_status",
166 + ChartMeta: ChartMeta{Description: "status"},
167 + MetricType: ProfileMetricTypeGauge,
168 + Mapping: NewExactMapping(map[string]string{"1": "up"}),
169 + Transform: `{{ .Metric }}`,
170 + ScaleFactor: 2,
171 + Format: "mac_address",
172 + ConstantValueOne: true,
173 + },
174 + },
175 + },
176 + },
177 + },
178 + wantErrContains: []string{
179 + "topology[0]: options cannot be used in topology rows",
180 + `topology[0]: symbol name "_topology_if_status" cannot be underscore-prefixed`,
181 + "topology[0]: chart_meta cannot be used in topology rows",
182 + "topology[0]: metric_type cannot be used in topology rows",
183 + "topology[0]: mapping cannot be used in topology rows",
184 + "topology[0]: transform cannot be used in topology rows",
185 + "topology[0]: scale_factor cannot be used in topology rows",
186 + "topology[0]: format cannot be used in topology rows",
187 + "topology[0]: constant_value_one cannot be used in topology rows",
188 + },
189 + },
190 + "metric tag extraction fields remain valid": {
191 + profile: ProfileDefinition{
192 + Topology: []TopologyConfig{
193 + {
194 + Kind: KindFdbEntry,
195 + MetricsConfig: MetricsConfig{
196 + Table: SymbolConfig{
197 + OID: "1.3.6.1.2.1.17.4.3",
198 + Name: "dot1dTpFdbTable",
199 + },
200 + Symbols: []SymbolConfig{
201 + {OID: "1.3.6.1.2.1.17.4.3.1.2", Name: "dot1dTpFdbPort"},
202 + },
203 + MetricTags: MetricTagConfigList{
204 + {
205 + Tag: "fdb_mac",
206 + Index: 1,
207 + Mapping: NewExactMapping(map[string]string{"1": "one"}),
208 + Symbol: SymbolConfigCompat{
209 + Name: "dot1dTpFdbAddress",
210 + Format: "mac_address",
211 + },
212 + IndexTransform: []MetricIndexTransform{{Start: 1}},
213 + },
214 + },
215 + },
216 + },
217 + },
218 + },
219 + },
220 + "invalid consumer": {
221 + profile: ProfileDefinition{
222 + Metadata: MetadataConfig{
223 + "device": {
224 + Fields: map[string]MetadataField{
225 + "vendor": {
226 + Value: "Cisco",
227 + Consumers: ConsumerSet{"logs"},
228 + },
229 + },
230 + },
231 + },
232 + MetricTags: []GlobalMetricTagConfig{
233 + {
234 + MetricTagConfig: MetricTagConfig{Tag: "vendor"},
235 + Consumers: ConsumerSet{ConsumerMetrics, ConsumerMetrics},
236 + },
237 + },
238 + },
239 + wantErrContains: []string{
240 + `metadata.device.fields.vendor.consumers[0]: invalid consumer "logs"`,
241 + `metric_tags[0].consumers[1]: duplicate consumer "metrics"`,
242 + },
243 + },
244 + }
245 +
246 + for name, tt := range tests {
247 + t.Run(name, func(t *testing.T) {
248 + profile := tt.profile
249 + err := ValidateEnrichProfile(&profile)
250 + if len(tt.wantErrContains) == 0 {
251 + assert.NoError(t, err)
252 + return
253 + }
254 + require.Error(t, err)
255 + for _, msg := range tt.wantErrContains {
256 + assert.ErrorContains(t, err, msg)
257 + }
258 + })
259 + }
260 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/validation.go
+175 -11
@@ -62,19 +62,23 @@ const (
62 ColumnSymbol
63 MetricTagSymbol
64 MetadataSymbol
65 + TopologyScalarSymbol
66 + TopologyColumnSymbol
67 )
68
69 // ValidateEnrichProfile validates a profile and normalizes it.
70 func ValidateEnrichProfile(p *ProfileDefinition) error {
71 normalizeMetrics(p.Metrics)
72 + normalizeTopology(p.Topology)
73
74 errs := []error{
75 validateEnrichLegacySelector(p),
76 validateEnrichMetadata(p.Metadata),
77 validateEnrichSysobjectIDMetadata(p.SysobjectIDMetadata),
78 validateEnrichMetrics(p.Metrics),
76 - validateEnrichMetricTags(p.MetricTags),
77 - validateEnrichVirtualMetrics(p.Metrics, p.VirtualMetrics),
79 + validateEnrichTopology(p.Topology),
80 + validateEnrichGlobalMetricTags(p.MetricTags),
81 + validateEnrichVirtualMetrics(p.Metrics, p.Topology, p.VirtualMetrics),
82 }
83
84 return errors.Join(errs...)
@@ -85,15 +89,31 @@ func ValidateEnrichProfile(p *ProfileDefinition) error {
89 // metric.Name and metric.OID info are moved to metric.Symbol.Name and metric.Symbol.OID
90 func normalizeMetrics(metrics []MetricsConfig) {
91 for i := range metrics {
88 - metric := &metrics[i]
92 + normalizeMetric(&metrics[i])
93 + }
94 +}
95
90 - // converts old symbol syntax to new symbol syntax
91 - if metric.Symbol.Name == "" && metric.Symbol.OID == "" && metric.Name != "" && metric.OID != "" {
92 - metric.Symbol.Name = metric.Name
93 - metric.Symbol.OID = metric.OID
94 - metric.Name = ""
95 - metric.OID = ""
96 - }
96 +func normalizeTopology(topology []TopologyConfig) {
97 + for i := range topology {
98 + normalizeMetric(&topology[i].MetricsConfig)
99 + }
100 +}
101 +
102 +func normalizeMetric(metric *MetricsConfig) {
103 + if metric == nil {
104 + return
105 + }
106 +
107 + // converts old symbol syntax to new symbol syntax
108 + if metric.Symbol.Name == "" && metric.Symbol.OID == "" && metric.Name != "" && metric.OID != "" {
109 + metric.Symbol.Name = metric.Name
110 + metric.Symbol.OID = metric.OID
111 + metric.Name = ""
112 + metric.OID = ""
113 + }
114 + if metric.Symbol.MetricType == "" {
115 + metric.Symbol.MetricType = metric.MetricType
116 + metric.MetricType = ""
117 }
118 }
119
@@ -146,6 +166,7 @@ func validateEnrichMetadata(metadata MetadataConfig) error {
166 continue
167 }
168 field := res.Fields[fieldName]
169 + errs = append(errs, validateConsumers(fmt.Sprintf("metadata.%s.fields.%s.consumers", resName, fieldName), field.Consumers))
170 for i := range field.Symbols {
171 errs = append(errs, validateEnrichSymbol(&field.Symbols[i], MetadataSymbol))
172 }
@@ -200,6 +221,7 @@ func validateEnrichSysobjectIDMetadata(entries []SysobjectIDMetadataEntryConfig)
221 if field.Value == "" && field.Symbol.OID == "" && len(field.Symbols) == 0 {
222 errs = append(errs, fmt.Errorf("sysobjectid_metadata[%d].%s: must have either value or symbol(s)", i, fieldName))
223 }
224 + errs = append(errs, validateConsumers(fmt.Sprintf("sysobjectid_metadata[%d].%s.consumers", i, fieldName), field.Consumers))
225
226 // Can't have both value and symbols
227 if field.Value != "" && (field.Symbol.OID != "" || len(field.Symbols) > 0) {
@@ -286,6 +308,119 @@ func validateEnrichMetrics(metrics []MetricsConfig) error {
308 return errors.Join(errs...)
309 }
310
311 +func validateEnrichTopology(topology []TopologyConfig) error {
312 + var errs []error
313 +
314 + for i := range topology {
315 + topo := &topology[i]
316 + metricConfig := &topo.MetricsConfig
317 +
318 + if topo.Kind == "" {
319 + errs = append(errs, fmt.Errorf("topology[%d]: missing kind", i))
320 + } else if !IsValidTopologyKind(topo.Kind) {
321 + errs = append(errs, fmt.Errorf("topology[%d]: invalid kind %q", i, topo.Kind))
322 + }
323 + if !metricConfig.IsScalar() && !metricConfig.IsColumn() {
324 + errs = append(errs, fmt.Errorf("topology[%d]: either a table symbol or a scalar symbol must be provided: %#v", i, metricConfig))
325 + }
326 + if metricConfig.IsScalar() && metricConfig.IsColumn() {
327 + errs = append(errs, fmt.Errorf("topology[%d]: table symbol and scalar symbol cannot be both provided: %#v", i, metricConfig))
328 + }
329 + if metricConfig.Options != (MetricsConfigOption{}) {
330 + errs = append(errs, fmt.Errorf("topology[%d]: options cannot be used in topology rows", i))
331 + }
332 + if metricConfig.IsScalar() {
333 + errs = append(errs, validateEnrichTopologySymbol(i, &metricConfig.Symbol, TopologyScalarSymbol))
334 + for j := range metricConfig.MetricTags {
335 + metricTag := &metricConfig.MetricTags[j]
336 + errs = append(errs, validateEnrichMetricTag(metricTag))
337 + if metricTag.Table != "" {
338 + errs = append(errs, fmt.Errorf("topology[%d].metric_tags[%d]: scalar metric_tags do not support `table` lookups (tag=%q, table=%q)", i, j, metricTag.Tag, metricTag.Table))
339 + }
340 + if metricTag.Index != 0 {
341 + errs = append(errs, fmt.Errorf("topology[%d].metric_tags[%d]: scalar metric_tags do not support `index` lookups (tag=%q, index=%d)", i, j, metricTag.Tag, metricTag.Index))
342 + }
343 + if len(metricTag.IndexTransform) > 0 {
344 + errs = append(errs, fmt.Errorf("topology[%d].metric_tags[%d]: scalar metric_tags do not support `index_transform` (tag=%q)", i, j, metricTag.Tag))
345 + }
346 + if metricTag.Symbol.OID == "" {
347 + errs = append(errs, fmt.Errorf("topology[%d].metric_tags[%d]: scalar metric_tags require `symbol.OID` (tag=%q)", i, j, metricTag.Tag))
348 + }
349 + }
350 + }
351 + if metricConfig.IsColumn() {
352 + for j := range metricConfig.Symbols {
353 + errs = append(errs, validateEnrichTopologySymbol(i, &metricConfig.Symbols[j], TopologyColumnSymbol))
354 + }
355 + for j := range metricConfig.MetricTags {
356 + errs = append(errs, validateEnrichMetricTag(&metricConfig.MetricTags[j]))
357 + }
358 + }
359 + }
360 +
361 + return errors.Join(errs...)
362 +}
363 +
364 +func validateEnrichTopologySymbol(topologyIdx int, symbol *SymbolConfig, symbolContext SymbolContext) error {
365 + var errs []error
366 +
367 + errs = append(errs, validateEnrichSymbol(symbol, symbolContext))
368 + if strings.HasPrefix(symbol.Name, "_") {
369 + errs = append(errs, fmt.Errorf("topology[%d]: symbol name %q cannot be underscore-prefixed", topologyIdx, symbol.Name))
370 + }
371 + if symbol.ChartMeta != (ChartMeta{}) {
372 + errs = append(errs, fmt.Errorf("topology[%d]: chart_meta cannot be used in topology rows", topologyIdx))
373 + }
374 + if symbol.MetricType != "" {
375 + errs = append(errs, fmt.Errorf("topology[%d]: metric_type cannot be used in topology rows", topologyIdx))
376 + }
377 + if symbol.Mapping.HasItems() || symbol.Mapping.Mode != "" {
378 + errs = append(errs, fmt.Errorf("topology[%d]: mapping cannot be used in topology rows", topologyIdx))
379 + }
380 + if symbol.Transform != "" {
381 + errs = append(errs, fmt.Errorf("topology[%d]: transform cannot be used in topology rows", topologyIdx))
382 + }
383 + if symbol.ScaleFactor != 0 {
384 + errs = append(errs, fmt.Errorf("topology[%d]: scale_factor cannot be used in topology rows", topologyIdx))
385 + }
386 + if symbol.Format != "" {
387 + errs = append(errs, fmt.Errorf("topology[%d]: format cannot be used in topology rows", topologyIdx))
388 + }
389 + if symbol.ConstantValueOne {
390 + errs = append(errs, fmt.Errorf("topology[%d]: constant_value_one cannot be used in topology rows", topologyIdx))
391 + }
392 +
393 + return errors.Join(errs...)
394 +}
395 +
396 +func validateEnrichGlobalMetricTags(metricTags []GlobalMetricTagConfig) error {
397 + var errs []error
398 + for i := range metricTags {
399 + errs = append(errs, validateEnrichMetricTag(&metricTags[i].MetricTagConfig))
400 + errs = append(errs, validateConsumers(fmt.Sprintf("metric_tags[%d].consumers", i), metricTags[i].Consumers))
401 + }
402 + return errors.Join(errs...)
403 +}
404 +
405 +func validateConsumers(path string, consumers ConsumerSet) error {
406 + var errs []error
407 + seen := make(map[ProfileConsumer]int)
408 + for i, consumer := range consumers {
409 + switch consumer {
410 + case ConsumerMetrics, ConsumerTopology:
411 + default:
412 + errs = append(errs, fmt.Errorf("%s[%d]: invalid consumer %q", path, i, consumer))
413 + continue
414 + }
415 + if firstIdx, ok := seen[consumer]; ok {
416 + errs = append(errs, fmt.Errorf("%s[%d]: duplicate consumer %q (first occurrence at index %d)", path, i, consumer, firstIdx))
417 + continue
418 + }
419 + seen[consumer] = i
420 + }
421 + return errors.Join(errs...)
422 +}
423 +
424 func validateEnrichMetricTags(metricTags []MetricTagConfig) error {
425 var errs []error
426 for i := range metricTags {
@@ -477,10 +612,11 @@ func validateMapping(mapping MappingConfig, symbolContext SymbolContext) error {
612 return errors.Join(errs...)
613 }
614
480 -func validateEnrichVirtualMetrics(metrics []MetricsConfig, vmetrics []VirtualMetricConfig) error {
615 +func validateEnrichVirtualMetrics(metrics []MetricsConfig, topology []TopologyConfig, vmetrics []VirtualMetricConfig) error {
616 var errs []error
617
618 metricSources := collectVirtualMetricSourceSpecs(metrics)
619 + topologySources := collectTopologyMetricSourceNames(topology)
620
621 seenNames := make(map[string]int)
622
@@ -521,6 +657,7 @@ func validateEnrichVirtualMetrics(metrics []MetricsConfig, vmetrics []VirtualMet
657 case len(vm.Sources) == 0 && len(vm.Alternatives) == 0:
658 errs = append(errs, fmt.Errorf("virtual_metrics[%d]: must define sources or alternatives", i))
659 case len(vm.Alternatives) == 0:
660 + errs = append(errs, validateVirtualMetricSourcesNotTopology(fmt.Sprintf("virtual_metrics[%d].sources", i), vm.Sources, topologySources))
661 errs = append(errs, validateVirtualMetricSources(fmt.Sprintf("virtual_metrics[%d].sources", i), vm.Sources, metricSources, grouped))
662 default:
663 for j, alt := range vm.Alternatives {
@@ -528,6 +665,7 @@ func validateEnrichVirtualMetrics(metrics []MetricsConfig, vmetrics []VirtualMet
665 errs = append(errs, fmt.Errorf("virtual_metrics[%d].alternatives[%d]: must define sources", i, j))
666 continue
667 }
668 + errs = append(errs, validateVirtualMetricSourcesNotTopology(fmt.Sprintf("virtual_metrics[%d].alternatives[%d].sources", i, j), alt.Sources, topologySources))
669 errs = append(errs, validateVirtualMetricSources(fmt.Sprintf("virtual_metrics[%d].alternatives[%d].sources", i, j), alt.Sources, metricSources, grouped))
670 }
671 }
@@ -536,6 +674,32 @@ func validateEnrichVirtualMetrics(metrics []MetricsConfig, vmetrics []VirtualMet
674 return errors.Join(errs...)
675 }
676
677 +func collectTopologyMetricSourceNames(topology []TopologyConfig) map[string]struct{} {
678 + names := make(map[string]struct{})
679 + for _, topo := range topology {
680 + metric := &topo.MetricsConfig
681 + switch {
682 + case metric.IsScalar():
683 + names[metric.Symbol.Name] = struct{}{}
684 + case metric.IsColumn():
685 + for _, sym := range metric.Symbols {
686 + names[sym.Name] = struct{}{}
687 + }
688 + }
689 + }
690 + return names
691 +}
692 +
693 +func validateVirtualMetricSourcesNotTopology(path string, sources []VirtualMetricSourceConfig, topologySources map[string]struct{}) error {
694 + var errs []error
695 + for i, src := range sources {
696 + if _, ok := topologySources[src.Metric]; ok {
697 + errs = append(errs, fmt.Errorf("%s[%d]: topology metric source %q cannot be used by virtual_metrics", path, i, src.Metric))
698 + }
699 + }
700 + return errors.Join(errs...)
701 +}
702 +
703 func validateVirtualMetricSources(path string, sources []VirtualMetricSourceConfig, metricSources map[string]map[string]virtualMetricSourceSpec, grouped bool) error {
704 var errs []error
705
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/validation_test.go
+33 -1
@@ -752,6 +752,7 @@ func Test_validateEnrichVirtualMetrics(t *testing.T) {
752
753 tests := map[string]struct {
754 metrics []MetricsConfig
755 + topology []TopologyConfig
756 virtualMetrics []VirtualMetricConfig
757 wantErrContains []string
758 }{
@@ -967,6 +968,37 @@ func Test_validateEnrichVirtualMetrics(t *testing.T) {
968 "virtual_metrics[0]: must define sources or alternatives",
969 },
970 },
971 + "reject topology source": {
972 + metrics: baseMetrics,
973 + topology: []TopologyConfig{
974 + {
975 + Kind: KindLldpRem,
976 + MetricsConfig: MetricsConfig{
977 + Table: SymbolConfig{
978 + OID: "1.0.8802.1.1.2.1.4.1",
979 + Name: "lldpRemTable",
980 + },
981 + Symbols: []SymbolConfig{
982 + {OID: "1.0.8802.1.1.2.1.4.1.1.6", Name: "lldpRemPortIdSubtype"},
983 + },
984 + MetricTags: MetricTagConfigList{
985 + {Tag: "lldp_rem_index", Index: 1},
986 + },
987 + },
988 + },
989 + },
990 + virtualMetrics: []VirtualMetricConfig{
991 + {
992 + Name: "invalidTopologyDerivedMetric",
993 + Sources: []VirtualMetricSourceConfig{
994 + {Metric: "lldpRemPortIdSubtype", Table: "lldpRemTable"},
995 + },
996 + },
997 + },
998 + wantErrContains: []string{
999 + `virtual_metrics[0].sources[0]: topology metric source "lldpRemPortIdSubtype" cannot be used by virtual_metrics`,
1000 + },
1001 + },
1002 "duplicate name conflicting with metric": {
1003 metrics: baseMetrics,
1004 virtualMetrics: []VirtualMetricConfig{
@@ -1026,7 +1058,7 @@ func Test_validateEnrichVirtualMetrics(t *testing.T) {
1058
1059 for name, tt := range tests {
1060 t.Run(name, func(t *testing.T) {
1029 - err := validateEnrichVirtualMetrics(tt.metrics, tt.virtualMetrics)
1061 + err := validateEnrichVirtualMetrics(tt.metrics, tt.topology, tt.virtualMetrics)
1062 if len(tt.wantErrContains) == 0 {
1063 assert.NoError(t, err)
1064 return
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector.go
+27 -95
@@ -15,7 +15,6 @@ import (
15
16 "github.com/netdata/netdata/go/plugins/logger"
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/ddprofiledefinition"
18 )
19
20 type Config struct {
@@ -35,7 +34,6 @@ func New(cfg Config) *Collector {
34 }
35
36 for _, prof := range cfg.Profiles {
38 - handleCrossTableTagsWithoutMetrics(prof)
37 coll.profiles[prof.SourceFile] = &profileState{profile: prof}
38 }
39
@@ -200,28 +198,43 @@ func (c *Collector) collectProfile(ps *profileState) (*ddsnmp.ProfileMetrics, er
198 pm.Stats.Timing.Table = time.Since(now)
199 pm.Stats.Metrics.Table += int64(len(tableMetrics))
200
201 + topologyMetrics, err := c.collectTopologyMetrics(ps.profile, &pm.Stats)
202 + if err != nil {
203 + return nil, err
204 + }
205 + pm.TopologyMetrics = append(pm.TopologyMetrics, topologyMetrics...)
206 +
207 for i := range pm.Metrics {
208 pm.Metrics[i].Profile = pm
209 }
210 + for i := range pm.TopologyMetrics {
211 + pm.TopologyMetrics[i].Profile = pm
212 + }
213
214 return pm, nil
215 }
216
217 func (c *Collector) updateProfileMetrics(pm *ddsnmp.ProfileMetrics) {
218 for i := range pm.Metrics {
212 - m := &pm.Metrics[i]
213 - m.Description = metricMetaReplacer.Replace(m.Description)
214 - m.Family = metricMetaReplacer.Replace(m.Family)
215 - m.Unit = metricMetaReplacer.Replace(m.Unit)
216 - for k, v := range m.Tags {
217 - // Remove tags prefixed with "rm:", which are intended for temporary use during transforms
218 - // and should not appear in the final exported metric.
219 - if strings.HasPrefix(k, "rm:") {
220 - delete(m.Tags, k)
221 - continue
222 - }
223 - m.Tags[k] = metricMetaReplacer.Replace(v)
219 + sanitizeMetricMetadata(&pm.Metrics[i])
220 + }
221 + for i := range pm.TopologyMetrics {
222 + sanitizeMetricMetadata(&pm.TopologyMetrics[i])
223 + }
224 +}
225 +
226 +func sanitizeMetricMetadata(m *ddsnmp.Metric) {
227 + m.Description = metricMetaReplacer.Replace(m.Description)
228 + m.Family = metricMetaReplacer.Replace(m.Family)
229 + m.Unit = metricMetaReplacer.Replace(m.Unit)
230 + for k, v := range m.Tags {
231 + // Remove tags prefixed with "rm:", which are intended for temporary use during transforms
232 + // and should not appear in the final exported metric.
233 + if strings.HasPrefix(k, "rm:") {
234 + delete(m.Tags, k)
235 + continue
236 }
237 + m.Tags[k] = metricMetaReplacer.Replace(v)
238 }
239 }
240
@@ -231,84 +244,3 @@ var metricMetaReplacer = strings.NewReplacer(
244 "\r", " ",
245 "\x00", "",
246 )
234 -
235 -// handleCrossTableTagsWithoutMetrics ensures tables referenced only by cross-table tags
236 -// are still walked during collection. Without this, if a table like ifXTable is used
237 -// only for cross-table tags (e.g., getting interface names) but has no metrics defined,
238 -// it won't be walked and the tags will be missing. This creates synthetic metric entries
239 -// for such tables using the longest common OID prefix of the referenced columns, including
240 -// lookup columns used by value-based joins.
241 -func handleCrossTableTagsWithoutMetrics(prof *ddsnmp.Profile) {
242 - if prof.Definition == nil {
243 - return
244 - }
245 -
246 - seenTableNames := make(map[string]bool)
247 -
248 - for _, m := range prof.Definition.Metrics {
249 - seenTableNames[m.Table.Name] = true
250 - }
251 -
252 - tagCrossTableOnlyOIDs := make(map[string][]string)
253 -
254 - for _, m := range prof.Definition.Metrics {
255 - if m.IsScalar() {
256 - continue
257 - }
258 - for _, tag := range m.MetricTags {
259 - if tag.Table == "" || seenTableNames[tag.Table] {
260 - continue
261 - }
262 - if tag.Symbol.OID != "" {
263 - tagCrossTableOnlyOIDs[tag.Table] = append(tagCrossTableOnlyOIDs[tag.Table], tag.Symbol.OID)
264 - }
265 - if tag.LookupSymbol.OID != "" {
266 - tagCrossTableOnlyOIDs[tag.Table] = append(tagCrossTableOnlyOIDs[tag.Table], tag.LookupSymbol.OID)
267 - }
268 - }
269 - }
270 -
271 - for tableName, oids := range tagCrossTableOnlyOIDs {
272 - slices.Sort(oids)
273 - oids = slices.Compact(oids)
274 -
275 - prof.Definition.Metrics = append(prof.Definition.Metrics, ddprofiledefinition.MetricsConfig{
276 - MIB: fmt.Sprintf("synthetic-%s-MIB", tableName),
277 - Table: ddprofiledefinition.SymbolConfig{
278 - OID: longestCommonPrefix(oids),
279 - Name: tableName,
280 - },
281 - })
282 - }
283 -}
284 -
285 -func longestCommonPrefix(oids []string) string {
286 - if len(oids) == 0 {
287 - return ""
288 - }
289 -
290 - prefixParts := splitOIDParts(oids[0])
291 - for i := 1; i < len(oids); i++ {
292 - parts := splitOIDParts(oids[i])
293 - n := min(len(parts), len(prefixParts))
294 -
295 - j := 0
296 - for j < n && prefixParts[j] == parts[j] {
297 - j++
298 - }
299 - prefixParts = prefixParts[:j]
300 - if len(prefixParts) == 0 {
301 - return ""
302 - }
303 - }
304 -
305 - return strings.Join(prefixParts, ".")
306 -}
307 -
308 -func splitOIDParts(oid string) []string {
309 - parts := strings.Split(strings.Trim(oid, "."), ".")
310 - if len(parts) == 1 && parts[0] == "" {
311 - return nil
312 - }
313 - return parts
314 -}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_global_tags.go
+11 -9
@@ -54,7 +54,7 @@ func (gc *globalTagsCollector) processStaticTags(staticTags []ddprofiledefinitio
54 }
55
56 // processDynamicTags processes tags that require SNMP fetching
57 -func (gc *globalTagsCollector) processDynamicTags(metricTags []ddprofiledefinition.MetricTagConfig, globalTags map[string]string) error {
57 +func (gc *globalTagsCollector) processDynamicTags(metricTags []ddprofiledefinition.GlobalMetricTagConfig, globalTags map[string]string) error {
58 // Identify OIDs to collect
59 oids, missingOIDs := gc.identifyTagOIDs(metricTags)
60
@@ -74,15 +74,16 @@ func (gc *globalTagsCollector) processDynamicTags(metricTags []ddprofiledefiniti
74 // Collect each tag configuration
75 var errs []error
76 for _, tagCfg := range metricTags {
77 - if tagCfg.Symbol.OID == "" {
77 + cfg := tagCfg.MetricTagConfig
78 + if cfg.Symbol.OID == "" {
79 continue
80 }
81
82 ta := tagAdder{tags: globalTags}
83
83 - if err := gc.tagProc.processTag(tagCfg, pdus, ta); err != nil {
84 + if err := gc.tagProc.processTag(cfg, pdus, ta); err != nil {
85 errs = append(errs, fmt.Errorf("failed to process tag value for %q: %w",
85 - metricTagDisplayName(tagCfg), err))
86 + metricTagDisplayName(cfg), err))
87 continue
88 }
89 }
@@ -94,22 +95,23 @@ func (gc *globalTagsCollector) processDynamicTags(metricTags []ddprofiledefiniti
95 return nil
96 }
97
97 -func (gc *globalTagsCollector) identifyTagOIDs(metricTags []ddprofiledefinition.MetricTagConfig) ([]string, []string) {
98 +func (gc *globalTagsCollector) identifyTagOIDs(metricTags []ddprofiledefinition.GlobalMetricTagConfig) ([]string, []string) {
99 var oids []string
100 var missingOIDs []string
101
102 for _, tagCfg := range metricTags {
102 - if tagCfg.Symbol.OID == "" {
103 + cfg := tagCfg.MetricTagConfig
104 + if cfg.Symbol.OID == "" {
105 continue
106 }
107
106 - oid := trimOID(tagCfg.Symbol.OID)
108 + oid := trimOID(cfg.Symbol.OID)
109 if gc.missingOIDs[oid] {
108 - missingOIDs = append(missingOIDs, tagCfg.Symbol.OID)
110 + missingOIDs = append(missingOIDs, cfg.Symbol.OID)
111 continue
112 }
113
112 - oids = append(oids, tagCfg.Symbol.OID)
114 + oids = append(oids, cfg.Symbol.OID)
115 }
116
117 // Sort and deduplicate
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_global_tags_test.go
+36 -28
@@ -17,6 +17,14 @@ import (
17 "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
18 )
19
20 +func globalMetricTags(tags ...ddprofiledefinition.MetricTagConfig) []ddprofiledefinition.GlobalMetricTagConfig {
21 + globalTags := make([]ddprofiledefinition.GlobalMetricTagConfig, 0, len(tags))
22 + for _, tag := range tags {
23 + globalTags = append(globalTags, ddprofiledefinition.GlobalMetricTagConfig{MetricTagConfig: tag})
24 + }
25 + return globalTags
26 +}
27 +
28 func TestGlobalTagsCollector_Collect(t *testing.T) {
29 tests := map[string]struct {
30 profile *ddsnmp.Profile
@@ -28,7 +36,7 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
36 "no tags configured": {
37 profile: &ddsnmp.Profile{
38 Definition: &ddprofiledefinition.ProfileDefinition{
31 - MetricTags: []ddprofiledefinition.MetricTagConfig{},
39 + MetricTags: globalMetricTags(),
40 StaticTags: []ddprofiledefinition.StaticMetricTagConfig{},
41 },
42 },
@@ -57,22 +65,22 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
65 "dynamic tags only": {
66 profile: &ddsnmp.Profile{
67 Definition: &ddprofiledefinition.ProfileDefinition{
60 - MetricTags: []ddprofiledefinition.MetricTagConfig{
61 - {
68 + MetricTags: globalMetricTags(
69 + ddprofiledefinition.MetricTagConfig{
70 Tag: "device_vendor",
71 Symbol: ddprofiledefinition.SymbolConfigCompat{
72 OID: "1.3.6.1.2.1.1.1.0",
73 Name: "sysDescr",
74 },
75 },
68 - {
76 + ddprofiledefinition.MetricTagConfig{
77 Tag: "location",
78 Symbol: ddprofiledefinition.SymbolConfigCompat{
79 OID: "1.3.6.1.2.1.1.6.0",
80 Name: "sysLocation",
81 },
82 },
75 - },
83 + ),
84 },
85 },
86 setupMock: func(m *snmpmock.MockHandler) {
@@ -110,15 +118,15 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
118 {Tag: "environment", Value: "production"},
119 {Tag: "managed", Value: "true"},
120 },
113 - MetricTags: []ddprofiledefinition.MetricTagConfig{
114 - {
121 + MetricTags: globalMetricTags(
122 + ddprofiledefinition.MetricTagConfig{
123 Tag: "hostname",
124 Symbol: ddprofiledefinition.SymbolConfigCompat{
125 OID: "1.3.6.1.2.1.1.5.0",
126 Name: "sysName",
127 },
128 },
121 - },
129 + ),
130 },
131 },
132 setupMock: func(m *snmpmock.MockHandler) {
@@ -145,8 +153,8 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
153 "tag with mapping": {
154 profile: &ddsnmp.Profile{
155 Definition: &ddprofiledefinition.ProfileDefinition{
148 - MetricTags: []ddprofiledefinition.MetricTagConfig{
149 - {
156 + MetricTags: globalMetricTags(
157 + ddprofiledefinition.MetricTagConfig{
158 Tag: "device_type",
159 Symbol: ddprofiledefinition.SymbolConfigCompat{
160 OID: "1.3.6.1.2.1.1.2.0",
@@ -158,7 +166,7 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
166 "1.3.6.1.4.1.9.1.3": "firewall",
167 }),
168 },
161 - },
169 + ),
170 },
171 },
172 setupMock: func(m *snmpmock.MockHandler) {
@@ -183,8 +191,8 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
191 "tag with pattern matching": {
192 profile: &ddsnmp.Profile{
193 Definition: &ddprofiledefinition.ProfileDefinition{
186 - MetricTags: []ddprofiledefinition.MetricTagConfig{
187 - {
194 + MetricTags: globalMetricTags(
195 + ddprofiledefinition.MetricTagConfig{
196 Symbol: ddprofiledefinition.SymbolConfigCompat{
197 OID: "1.3.6.1.2.1.1.5.0",
198 Name: "sysName",
@@ -196,7 +204,7 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
204 "unit_num": "$3",
205 },
206 },
199 - },
207 + ),
208 },
209 },
210 setupMock: func(m *snmpmock.MockHandler) {
@@ -223,15 +231,15 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
231 "missing OID": {
232 profile: &ddsnmp.Profile{
233 Definition: &ddprofiledefinition.ProfileDefinition{
226 - MetricTags: []ddprofiledefinition.MetricTagConfig{
227 - {
234 + MetricTags: globalMetricTags(
235 + ddprofiledefinition.MetricTagConfig{
236 Tag: "hostname",
237 Symbol: ddprofiledefinition.SymbolConfigCompat{
238 OID: "1.3.6.1.2.1.1.5.0",
239 Name: "sysName",
240 },
241 },
234 - },
242 + ),
243 },
244 },
245 setupMock: func(m *snmpmock.MockHandler) {
@@ -254,15 +262,15 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
262 "SNMP error": {
263 profile: &ddsnmp.Profile{
264 Definition: &ddprofiledefinition.ProfileDefinition{
257 - MetricTags: []ddprofiledefinition.MetricTagConfig{
258 - {
265 + MetricTags: globalMetricTags(
266 + ddprofiledefinition.MetricTagConfig{
267 Tag: "hostname",
268 Symbol: ddprofiledefinition.SymbolConfigCompat{
269 OID: "1.3.6.1.2.1.1.5.0",
270 Name: "sysName",
271 },
272 },
265 - },
273 + ),
274 },
275 },
276 setupMock: func(m *snmpmock.MockHandler) {
@@ -279,15 +287,15 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
287 "empty tag name falls back to symbol name": {
288 profile: &ddsnmp.Profile{
289 Definition: &ddprofiledefinition.ProfileDefinition{
282 - MetricTags: []ddprofiledefinition.MetricTagConfig{
283 - {
290 + MetricTags: globalMetricTags(
291 + ddprofiledefinition.MetricTagConfig{
292 Tag: "", // Empty tag name
293 Symbol: ddprofiledefinition.SymbolConfigCompat{
294 OID: "1.3.6.1.2.1.1.5.0",
295 Name: "sysName",
296 },
297 },
290 - },
298 + ),
299 },
300 },
301 setupMock: func(m *snmpmock.MockHandler) {
@@ -312,29 +320,29 @@ func TestGlobalTagsCollector_Collect(t *testing.T) {
320 "chunked requests": {
321 profile: &ddsnmp.Profile{
322 Definition: &ddprofiledefinition.ProfileDefinition{
315 - MetricTags: []ddprofiledefinition.MetricTagConfig{
316 - {
323 + MetricTags: globalMetricTags(
324 + ddprofiledefinition.MetricTagConfig{
325 Tag: "tag1",
326 Symbol: ddprofiledefinition.SymbolConfigCompat{
327 OID: "1.3.6.1.2.1.1.1.0",
328 Name: "oid1",
329 },
330 },
323 - {
331 + ddprofiledefinition.MetricTagConfig{
332 Tag: "tag2",
333 Symbol: ddprofiledefinition.SymbolConfigCompat{
334 OID: "1.3.6.1.2.1.1.2.0",
335 Name: "oid2",
336 },
337 },
330 - {
338 + ddprofiledefinition.MetricTagConfig{
339 Tag: "tag3",
340 Symbol: ddprofiledefinition.SymbolConfigCompat{
341 OID: "1.3.6.1.2.1.1.3.0",
342 Name: "oid3",
343 },
344 },
337 - },
345 + ),
346 },
347 },
348 setupMock: func(m *snmpmock.MockHandler) {
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table_test.go
+2 -1
@@ -4125,7 +4125,7 @@ func TestTableCollector_Collect(t *testing.T) {
4125
4126 tc.setupMock(mockHandler)
4127
4128 - handleCrossTableTagsWithoutMetrics(tc.profile)
4128 + ddsnmp.FinalizeProfiles([]*ddsnmp.Profile{tc.profile})
4129 if err := ddsnmp.CompileTransforms(tc.profile); err != nil {
4130 if tc.expectedError && tc.errorContains != "" && strings.Contains(err.Error(), tc.errorContains) {
4131 return // Expected error during compilation
@@ -4927,6 +4927,7 @@ func TestCollector_Collect_TableCaching(t *testing.T) {
4927 mockHandler := snmpmock.NewMockHandler(ctrl)
4928 tc.setupMock(mockHandler)
4929
4930 + ddsnmp.FinalizeProfiles(tc.profiles)
4931 collector := New(Config{
4932 SnmpClient: mockHandler,
4933 Profiles: tc.profiles,
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_test.go
+66 -11
@@ -82,7 +82,6 @@ func TestCollector_Collect_StatsSnapshot(t *testing.T) {
82 },
83 }
84
85 - handleCrossTableTagsWithoutMetrics(profile)
85 require.NoError(t, ddsnmp.CompileTransforms(profile))
86
87 collector := New(Config{
@@ -202,7 +201,6 @@ func TestCollector_Collect_PreservesHiddenMetrics(t *testing.T) {
201 },
202 }
203
205 - handleCrossTableTagsWithoutMetrics(profile)
204 require.NoError(t, ddsnmp.CompileTransforms(profile))
205
206 collector := New(Config{
@@ -224,14 +222,71 @@ func TestCollector_Collect_PreservesHiddenMetrics(t *testing.T) {
222 assert.Equal(t, "privateMetric_total", pm.Metrics[0].Name)
223 }
224
227 -func TestLongestCommonPrefix(t *testing.T) {
228 - assert.Equal(t, "1.3.6.1.2.1.31.1.1.1", longestCommonPrefix([]string{
229 - "1.3.6.1.2.1.31.1.1.1.1",
230 - "1.3.6.1.2.1.31.1.1.1.18",
231 - }))
225 +func TestCollector_Collect_SeparatesTopologyMetricsFromHiddenMetrics(t *testing.T) {
226 + ctrl, mockHandler := setupMockHandler(t)
227 + defer ctrl.Finish()
228 +
229 + expectSNMPWalk(mockHandler,
230 + gosnmp.Version2c,
231 + "1.3.6.1.4.1.99999.1",
232 + []gosnmp.SnmpPDU{
233 + createCounter32PDU("1.3.6.1.4.1.99999.1.1.1", 100),
234 + },
235 + )
236 + expectSNMPGet(mockHandler,
237 + []string{"1.3.6.1.4.1.99999.2.0"},
238 + []gosnmp.SnmpPDU{
239 + createIntegerPDU("1.3.6.1.4.1.99999.2.0", 1),
240 + },
241 + )
242
233 - assert.Equal(t, "1.3.6.1.4.1.2636.5.1.1.2.1.1.1", longestCommonPrefix([]string{
234 - "1.3.6.1.4.1.2636.5.1.1.2.1.1.1.11",
235 - "1.3.6.1.4.1.2636.5.1.1.2.1.1.1.14",
236 - }))
243 + profile := &ddsnmp.Profile{
244 + SourceFile: "topology-delivery-profile.yaml",
245 + Definition: &ddprofiledefinition.ProfileDefinition{
246 + Metrics: []ddprofiledefinition.MetricsConfig{
247 + {
248 + Table: ddprofiledefinition.SymbolConfig{
249 + OID: "1.3.6.1.4.1.99999.1",
250 + Name: "privateTable",
251 + },
252 + Symbols: []ddprofiledefinition.SymbolConfig{
253 + {
254 + OID: "1.3.6.1.4.1.99999.1.1",
255 + Name: "_privateMetric",
256 + },
257 + },
258 + },
259 + },
260 + Topology: []ddprofiledefinition.TopologyConfig{
261 + {
262 + Kind: ddprofiledefinition.KindIfStatus,
263 + MetricsConfig: ddprofiledefinition.MetricsConfig{
264 + Symbol: ddprofiledefinition.SymbolConfig{
265 + OID: "1.3.6.1.4.1.99999.2.0",
266 + Name: "if_status",
267 + },
268 + },
269 + },
270 + },
271 + },
272 + }
273 +
274 + collector := New(Config{
275 + SnmpClient: mockHandler,
276 + Profiles: []*ddsnmp.Profile{profile},
277 + Log: logger.New(),
278 + SysObjectID: "",
279 + })
280 +
281 + results, err := collector.Collect()
282 + require.NoError(t, err)
283 + require.Len(t, results, 1)
284 +
285 + pm := results[0]
286 + require.Len(t, pm.HiddenMetrics, 1)
287 + assert.Equal(t, "_privateMetric", pm.HiddenMetrics[0].Name)
288 + require.Len(t, pm.TopologyMetrics, 1)
289 + assert.Equal(t, "if_status", pm.TopologyMetrics[0].Name)
290 + assert.Equal(t, ddsnmp.KindIfStatus, pm.TopologyMetrics[0].TopologyKind)
291 + require.Empty(t, pm.Metrics)
292 }
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_topology.go new
+64
@@ -0,0 +1,64 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
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 +type topologyMetricLookupKey struct {
11 + table string
12 + name string
13 +}
14 +
15 +func (c *Collector) collectTopologyMetrics(prof *ddsnmp.Profile, stats *ddsnmp.CollectionStats) ([]ddsnmp.Metric, error) {
16 + if prof.Definition == nil || len(prof.Definition.Topology) == 0 {
17 + return nil, nil
18 + }
19 +
20 + metricsConfig, kinds := topologyRowsAsMetrics(prof.Definition.Topology)
21 + topologyProfile := &ddsnmp.Profile{
22 + SourceFile: prof.SourceFile,
23 + Definition: &ddprofiledefinition.ProfileDefinition{
24 + Metrics: metricsConfig,
25 + },
26 + }
27 +
28 + scalarMetrics, err := c.scalarCollector.collect(topologyProfile, stats)
29 + if err != nil {
30 + return nil, err
31 + }
32 + tableMetrics, err := c.tableCollector.collect(topologyProfile, stats)
33 + if err != nil {
34 + return nil, err
35 + }
36 +
37 + topologyMetrics := append(scalarMetrics, tableMetrics...)
38 + for i := range topologyMetrics {
39 + key := topologyMetricLookupKey{table: topologyMetrics[i].Table, name: topologyMetrics[i].Name}
40 + topologyMetrics[i].TopologyKind = kinds[key]
41 + }
42 +
43 + return topologyMetrics, nil
44 +}
45 +
46 +func topologyRowsAsMetrics(topology []ddprofiledefinition.TopologyConfig) ([]ddprofiledefinition.MetricsConfig, map[topologyMetricLookupKey]ddprofiledefinition.TopologyKind) {
47 + metrics := make([]ddprofiledefinition.MetricsConfig, 0, len(topology))
48 + kinds := make(map[topologyMetricLookupKey]ddprofiledefinition.TopologyKind)
49 +
50 + for _, topo := range topology {
51 + cfg := topo.MetricsConfig
52 + metrics = append(metrics, cfg)
53 + switch {
54 + case cfg.IsScalar():
55 + kinds[topologyMetricLookupKey{name: cfg.Symbol.Name}] = topo.Kind
56 + case cfg.IsColumn():
57 + for _, sym := range cfg.Symbols {
58 + kinds[topologyMetricLookupKey{table: cfg.Table.Name, name: sym.Name}] = topo.Kind
59 + }
60 + }
61 + }
62 +
63 + return metrics, kinds
64 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/topology_profile_index_test.go
+47 -38
@@ -13,14 +13,15 @@ import (
13 )
14
15 func TestTopologyProfile_QBridgeFDBUsesMACFromIndex(t *testing.T) {
16 - for _, tc := range []struct {
17 - name string
16 + tests := map[string]struct {
17 indexSuffix string
18 }{
20 - {name: "normal_mac_index", indexSuffix: "7.0.80.86.171.205.239"},
21 - {name: "length_prefixed_mac_index", indexSuffix: "7.6.0.80.86.171.205.239"},
22 - } {
23 - t.Run(tc.name, func(t *testing.T) {
19 + "normal_mac_index": {indexSuffix: "7.0.80.86.171.205.239"},
20 + "length_prefixed_mac_index": {indexSuffix: "7.6.0.80.86.171.205.239"},
21 + }
22 +
23 + for name, tc := range tests {
24 + t.Run(name, func(t *testing.T) {
25 ctrl, mockHandler := setupMockHandler(t)
26 defer ctrl.Finish()
27
@@ -59,20 +60,22 @@ func TestTopologyProfile_IPNetToPhysicalUsesIndexFields(t *testing.T) {
60
61 assertTableMetricsEqual(t, []ddsnmp.Metric{
62 {
62 - Name: "_topology_arp_entry",
63 - Value: 1,
64 - Tags: map[string]string{"arp_if_index": "2", "arp_addr_type": "ipv4", "arp_ip": "10.0.2.10", "arp_mac": "005056abcdef", "arp_state": "1"},
65 - MetricType: "gauge",
66 - IsTable: true,
67 - Table: "ipNetToPhysicalTable",
63 + Name: "arp_entry",
64 + Value: 1,
65 + Tags: map[string]string{"arp_if_index": "2", "arp_addr_type": "ipv4", "arp_ip": "10.0.2.10", "arp_mac": "005056abcdef", "arp_state": "1"},
66 + MetricType: "gauge",
67 + IsTable: true,
68 + Table: "ipNetToPhysicalTable",
69 + TopologyKind: ddsnmp.KindArpEntry,
70 },
71 {
70 - Name: "_topology_arp_entry",
71 - Value: 2,
72 - Tags: map[string]string{"arp_if_index": "3", "arp_addr_type": "ipv6", "arp_ip": "fe80::1", "arp_mac": "005056abcdf0", "arp_state": "2"},
73 - MetricType: "gauge",
74 - IsTable: true,
75 - Table: "ipNetToPhysicalTable",
72 + Name: "arp_entry",
73 + Value: 2,
74 + Tags: map[string]string{"arp_if_index": "3", "arp_addr_type": "ipv6", "arp_ip": "fe80::1", "arp_mac": "005056abcdf0", "arp_state": "2"},
75 + MetricType: "gauge",
76 + IsTable: true,
77 + Table: "ipNetToPhysicalTable",
78 + TopologyKind: ddsnmp.KindArpEntry,
79 },
80 }, actual)
81 }
@@ -99,20 +102,22 @@ func TestTopologyProfile_LLDPManagementAddressUsesIndexFields(t *testing.T) {
102
103 assertTableMetricsEqual(t, []ddsnmp.Metric{
104 {
102 - Name: "_topology_lldp_loc_man_addr_entry",
103 - Value: 4,
104 - Tags: map[string]string{"lldp_loc_mgmt_addr_subtype": "1", "lldp_loc_mgmt_addr": "0a000001", "lldp_loc_mgmt_addr_if_subtype": "2", "lldp_loc_mgmt_addr_if_id": "12", "lldp_loc_mgmt_addr_oid": "0.0"},
105 - MetricType: "gauge",
106 - IsTable: true,
107 - Table: "lldpLocManAddrTable",
105 + Name: "lldp_loc_man_addr",
106 + Value: 4,
107 + Tags: map[string]string{"lldp_loc_mgmt_addr_subtype": "1", "lldp_loc_mgmt_addr": "0a000001", "lldp_loc_mgmt_addr_if_subtype": "2", "lldp_loc_mgmt_addr_if_id": "12", "lldp_loc_mgmt_addr_oid": "0.0"},
108 + MetricType: "gauge",
109 + IsTable: true,
110 + Table: "lldpLocManAddrTable",
111 + TopologyKind: ddsnmp.KindLldpLocManAddr,
112 },
113 {
110 - Name: "_topology_lldp_loc_man_addr_entry",
111 - Value: 6,
112 - Tags: map[string]string{"lldp_loc_mgmt_addr_subtype": "6", "lldp_loc_mgmt_addr": "005056abcdef", "lldp_loc_mgmt_addr_if_subtype": "2", "lldp_loc_mgmt_addr_if_id": "12", "lldp_loc_mgmt_addr_oid": "0.0"},
113 - MetricType: "gauge",
114 - IsTable: true,
115 - Table: "lldpLocManAddrTable",
114 + Name: "lldp_loc_man_addr",
115 + Value: 6,
116 + Tags: map[string]string{"lldp_loc_mgmt_addr_subtype": "6", "lldp_loc_mgmt_addr": "005056abcdef", "lldp_loc_mgmt_addr_if_subtype": "2", "lldp_loc_mgmt_addr_if_id": "12", "lldp_loc_mgmt_addr_oid": "0.0"},
117 + MetricType: "gauge",
118 + IsTable: true,
119 + Table: "lldpLocManAddrTable",
120 + TopologyKind: ddsnmp.KindLldpLocManAddr,
121 },
122 }, actual)
123 }
@@ -125,10 +130,13 @@ func collectTopologyProfileTables(t *testing.T, mockHandler gosnmp.Handler, prof
130
131 missingOIDs := make(map[string]bool)
132 tcache := newTableCache(0, 0)
128 - collector := newTableCollector(mockHandler, missingOIDs, tcache, logger.New(), false)
133 + collector := &Collector{
134 + scalarCollector: newScalarCollector(mockHandler, missingOIDs, logger.New()),
135 + tableCollector: newTableCollector(mockHandler, missingOIDs, tcache, logger.New(), false),
136 + }
137
138 var stats ddsnmp.CollectionStats
131 - actual, err := collector.collect(profile, &stats)
139 + actual, err := collector.collectTopologyMetrics(profile, &stats)
140 require.NoError(t, err)
141
142 return actual
@@ -136,11 +144,12 @@ func collectTopologyProfileTables(t *testing.T, mockHandler gosnmp.Handler, prof
144
145 func qBridgeFDBMetric() ddsnmp.Metric {
146 return ddsnmp.Metric{
139 - Name: "_topology_qbridge_fdb_entry",
140 - Value: 5,
141 - Tags: map[string]string{"dot1q_fdb_id": "7", "dot1q_fdb_mac": "00:50:56:ab:cd:ef", "dot1q_fdb_bridge_port": "5", "dot1q_fdb_status": "3"},
142 - MetricType: "gauge",
143 - IsTable: true,
144 - Table: "dot1qTpFdbTable",
147 + Name: "qbridge_fdb_entry",
148 + Value: 5,
149 + Tags: map[string]string{"dot1q_fdb_id": "7", "dot1q_fdb_mac": "00:50:56:ab:cd:ef", "dot1q_fdb_bridge_port": "5", "dot1q_fdb_status": "3"},
150 + MetricType: "gauge",
151 + IsTable: true,
152 + Table: "dot1qTpFdbTable",
153 + TopologyKind: ddsnmp.KindQbridgeFdbEntry,
154 }
155 }
src/go/plugin/go.d/collector/snmp/ddsnmp/load.go
+20 -14
@@ -74,7 +74,8 @@ func loadProfiles() {
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.
77 +// This supports loading abstract profiles (e.g., "_std-*.yaml") for tests and
78 +// programmatic profile checks that intentionally bypass selector matching.
79 func LoadProfileByName(name string) (*Profile, error) {
80 paths := getProfilesDirs()
81
@@ -96,13 +97,9 @@ func LoadProfileByName(name string) (*Profile, error) {
97 return nil, err
98 }
99
99 - if err := profile.validate(); err != nil {
100 + if err := prepareLoadedProfile(profile); err != nil {
101 return nil, err
102 }
102 - if err := CompileTransforms(profile); err != nil {
103 - return nil, err
104 - }
105 - profile.removeConstantMetrics()
103
104 return profile, nil
105 }
@@ -134,17 +131,11 @@ func loadProfilesFromDir(dirpath string, extendsPaths multipath.MultiPath) ([]*P
131 return nil
132 }
133
137 - if err := profile.validate(); err != nil {
138 - log.Warningf("invalid profile '%s': %v", path, err)
139 - return nil
140 - }
141 - if err := CompileTransforms(profile); err != nil {
134 + if err := prepareLoadedProfile(profile); err != nil {
135 log.Warningf("invalid profile '%s': %v", path, err)
136 return nil
137 }
138
146 - profile.removeConstantMetrics()
147 -
139 profiles = append(profiles, profile)
140 return nil
141 }); err != nil {
@@ -211,12 +202,27 @@ func loadProfileWithExtendsMap(filename string, extendsPaths multipath.MultiPath
202 // Merge in reverse so later extends override earlier ones while the
203 // current profile still keeps the highest precedence.
204 for i := len(mergedBases) - 1; i >= 0; i-- {
214 - prof.merge(mergedBases[i])
205 + if err := prof.merge(mergedBases[i]); err != nil {
206 + return nil, err
207 + }
208 }
209
210 return &prof, nil
211 }
212
213 +func prepareLoadedProfile(profile *Profile) error {
214 + if err := profile.validate(); err != nil {
215 + return err
216 + }
217 + if err := CompileTransforms(profile); err != nil {
218 + return err
219 + }
220 + profile.removeConstantMetrics()
221 + enrichProfile(profile)
222 + handleCrossTableTagsWithoutMetrics(profile)
223 + return nil
224 +}
225 +
226 func getProfilesDirs() multipath.MultiPath {
227 if executable.Name == "test" {
228 return multipath.New(snmpProfilesDirFromThisFile())
src/go/plugin/go.d/collector/snmp/ddsnmp/metric.go
+9 -7
@@ -7,12 +7,13 @@ import (
7 )
8
9 type ProfileMetrics struct {
10 - Source string
11 - DeviceMetadata map[string]MetaTag
12 - Tags map[string]string
13 - Metrics []Metric
14 - HiddenMetrics []Metric
15 - Stats CollectionStats
10 + Source string
11 + DeviceMetadata map[string]MetaTag
12 + Tags map[string]string
13 + Metrics []Metric
14 + TopologyMetrics []Metric
15 + HiddenMetrics []Metric
16 + Stats CollectionStats
17 }
18
19 type Metric struct {
@@ -29,7 +30,8 @@ type Metric struct {
30 Value int64
31 MultiValue map[string]int64
32
32 - IsTable bool
33 + TopologyKind ddprofiledefinition.TopologyKind
34 + IsTable bool
35 }
36
37 type MetaTag struct {
src/go/plugin/go.d/collector/snmp/ddsnmp/profile.go
+222 -65
@@ -22,61 +22,49 @@ type columnMetricKey struct {
22 symbolName string
23 }
24
25 -// FindProfiles returns profiles matching the given sysObjectID.
26 -// Profiles are sorted by match specificity: most specific first.
27 -func FindProfiles(sysObjID, sysDescr string, manualProfiles []string) []*Profile {
28 - loadProfiles()
29 -
30 - finalize := func(profiles []*Profile) []*Profile {
31 - if len(profiles) == 0 {
32 - return nil
33 - }
34 - enrichProfiles(profiles)
35 - deduplicateMetricsAcrossProfiles(profiles)
36 - return profiles
37 - }
38 -
39 - // Fallback/manual path (no sysObjectID)
40 - if sysObjID == "" {
41 - if len(manualProfiles) == 0 {
42 - log.Warning("No sysObjectID found and no manual_profiles configured. Either ensure the device provides sysObjectID or configure manual_profiles option.")
43 - return nil
44 - }
45 -
46 - var selected []*Profile
47 - for _, prof := range ddProfiles {
48 - name := stripFileNameExt(prof.SourceFile)
49 - if slices.ContainsFunc(manualProfiles, func(p string) bool { return stripFileNameExt(p) == name }) {
50 - selected = append(selected, prof.clone())
51 - }
52 - }
25 +type topologyScalarMetricKey struct {
26 + kind ddprofiledefinition.TopologyKind
27 + name string
28 + oid string
29 +}
30
54 - return finalize(selected)
55 - }
31 +type topologyColumnMetricKey struct {
32 + kind ddprofiledefinition.TopologyKind
33 + table string
34 + symbolName string
35 +}
36
57 - // Auto-detect path
58 - matchedOIDs := make(map[*Profile]string)
59 - var selected []*Profile
37 +type topologyScalarConflictKey struct {
38 + name string
39 + oid string
40 +}
41
61 - for _, prof := range ddProfiles {
62 - if ok, matchedOid := prof.Definition.Selector.Matches(sysObjID, sysDescr); ok {
63 - cloned := prof.clone()
64 - selected = append(selected, cloned)
65 - matchedOIDs[cloned] = matchedOid
66 - }
67 - }
42 +type topologyColumnConflictKey struct {
43 + table string
44 + symbolName string
45 +}
46
69 - sortProfilesBySpecificity(selected, matchedOIDs)
70 - return finalize(selected)
47 +// FindProfiles returns profiles matching the given sysObjectID.
48 +// Profiles are sorted by match specificity: most specific first.
49 +func FindProfiles(sysObjID, sysDescr string, manualProfiles []string) []*Profile {
50 + return DefaultCatalog().Resolve(ResolveRequest{
51 + SysObjectID: sysObjID,
52 + SysDescr: sysDescr,
53 + ManualProfiles: manualProfiles,
54 + ManualPolicy: ManualProfileFallback,
55 + }).Profiles()
56 }
57
73 -// FinalizeProfiles enriches and deduplicates metrics for a given profile list.
74 -// This mirrors the post-processing performed by FindProfiles.
58 +// FinalizeProfiles applies load-time profile preparation and deduplicates metrics for a
59 +// given profile list. This mirrors the post-processing performed by FindProfiles.
60 func FinalizeProfiles(profiles []*Profile) []*Profile {
61 if len(profiles) == 0 {
62 return nil
63 }
79 - enrichProfiles(profiles)
64 + for _, prof := range profiles {
65 + enrichProfile(prof)
66 + handleCrossTableTagsWithoutMetrics(prof)
67 + }
68 deduplicateMetricsAcrossProfiles(profiles)
69 return profiles
70 }
@@ -183,12 +171,16 @@ func cloneExtensionHierarchy(extensions []*extensionInfo) []*extensionInfo {
171 return cloned
172 }
173
186 -func (p *Profile) merge(base *Profile) {
174 +func (p *Profile) merge(base *Profile) error {
175 p.mergeMetadata(base)
176 p.mergeMetrics(base)
177 + if err := p.mergeTopology(base); err != nil {
178 + return err
179 + }
180 // Append other fields as before (these likely don't need deduplication)
181 p.Definition.MetricTags = append(p.Definition.MetricTags, base.Definition.MetricTags...)
182 p.Definition.StaticTags = append(slices.Clone(base.Definition.StaticTags), p.Definition.StaticTags...)
183 + return nil
184 }
185
186 func (p *Profile) mergeMetrics(base *Profile) {
@@ -265,6 +257,109 @@ func columnMetricTableIdentity(table ddprofiledefinition.SymbolConfig) string {
257 return table.OID
258 }
259
260 +func (p *Profile) mergeTopology(base *Profile) error {
261 + seenScalars := make(map[topologyScalarMetricKey]bool)
262 + seenColumns := make(map[topologyColumnMetricKey]bool)
263 + seenTableOIDs := make(map[string]string)
264 + scalarKinds := make(map[topologyScalarConflictKey]ddprofiledefinition.TopologyKind)
265 + columnKinds := make(map[topologyColumnConflictKey]ddprofiledefinition.TopologyKind)
266 +
267 + for _, topo := range p.Definition.Topology {
268 + if err := indexTopologyMergeConflicts(topo, scalarKinds, columnKinds); err != nil {
269 + return err
270 + }
271 + switch {
272 + case topo.IsScalar():
273 + seenScalars[topologyScalarMetricKey{kind: topo.Kind, name: topo.Symbol.Name, oid: topo.Symbol.OID}] = true
274 + case topo.IsColumn():
275 + seenTableOIDs[topologyColumnTableIdentity(topo.Kind, topo.Table)] = topo.Table.OID
276 + for _, sym := range topo.Symbols {
277 + seenColumns[topologyColumnSymbolKey(topo.Kind, topo.Table, sym)] = true
278 + }
279 + }
280 + }
281 +
282 + for _, baseTopo := range base.Definition.Topology {
283 + if err := indexTopologyMergeConflicts(baseTopo, scalarKinds, columnKinds); err != nil {
284 + return err
285 + }
286 + switch {
287 + case baseTopo.IsScalar():
288 + key := topologyScalarMetricKey{kind: baseTopo.Kind, name: baseTopo.Symbol.Name, oid: baseTopo.Symbol.OID}
289 + if !seenScalars[key] {
290 + p.Definition.Topology = append(p.Definition.Topology, baseTopo)
291 + seenScalars[key] = true
292 + }
293 + case baseTopo.IsColumn():
294 + tableID := topologyColumnTableIdentity(baseTopo.Kind, baseTopo.Table)
295 + if tableOID, ok := seenTableOIDs[tableID]; ok && tableOID != baseTopo.Table.OID {
296 + continue
297 + }
298 +
299 + symbols := make([]ddprofiledefinition.SymbolConfig, 0, len(baseTopo.Symbols))
300 + for _, sym := range baseTopo.Symbols {
301 + key := topologyColumnSymbolKey(baseTopo.Kind, baseTopo.Table, sym)
302 + if seenColumns[key] {
303 + continue
304 + }
305 + symbols = append(symbols, sym)
306 + }
307 + baseTopo.Symbols = symbols
308 + if len(baseTopo.Symbols) > 0 {
309 + p.Definition.Topology = append(p.Definition.Topology, baseTopo)
310 + seenTableOIDs[tableID] = baseTopo.Table.OID
311 + }
312 + }
313 + }
314 +
315 + return nil
316 +}
317 +
318 +func indexTopologyMergeConflicts(
319 + topo ddprofiledefinition.TopologyConfig,
320 + scalarKinds map[topologyScalarConflictKey]ddprofiledefinition.TopologyKind,
321 + columnKinds map[topologyColumnConflictKey]ddprofiledefinition.TopologyKind,
322 +) error {
323 + switch {
324 + case topo.IsScalar():
325 + key := topologyScalarConflictKey{name: topo.Symbol.Name, oid: topo.Symbol.OID}
326 + return indexTopologyKindConflict(fmt.Sprintf("scalar %q/%q", topo.Symbol.Name, topo.Symbol.OID), key, topo.Kind, scalarKinds)
327 + case topo.IsColumn():
328 + for _, sym := range topo.Symbols {
329 + key := topologyColumnConflictKey{table: columnMetricTableIdentity(topo.Table), symbolName: sym.Name}
330 + if err := indexTopologyKindConflict(fmt.Sprintf("table %q symbol %q", columnMetricTableIdentity(topo.Table), sym.Name), key, topo.Kind, columnKinds); err != nil {
331 + return err
332 + }
333 + }
334 + }
335 + return nil
336 +}
337 +
338 +func indexTopologyKindConflict[K comparable](
339 + label string,
340 + key K,
341 + kind ddprofiledefinition.TopologyKind,
342 + seen map[K]ddprofiledefinition.TopologyKind,
343 +) error {
344 + if existingKind, ok := seen[key]; ok && existingKind != kind {
345 + return fmt.Errorf("conflicting topology kinds for %s: %q and %q", label, existingKind, kind)
346 + }
347 + seen[key] = kind
348 + return nil
349 +}
350 +
351 +func topologyColumnSymbolKey(kind ddprofiledefinition.TopologyKind, table ddprofiledefinition.SymbolConfig, sym ddprofiledefinition.SymbolConfig) topologyColumnMetricKey {
352 + return topologyColumnMetricKey{
353 + kind: kind,
354 + table: columnMetricTableIdentity(table),
355 + symbolName: sym.Name,
356 + }
357 +}
358 +
359 +func topologyColumnTableIdentity(kind ddprofiledefinition.TopologyKind, table ddprofiledefinition.SymbolConfig) string {
360 + return string(kind) + "|" + columnMetricTableIdentity(table)
361 +}
362 +
363 func (p *Profile) mergeMetadata(base *Profile) {
364 if p.Definition.Metadata == nil {
365 p.Definition.Metadata = make(ddprofiledefinition.MetadataConfig)
@@ -372,29 +467,32 @@ func sortProfilesBySpecificity(profiles []*Profile, matchedOIDs map[*Profile]str
467 })
468 }
469
375 -func enrichProfiles(profiles []*Profile) {
376 - for _, prof := range profiles {
377 - if prof.Definition == nil {
378 - continue
379 - }
470 +func enrichProfile(prof *Profile) {
471 + if prof.Definition == nil {
472 + return
473 + }
474
381 - for i := range prof.Definition.Metrics {
382 - metric := &prof.Definition.Metrics[i]
475 + for i := range prof.Definition.Metrics {
476 + enrichMetricTagMappingRefs(prof.Definition.Metrics[i].MetricTags)
477 + }
478 + for i := range prof.Definition.Topology {
479 + enrichMetricTagMappingRefs(prof.Definition.Topology[i].MetricTags)
480 + }
481 +}
482
384 - for j := range metric.MetricTags {
385 - tagCfg := &metric.MetricTags[j]
483 +func enrichMetricTagMappingRefs(tags ddprofiledefinition.MetricTagConfigList) {
484 + for j := range tags {
485 + tagCfg := &tags[j]
486
387 - if tagCfg.Mapping.HasItems() {
388 - continue
389 - }
487 + if tagCfg.Mapping.HasItems() {
488 + continue
489 + }
490
391 - switch tagCfg.MappingRef {
392 - case "ifType":
393 - tagCfg.Mapping = ddprofiledefinition.NewExactMapping(sharedMappings.ifType)
394 - case "ifTypeGroup":
395 - tagCfg.Mapping = ddprofiledefinition.NewExactMapping(sharedMappings.ifTypeGroup)
396 - }
397 - }
491 + switch tagCfg.MappingRef {
492 + case "ifType":
493 + tagCfg.Mapping = ddprofiledefinition.NewExactMapping(sharedMappings.ifType)
494 + case "ifTypeGroup":
495 + tagCfg.Mapping = ddprofiledefinition.NewExactMapping(sharedMappings.ifTypeGroup)
496 }
497 }
498 }
@@ -436,7 +534,66 @@ func deduplicateMetricsAcrossProfiles(profiles []*Profile) {
534 return false
535 },
536 )
537 +
538 + deduplicateTopologyInProfile(prof, seenMetrics)
539 + }
540 +}
541 +
542 +func deduplicateTopologyInProfile(prof *Profile, seenMetrics map[string]bool) {
543 + filtered := prof.Definition.Topology[:0]
544 + for _, topo := range prof.Definition.Topology {
545 + if topo.IsScalar() {
546 + key := generateTopologyScalarMetricKey(topo)
547 + if seenMetrics[key] {
548 + continue
549 + }
550 + seenMetrics[key] = true
551 + filtered = append(filtered, topo)
552 + continue
553 + }
554 +
555 + if topo.IsColumn() {
556 + symbols := topo.Symbols[:0]
557 + for _, sym := range topo.Symbols {
558 + key := generateTopologyColumnMetricKey(topo, sym)
559 + if seenMetrics[key] {
560 + continue
561 + }
562 + seenMetrics[key] = true
563 + symbols = append(symbols, sym)
564 + }
565 + topo.Symbols = symbols
566 + if len(topo.Symbols) == 0 {
567 + continue
568 + }
569 + }
570 +
571 + filtered = append(filtered, topo)
572 + }
573 + if len(filtered) == 0 {
574 + prof.Definition.Topology = nil
575 + return
576 }
577 + prof.Definition.Topology = filtered
578 +}
579 +
580 +func generateTopologyScalarMetricKey(topo ddprofiledefinition.TopologyConfig) string {
581 + return strings.Join([]string{
582 + "topology-scalar",
583 + string(topo.Kind),
584 + topo.Symbol.OID,
585 + topo.Symbol.Name,
586 + }, "|")
587 +}
588 +
589 +func generateTopologyColumnMetricKey(topo ddprofiledefinition.TopologyConfig, sym ddprofiledefinition.SymbolConfig) string {
590 + return strings.Join([]string{
591 + "topology-table",
592 + string(topo.Kind),
593 + topo.Table.OID,
594 + columnMetricTableIdentity(topo.Table),
595 + sym.Name,
596 + }, "|")
597 }
598
599 func generateMetricKey(metric ddprofiledefinition.MetricsConfig) string {
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_catalog.go new
+291
@@ -0,0 +1,291 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmp
4 +
5 +import (
6 + "slices"
7 +
8 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
9 +)
10 +
11 +type ProfileConsumer = ddprofiledefinition.ProfileConsumer
12 +
13 +const (
14 + ConsumerMetrics = ddprofiledefinition.ConsumerMetrics
15 + ConsumerTopology = ddprofiledefinition.ConsumerTopology
16 +)
17 +
18 +type ManualProfilePolicy int
19 +
20 +const (
21 + ManualProfileFallback ManualProfilePolicy = iota
22 + ManualProfileAugment
23 + ManualProfileOverride
24 +)
25 +
26 +type Catalog struct {
27 + profiles []*Profile
28 +}
29 +
30 +type ResolveRequest struct {
31 + SysObjectID string
32 + SysDescr string
33 + ManualProfiles []string
34 + ManualPolicy ManualProfilePolicy
35 +}
36 +
37 +type ResolvedProfileSet struct {
38 + profiles []*Profile
39 +}
40 +
41 +type ProjectedView struct {
42 + profiles []*Profile
43 +}
44 +
45 +func DefaultCatalog() *Catalog {
46 + return &Catalog{}
47 +}
48 +
49 +func (c *Catalog) Resolve(req ResolveRequest) *ResolvedProfileSet {
50 + available := c.catalogProfiles()
51 +
52 + switch {
53 + case req.ManualPolicy == ManualProfileOverride:
54 + return &ResolvedProfileSet{profiles: finalizeResolvedProfiles(selectManualProfiles(available, req.ManualProfiles))}
55 + case req.SysObjectID == "":
56 + if len(req.ManualProfiles) == 0 {
57 + log.Warning("No sysObjectID found and no manual_profiles configured. Either ensure the device provides sysObjectID or configure manual_profiles option.")
58 + return &ResolvedProfileSet{}
59 + }
60 + return &ResolvedProfileSet{profiles: finalizeResolvedProfiles(selectManualProfiles(available, req.ManualProfiles))}
61 + default:
62 + profiles := selectMatchedProfiles(available, req.SysObjectID, req.SysDescr)
63 + if req.ManualPolicy == ManualProfileAugment {
64 + profiles = appendMissingManualProfiles(profiles, req.ManualProfiles, available)
65 + }
66 + return &ResolvedProfileSet{profiles: finalizeResolvedProfiles(profiles)}
67 + }
68 +}
69 +
70 +func (c *Catalog) catalogProfiles() []*Profile {
71 + if c != nil && c.profiles != nil {
72 + return c.profiles
73 + }
74 + loadProfiles()
75 + return ddProfiles
76 +}
77 +
78 +func (r *ResolvedProfileSet) Profiles() []*Profile {
79 + if r == nil {
80 + return nil
81 + }
82 + return r.profiles
83 +}
84 +
85 +func (r *ResolvedProfileSet) Project(consumer ProfileConsumer) ProjectedView {
86 + if r == nil || len(r.profiles) == 0 {
87 + return ProjectedView{}
88 + }
89 +
90 + profiles := make([]*Profile, 0, len(r.profiles))
91 + for _, prof := range r.profiles {
92 + projected := prof.clone()
93 + projectProfile(projected, consumer)
94 + if profileHasProjectedData(projected.Definition, consumer) {
95 + profiles = append(profiles, projected)
96 + }
97 + }
98 + return ProjectedView{profiles: profiles}
99 +}
100 +
101 +func (v ProjectedView) Profiles() []*Profile {
102 + return v.profiles
103 +}
104 +
105 +func (v ProjectedView) FilterByKind(kinds map[ddprofiledefinition.TopologyKind]bool) ProjectedView {
106 + for _, prof := range v.profiles {
107 + if prof == nil || prof.Definition == nil {
108 + continue
109 + }
110 + prof.Definition.Metrics = nil
111 + prof.Definition.Topology = slices.DeleteFunc(prof.Definition.Topology, func(topo ddprofiledefinition.TopologyConfig) bool {
112 + return !kinds[topo.Kind]
113 + })
114 + }
115 + return ProjectedView{profiles: slices.DeleteFunc(v.profiles, func(prof *Profile) bool {
116 + return prof == nil || prof.Definition == nil || (len(prof.Definition.Topology) == 0 && len(prof.Definition.Metrics) == 0)
117 + })}
118 +}
119 +
120 +func selectMatchedProfiles(available []*Profile, sysObjID, sysDescr string) []*Profile {
121 + matchedOIDs := make(map[*Profile]string)
122 + var selected []*Profile
123 + for _, prof := range available {
124 + if ok, matchedOid := prof.Definition.Selector.Matches(sysObjID, sysDescr); ok {
125 + cloned := prof.clone()
126 + selected = append(selected, cloned)
127 + matchedOIDs[cloned] = matchedOid
128 + }
129 + }
130 + sortProfilesBySpecificity(selected, matchedOIDs)
131 + return selected
132 +}
133 +
134 +func selectManualProfiles(available []*Profile, manualProfiles []string) []*Profile {
135 + var selected []*Profile
136 + for _, prof := range available {
137 + name := stripFileNameExt(prof.SourceFile)
138 + if slices.ContainsFunc(manualProfiles, func(p string) bool { return stripFileNameExt(p) == name }) {
139 + selected = append(selected, prof.clone())
140 + }
141 + }
142 + return selected
143 +}
144 +
145 +func appendMissingManualProfiles(profiles []*Profile, manualProfiles []string, available []*Profile) []*Profile {
146 + seen := make(map[string]bool)
147 + for _, prof := range profiles {
148 + seen[stripFileNameExt(prof.SourceFile)] = true
149 + }
150 + for _, prof := range selectManualProfiles(available, manualProfiles) {
151 + name := stripFileNameExt(prof.SourceFile)
152 + if !seen[name] {
153 + profiles = append(profiles, prof)
154 + seen[name] = true
155 + }
156 + }
157 + return profiles
158 +}
159 +
160 +func finalizeResolvedProfiles(profiles []*Profile) []*Profile {
161 + if len(profiles) == 0 {
162 + return nil
163 + }
164 + deduplicateMetricsAcrossProfiles(profiles)
165 + return profiles
166 +}
167 +
168 +func projectProfile(prof *Profile, consumer ProfileConsumer) {
169 + if prof == nil || prof.Definition == nil {
170 + return
171 + }
172 +
173 + def := prof.Definition
174 + def.Metadata = projectMetadata(def.Metadata, consumer)
175 + def.SysobjectIDMetadata = projectSysobjectIDMetadata(def.SysobjectIDMetadata, consumer)
176 + def.MetricTags = projectGlobalMetricTags(def.MetricTags, consumer)
177 +
178 + switch consumer {
179 + case ConsumerMetrics:
180 + def.Topology = nil
181 + case ConsumerTopology:
182 + def.Metrics = nil
183 + def.VirtualMetrics = nil
184 + default:
185 + def.Metrics = nil
186 + def.Topology = nil
187 + def.VirtualMetrics = nil
188 + def.Metadata = nil
189 + def.SysobjectIDMetadata = nil
190 + def.MetricTags = nil
191 + }
192 +}
193 +
194 +func projectMetadata(meta ddprofiledefinition.MetadataConfig, consumer ProfileConsumer) ddprofiledefinition.MetadataConfig {
195 + if len(meta) == 0 {
196 + return nil
197 + }
198 + projected := make(ddprofiledefinition.MetadataConfig)
199 + for resName, res := range meta {
200 + fields := make(map[string]ddprofiledefinition.MetadataField)
201 + for name, field := range res.Fields {
202 + if consumersInclude(field.Consumers, consumer) {
203 + fields[name] = field
204 + }
205 + }
206 + idTags := projectMetricTagList(res.IDTags, consumer)
207 + if len(fields) == 0 && len(idTags) == 0 {
208 + continue
209 + }
210 + projected[resName] = ddprofiledefinition.MetadataResourceConfig{
211 + Fields: fields,
212 + IDTags: idTags,
213 + }
214 + }
215 + if len(projected) == 0 {
216 + return nil
217 + }
218 + return projected
219 +}
220 +
221 +func projectSysobjectIDMetadata(entries []ddprofiledefinition.SysobjectIDMetadataEntryConfig, consumer ProfileConsumer) []ddprofiledefinition.SysobjectIDMetadataEntryConfig {
222 + if len(entries) == 0 {
223 + return nil
224 + }
225 + projected := make([]ddprofiledefinition.SysobjectIDMetadataEntryConfig, 0, len(entries))
226 + for _, entry := range entries {
227 + fields := make(map[string]ddprofiledefinition.MetadataField)
228 + for name, field := range entry.Metadata {
229 + if consumersInclude(field.Consumers, consumer) {
230 + fields[name] = field
231 + }
232 + }
233 + if len(fields) == 0 {
234 + continue
235 + }
236 + projected = append(projected, ddprofiledefinition.SysobjectIDMetadataEntryConfig{
237 + SysobjectID: entry.SysobjectID,
238 + Metadata: fields,
239 + })
240 + }
241 + if len(projected) == 0 {
242 + return nil
243 + }
244 + return projected
245 +}
246 +
247 +func projectMetricTagList(tags []ddprofiledefinition.MetricTagConfig, consumer ProfileConsumer) []ddprofiledefinition.MetricTagConfig {
248 + // Metadata id_tags do not carry Consumers today. They inherit metadata defaults.
249 + if consumer == ConsumerMetrics || consumer == ConsumerTopology {
250 + return tags
251 + }
252 + return nil
253 +}
254 +
255 +func projectGlobalMetricTags(tags []ddprofiledefinition.GlobalMetricTagConfig, consumer ProfileConsumer) []ddprofiledefinition.GlobalMetricTagConfig {
256 + filtered := tags[:0]
257 + for _, tag := range tags {
258 + if consumersInclude(tag.Consumers, consumer) {
259 + filtered = append(filtered, tag)
260 + }
261 + }
262 + if len(filtered) == 0 {
263 + return nil
264 + }
265 + return filtered
266 +}
267 +
268 +func consumersInclude(consumers ddprofiledefinition.ConsumerSet, consumer ProfileConsumer) bool {
269 + return len(consumers) == 0 || consumers.Contains(consumer)
270 +}
271 +
272 +func profileHasProjectedData(def *ddprofiledefinition.ProfileDefinition, consumer ProfileConsumer) bool {
273 + if def == nil {
274 + return false
275 + }
276 + switch consumer {
277 + case ConsumerMetrics:
278 + return len(def.Metrics) > 0 ||
279 + len(def.VirtualMetrics) > 0 ||
280 + len(def.MetricTags) > 0 ||
281 + len(def.Metadata) > 0 ||
282 + len(def.SysobjectIDMetadata) > 0
283 + case ConsumerTopology:
284 + return len(def.Topology) > 0 ||
285 + len(def.MetricTags) > 0 ||
286 + len(def.Metadata) > 0 ||
287 + len(def.SysobjectIDMetadata) > 0
288 + default:
289 + return false
290 + }
291 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_catalog_test.go new
+234
@@ -0,0 +1,234 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmp
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/ddprofiledefinition"
12 +)
13 +
14 +func TestCatalogResolve_ManualProfilePolicies(t *testing.T) {
15 + catalog := &Catalog{profiles: []*Profile{
16 + {
17 + SourceFile: "auto.yaml",
18 + Definition: &ddprofiledefinition.ProfileDefinition{
19 + Selector: ddprofiledefinition.SelectorSpec{
20 + {SysObjectID: ddprofiledefinition.SelectorIncludeExclude{Include: []string{"1.3.6.1.*"}}},
21 + },
22 + Metrics: []ddprofiledefinition.MetricsConfig{
23 + {Symbol: ddprofiledefinition.SymbolConfig{OID: "1.3.6.1.2.1.1.5.0", Name: "sysName"}},
24 + },
25 + },
26 + },
27 + {
28 + SourceFile: "manual.yaml",
29 + Definition: &ddprofiledefinition.ProfileDefinition{
30 + Metrics: []ddprofiledefinition.MetricsConfig{
31 + {Symbol: ddprofiledefinition.SymbolConfig{OID: "1.3.6.1.2.1.1.1.0", Name: "sysDescr"}},
32 + },
33 + },
34 + },
35 + }}
36 +
37 + tests := map[string]struct {
38 + policy ManualProfilePolicy
39 + expected []string
40 + }{
41 + "fallback_keeps_auto_match": {policy: ManualProfileFallback, expected: []string{"auto.yaml"}},
42 + "augment_appends_manual": {policy: ManualProfileAugment, expected: []string{"auto.yaml", "manual.yaml"}},
43 + "override_uses_manual_only": {policy: ManualProfileOverride, expected: []string{"manual.yaml"}},
44 + }
45 +
46 + for name, tc := range tests {
47 + t.Run(name, func(t *testing.T) {
48 + profiles := catalog.Resolve(ResolveRequest{
49 + SysObjectID: "1.3.6.1.4",
50 + ManualProfiles: []string{"manual"},
51 + ManualPolicy: tc.policy,
52 + }).Profiles()
53 +
54 + require.Len(t, profiles, len(tc.expected))
55 + for i, expected := range tc.expected {
56 + assert.Equal(t, expected, profiles[i].SourceFile)
57 + }
58 + })
59 + }
60 +}
61 +
62 +func TestResolvedProfileSetProject_SeparatesMetricsAndTopology(t *testing.T) {
63 + tests := map[string]struct {
64 + consumer ProfileConsumer
65 + metrics int
66 + topology int
67 + virtual int
68 + metadataField string
69 + metricTag string
70 + sysobjectID string
71 + firstKind ddprofiledefinition.TopologyKind
72 + }{
73 + "metrics_projection": {
74 + consumer: ConsumerMetrics,
75 + metrics: 2,
76 + virtual: 1,
77 + metadataField: "vendor",
78 + metricTag: "model",
79 + sysobjectID: "sysobjectid_vendor",
80 + },
81 + "topology_projection": {
82 + consumer: ConsumerTopology,
83 + topology: 2,
84 + metadataField: "lldp_loc_sys_name",
85 + metricTag: "lldp_loc_chassis_id",
86 + sysobjectID: "sysobjectid_topology_vendor",
87 + firstKind: ddprofiledefinition.KindLldpRem,
88 + },
89 + }
90 +
91 + for name, tc := range tests {
92 + t.Run(name, func(t *testing.T) {
93 + resolved := &ResolvedProfileSet{profiles: []*Profile{projectionTestProfile()}}
94 +
95 + profiles := resolved.Project(tc.consumer).Profiles()
96 +
97 + require.Len(t, profiles, 1)
98 + def := profiles[0].Definition
99 + require.Len(t, def.Metrics, tc.metrics)
100 + require.Len(t, def.Topology, tc.topology)
101 + require.Len(t, def.VirtualMetrics, tc.virtual)
102 + require.Len(t, def.Metadata["device"].Fields, 1)
103 + assert.Contains(t, def.Metadata["device"].Fields, tc.metadataField)
104 + require.Len(t, def.MetricTags, 1)
105 + assert.Equal(t, tc.metricTag, def.MetricTags[0].Tag)
106 + require.Len(t, def.SysobjectIDMetadata, 1)
107 + assert.Contains(t, def.SysobjectIDMetadata[0].Metadata, tc.sysobjectID)
108 + if tc.firstKind != "" {
109 + assert.Equal(t, tc.firstKind, def.Topology[0].Kind)
110 + }
111 + })
112 + }
113 +}
114 +
115 +func TestResolvedProfileSetProject_DoesNotShareMutableProjectionState(t *testing.T) {
116 + resolved := &ResolvedProfileSet{profiles: []*Profile{projectionTestProfile()}}
117 +
118 + view1 := resolved.Project(ConsumerTopology).Profiles()
119 + view2 := resolved.Project(ConsumerTopology).Profiles()
120 +
121 + require.Len(t, view1, 1)
122 + require.Len(t, view2, 1)
123 +
124 + view1[0].Definition.Topology[0].MetricTags[0].Tag = "mutated"
125 + view1[0].Definition.Metadata["device"].Fields["lldp_loc_sys_name"] = ddprofiledefinition.MetadataField{Value: "mutated"}
126 +
127 + assert.Equal(t, "lldp_rem_index", view2[0].Definition.Topology[0].MetricTags[0].Tag)
128 + assert.NotEqual(t, "mutated", view2[0].Definition.Metadata["device"].Fields["lldp_loc_sys_name"].Value)
129 +
130 + fresh := resolved.Project(ConsumerTopology).Profiles()
131 + assert.Equal(t, "lldp_rem_index", fresh[0].Definition.Topology[0].MetricTags[0].Tag)
132 + assert.NotEqual(t, "mutated", fresh[0].Definition.Metadata["device"].Fields["lldp_loc_sys_name"].Value)
133 +}
134 +
135 +func TestProjectedViewFilterByKind(t *testing.T) {
136 + resolved := &ResolvedProfileSet{profiles: []*Profile{projectionTestProfile()}}
137 +
138 + view := resolved.Project(ConsumerTopology).FilterByKind(map[ddprofiledefinition.TopologyKind]bool{
139 + ddprofiledefinition.KindStpPort: true,
140 + }).Profiles()
141 +
142 + require.Len(t, view, 1)
143 + require.Len(t, view[0].Definition.Topology, 1)
144 + assert.Equal(t, ddprofiledefinition.KindStpPort, view[0].Definition.Topology[0].Kind)
145 + assert.Empty(t, view[0].Definition.Metrics)
146 +
147 + unfiltered := resolved.Project(ConsumerTopology).Profiles()
148 + require.Len(t, unfiltered[0].Definition.Topology, 2)
149 + assert.Empty(t, unfiltered[0].Definition.Metrics)
150 +}
151 +
152 +func projectionTestProfile() *Profile {
153 + return &Profile{
154 + SourceFile: "projection.yaml",
155 + Definition: &ddprofiledefinition.ProfileDefinition{
156 + Metadata: ddprofiledefinition.MetadataConfig{
157 + "device": {
158 + Fields: map[string]ddprofiledefinition.MetadataField{
159 + "vendor": {
160 + Value: "Cisco",
161 + Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerMetrics},
162 + },
163 + "lldp_loc_sys_name": {
164 + Symbol: ddprofiledefinition.SymbolConfig{Name: "lldpLocSysName"},
165 + Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerTopology},
166 + },
167 + },
168 + },
169 + },
170 + SysobjectIDMetadata: []ddprofiledefinition.SysobjectIDMetadataEntryConfig{
171 + {
172 + SysobjectID: "1.3.6.1.4.1.9",
173 + Metadata: map[string]ddprofiledefinition.MetadataField{
174 + "sysobjectid_vendor": {
175 + Value: "Cisco",
176 + Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerMetrics},
177 + },
178 + "sysobjectid_topology_vendor": {
179 + Value: "Cisco topology",
180 + Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerTopology},
181 + },
182 + },
183 + },
184 + },
185 + MetricTags: []ddprofiledefinition.GlobalMetricTagConfig{
186 + {
187 + MetricTagConfig: ddprofiledefinition.MetricTagConfig{Tag: "model"},
188 + Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerMetrics},
189 + },
190 + {
191 + MetricTagConfig: ddprofiledefinition.MetricTagConfig{Tag: "lldp_loc_chassis_id"},
192 + Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerTopology},
193 + },
194 + },
195 + Metrics: []ddprofiledefinition.MetricsConfig{
196 + {
197 + Symbol: ddprofiledefinition.SymbolConfig{OID: "1.3.6.1.2.1.1.3.0", Name: "systemUptime"},
198 + },
199 + {
200 + Symbol: ddprofiledefinition.SymbolConfig{OID: "1.3.6.1.2.1.1.5.0", Name: "sysName"},
201 + },
202 + },
203 + Topology: []ddprofiledefinition.TopologyConfig{
204 + {
205 + Kind: ddprofiledefinition.KindLldpRem,
206 + MetricsConfig: ddprofiledefinition.MetricsConfig{
207 + Table: ddprofiledefinition.SymbolConfig{OID: "1.0.8802.1.1.2.1.4.1", Name: "lldpRemTable"},
208 + Symbols: []ddprofiledefinition.SymbolConfig{
209 + {OID: "1.0.8802.1.1.2.1.4.1.1.6", Name: "lldp_rem"},
210 + },
211 + MetricTags: ddprofiledefinition.MetricTagConfigList{
212 + {Tag: "lldp_rem_index", Index: 3},
213 + },
214 + },
215 + },
216 + {
217 + Kind: ddprofiledefinition.KindStpPort,
218 + MetricsConfig: ddprofiledefinition.MetricsConfig{
219 + Table: ddprofiledefinition.SymbolConfig{OID: "1.3.6.1.2.1.17.2.15.1", Name: "dot1dStpPortTable"},
220 + Symbols: []ddprofiledefinition.SymbolConfig{
221 + {OID: "1.3.6.1.2.1.17.2.15.1.3", Name: "stp_port"},
222 + },
223 + MetricTags: ddprofiledefinition.MetricTagConfigList{
224 + {Tag: "stp_port", Index: 1},
225 + },
226 + },
227 + },
228 + },
229 + VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
230 + {Name: "sysNameTotal", Sources: []ddprofiledefinition.VirtualMetricSourceConfig{{Metric: "sysName"}}},
231 + },
232 + },
233 + }
234 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_filter.go
+10 -1
@@ -58,7 +58,7 @@ func addMetricNames(names map[string]struct{}, metric *ddprofiledefinition.Metri
58 return
59 }
60
61 - if name := strings.TrimSpace(FirstNonEmpty(metric.Symbol.Name, metric.Name)); name != "" {
61 + if name := strings.TrimSpace(firstNonEmpty(metric.Symbol.Name, metric.Name)); name != "" {
62 names[name] = struct{}{}
63 }
64 for i := range metric.Symbols {
@@ -68,6 +68,15 @@ func addMetricNames(names map[string]struct{}, metric *ddprofiledefinition.Metri
68 }
69 }
70
71 +func firstNonEmpty(values ...string) string {
72 + for _, v := range values {
73 + if strings.TrimSpace(v) != "" {
74 + return v
75 + }
76 + }
77 + return ""
78 +}
79 +
80 func sourcesAvailable(sources []ddprofiledefinition.VirtualMetricSourceConfig, metricNames map[string]struct{}) bool {
81 if len(sources) == 0 {
82 return false
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_merge_test.go
+160
@@ -178,6 +178,148 @@ func TestProfile_MergeMetrics_PreservesRepeatedBaseColumnSymbols(t *testing.T) {
178 assert.Equal(t, "1.3.6.1.4.1.999.1.1.6", target.Definition.Metrics[0].Symbols[1].OID)
179 }
180
181 +func TestProfile_ExtendsTopologyMixinInheritsTopologyRows(t *testing.T) {
182 + tmp := t.TempDir()
183 +
184 + writeYAML(t, filepath.Join(tmp, "_topology-base.yaml"), ddprofiledefinition.ProfileDefinition{
185 + Topology: []ddprofiledefinition.TopologyConfig{
186 + topologyTableConfig(
187 + ddprofiledefinition.KindLldpRem,
188 + "1.0.8802.1.1.2.1.4.1",
189 + "lldpRemTable",
190 + "1.0.8802.1.1.2.1.4.1.1.6",
191 + "lldpRemPortIdSubtype",
192 + ),
193 + },
194 + })
195 + writeYAML(t, filepath.Join(tmp, "device.yaml"), ddprofiledefinition.ProfileDefinition{
196 + Extends: []string{"_topology-base.yaml"},
197 + })
198 +
199 + prof, err := loadProfile(filepath.Join(tmp, "device.yaml"), multipath.New(tmp))
200 + require.NoError(t, err)
201 + require.Len(t, prof.Definition.Topology, 1)
202 + assert.Equal(t, ddprofiledefinition.KindLldpRem, prof.Definition.Topology[0].Kind)
203 + assert.Equal(t, "lldpRemTable", prof.Definition.Topology[0].Table.Name)
204 + require.Len(t, prof.Definition.Topology[0].Symbols, 1)
205 + assert.Equal(t, "lldpRemPortIdSubtype", prof.Definition.Topology[0].Symbols[0].Name)
206 +}
207 +
208 +func TestProfile_MergeTopology_DerivedOverridesDuplicateAndPreservesBaseMissingSymbols(t *testing.T) {
209 + target := &Profile{Definition: &ddprofiledefinition.ProfileDefinition{
210 + Topology: []ddprofiledefinition.TopologyConfig{
211 + topologyTableConfig(
212 + ddprofiledefinition.KindLldpRem,
213 + "1.0.8802.1.1.2.1.4.1",
214 + "lldpRemTable",
215 + "1.0.8802.1.1.2.1.4.1.1.99",
216 + "lldpRemPortIdSubtype",
217 + ),
218 + },
219 + }}
220 + base := &Profile{Definition: &ddprofiledefinition.ProfileDefinition{
221 + Topology: []ddprofiledefinition.TopologyConfig{
222 + {
223 + Kind: ddprofiledefinition.KindLldpRem,
224 + MetricsConfig: ddprofiledefinition.MetricsConfig{
225 + Table: ddprofiledefinition.SymbolConfig{
226 + OID: "1.0.8802.1.1.2.1.4.1",
227 + Name: "lldpRemTable",
228 + },
229 + Symbols: []ddprofiledefinition.SymbolConfig{
230 + {OID: "1.0.8802.1.1.2.1.4.1.1.6", Name: "lldpRemPortIdSubtype"},
231 + {OID: "1.0.8802.1.1.2.1.4.1.1.7", Name: "lldpRemPortId"},
232 + },
233 + },
234 + },
235 + },
236 + }}
237 +
238 + require.NoError(t, target.mergeTopology(base))
239 +
240 + require.Len(t, target.Definition.Topology, 2)
241 + require.Len(t, target.Definition.Topology[0].Symbols, 1)
242 + assert.Equal(t, "1.0.8802.1.1.2.1.4.1.1.99", target.Definition.Topology[0].Symbols[0].OID)
243 + require.Len(t, target.Definition.Topology[1].Symbols, 1)
244 + assert.Equal(t, "lldpRemPortId", target.Definition.Topology[1].Symbols[0].Name)
245 +}
246 +
247 +func TestProfile_MergeTopology_RejectsConflictingKindForSameRow(t *testing.T) {
248 + target := &Profile{Definition: &ddprofiledefinition.ProfileDefinition{
249 + Topology: []ddprofiledefinition.TopologyConfig{
250 + topologyTableConfig(
251 + ddprofiledefinition.KindLldpRem,
252 + "1.0.8802.1.1.2.1.4.1",
253 + "lldpRemTable",
254 + "1.0.8802.1.1.2.1.4.1.1.6",
255 + "lldpRemPortIdSubtype",
256 + ),
257 + },
258 + }}
259 + base := &Profile{Definition: &ddprofiledefinition.ProfileDefinition{
260 + Topology: []ddprofiledefinition.TopologyConfig{
261 + topologyTableConfig(
262 + ddprofiledefinition.KindLldpLocPort,
263 + "1.0.8802.1.1.2.1.4.1",
264 + "lldpRemTable",
265 + "1.0.8802.1.1.2.1.4.1.1.6",
266 + "lldpRemPortIdSubtype",
267 + ),
268 + },
269 + }}
270 +
271 + err := target.mergeTopology(base)
272 +
273 + require.Error(t, err)
274 + assert.Contains(t, err.Error(), "conflicting topology kinds")
275 +}
276 +
277 +func TestDeduplicateMetricsAcrossProfiles_TopologyUsesKindTableAndSymbol(t *testing.T) {
278 + profiles := []*Profile{
279 + {
280 + SourceFile: "specific.yaml",
281 + Definition: &ddprofiledefinition.ProfileDefinition{
282 + Topology: []ddprofiledefinition.TopologyConfig{
283 + topologyTableConfig(
284 + ddprofiledefinition.KindLldpRem,
285 + "1.0.8802.1.1.2.1.4.1",
286 + "lldpRemTable",
287 + "1.0.8802.1.1.2.1.4.1.1.6",
288 + "lldpRemPortIdSubtype",
289 + ),
290 + },
291 + },
292 + },
293 + {
294 + SourceFile: "generic.yaml",
295 + Definition: &ddprofiledefinition.ProfileDefinition{
296 + Topology: []ddprofiledefinition.TopologyConfig{
297 + {
298 + Kind: ddprofiledefinition.KindLldpRem,
299 + MetricsConfig: ddprofiledefinition.MetricsConfig{
300 + Table: ddprofiledefinition.SymbolConfig{
301 + OID: "1.0.8802.1.1.2.1.4.1",
302 + Name: "lldpRemTable",
303 + },
304 + Symbols: []ddprofiledefinition.SymbolConfig{
305 + {OID: "1.0.8802.1.1.2.1.4.1.1.6", Name: "lldpRemPortIdSubtype"},
306 + {OID: "1.0.8802.1.1.2.1.4.1.1.7", Name: "lldpRemPortId"},
307 + },
308 + },
309 + },
310 + },
311 + },
312 + },
313 + }
314 +
315 + deduplicateMetricsAcrossProfiles(profiles)
316 +
317 + require.Len(t, profiles[0].Definition.Topology, 1)
318 + require.Len(t, profiles[1].Definition.Topology, 1)
319 + require.Len(t, profiles[1].Definition.Topology[0].Symbols, 1)
320 + assert.Equal(t, "lldpRemPortId", profiles[1].Definition.Topology[0].Symbols[0].Name)
321 +}
322 +
323 func writeTableBase(t *testing.T, path, tableOID, tableName, symbolOID, symbolName, description string) {
324 t.Helper()
325
@@ -188,6 +330,24 @@ func writeTableBase(t *testing.T, path, tableOID, tableName, symbolOID, symbolNa
330 })
331 }
332
333 +func topologyTableConfig(kind ddprofiledefinition.TopologyKind, tableOID, tableName, symbolOID, symbolName string) ddprofiledefinition.TopologyConfig {
334 + return ddprofiledefinition.TopologyConfig{
335 + Kind: kind,
336 + MetricsConfig: ddprofiledefinition.MetricsConfig{
337 + Table: ddprofiledefinition.SymbolConfig{
338 + OID: tableOID,
339 + Name: tableName,
340 + },
341 + Symbols: []ddprofiledefinition.SymbolConfig{
342 + {OID: symbolOID, Name: symbolName},
343 + },
344 + MetricTags: []ddprofiledefinition.MetricTagConfig{
345 + {Tag: "row", Index: 1},
346 + },
347 + },
348 + }
349 +}
350 +
351 func tableMetricConfig(tableOID, tableName, symbolOID, symbolName, description string) ddprofiledefinition.MetricsConfig {
352 return ddprofiledefinition.MetricsConfig{
353 Table: ddprofiledefinition.SymbolConfig{
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_prepare.go new
+122
@@ -0,0 +1,122 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmp
4 +
5 +import (
6 + "fmt"
7 + "slices"
8 + "strings"
9 +
10 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
11 +)
12 +
13 +// handleCrossTableTagsWithoutMetrics ensures tables referenced only by cross-table tags
14 +// are still walked during collection. Without this, if a table like ifXTable is used
15 +// only for cross-table tags (e.g., getting interface names) but has no metrics defined,
16 +// it won't be walked and the tags will be missing. This creates synthetic metric entries
17 +// for such tables using the longest common OID prefix of the referenced columns, including
18 +// lookup columns used by value-based joins.
19 +func handleCrossTableTagsWithoutMetrics(prof *Profile) {
20 + if prof.Definition == nil {
21 + return
22 + }
23 +
24 + handleCrossTableTagsWithoutMetricsForRows(&prof.Definition.Metrics)
25 + handleCrossTableTagsWithoutMetricsForTopologyRows(&prof.Definition.Topology)
26 +}
27 +
28 +func handleCrossTableTagsWithoutMetricsForRows(metrics *[]ddprofiledefinition.MetricsConfig) {
29 + seenTableNames := make(map[string]bool)
30 + for _, m := range *metrics {
31 + seenTableNames[m.Table.Name] = true
32 + }
33 +
34 + tagCrossTableOnlyOIDs := crossTableOnlyTagOIDs(*metrics, seenTableNames)
35 + for tableName, oids := range tagCrossTableOnlyOIDs {
36 + *metrics = append(*metrics, syntheticCrossTableMetric(tableName, oids))
37 + }
38 +}
39 +
40 +func handleCrossTableTagsWithoutMetricsForTopologyRows(topology *[]ddprofiledefinition.TopologyConfig) {
41 + seenTableNames := make(map[string]bool)
42 + for _, topo := range *topology {
43 + seenTableNames[topo.Table.Name] = true
44 + }
45 +
46 + for i := range *topology {
47 + topo := &(*topology)[i]
48 + tagCrossTableOnlyOIDs := crossTableOnlyTagOIDs([]ddprofiledefinition.MetricsConfig{topo.MetricsConfig}, seenTableNames)
49 + for tableName, oids := range tagCrossTableOnlyOIDs {
50 + *topology = append(*topology, ddprofiledefinition.TopologyConfig{
51 + Kind: topo.Kind,
52 + MetricsConfig: syntheticCrossTableMetric(tableName, oids),
53 + })
54 + seenTableNames[tableName] = true
55 + }
56 + }
57 +}
58 +
59 +func crossTableOnlyTagOIDs(metrics []ddprofiledefinition.MetricsConfig, seenTableNames map[string]bool) map[string][]string {
60 + tagCrossTableOnlyOIDs := make(map[string][]string)
61 + for _, m := range metrics {
62 + if m.IsScalar() {
63 + continue
64 + }
65 + for _, tag := range m.MetricTags {
66 + if tag.Table == "" || seenTableNames[tag.Table] {
67 + continue
68 + }
69 + if tag.Symbol.OID != "" {
70 + tagCrossTableOnlyOIDs[tag.Table] = append(tagCrossTableOnlyOIDs[tag.Table], tag.Symbol.OID)
71 + }
72 + if tag.LookupSymbol.OID != "" {
73 + tagCrossTableOnlyOIDs[tag.Table] = append(tagCrossTableOnlyOIDs[tag.Table], tag.LookupSymbol.OID)
74 + }
75 + }
76 + }
77 + return tagCrossTableOnlyOIDs
78 +}
79 +
80 +func syntheticCrossTableMetric(tableName string, oids []string) ddprofiledefinition.MetricsConfig {
81 + slices.Sort(oids)
82 + oids = slices.Compact(oids)
83 +
84 + return ddprofiledefinition.MetricsConfig{
85 + MIB: fmt.Sprintf("synthetic-%s-MIB", tableName),
86 + Table: ddprofiledefinition.SymbolConfig{
87 + OID: longestCommonPrefix(oids),
88 + Name: tableName,
89 + },
90 + }
91 +}
92 +
93 +func longestCommonPrefix(oids []string) string {
94 + if len(oids) == 0 {
95 + return ""
96 + }
97 +
98 + prefixParts := splitOIDParts(oids[0])
99 + for i := 1; i < len(oids); i++ {
100 + parts := splitOIDParts(oids[i])
101 + n := min(len(parts), len(prefixParts))
102 +
103 + j := 0
104 + for j < n && prefixParts[j] == parts[j] {
105 + j++
106 + }
107 + prefixParts = prefixParts[:j]
108 + if len(prefixParts) == 0 {
109 + return ""
110 + }
111 + }
112 +
113 + return strings.Join(prefixParts, ".")
114 +}
115 +
116 +func splitOIDParts(oid string) []string {
117 + parts := strings.Split(strings.Trim(oid, "."), ".")
118 + if len(parts) == 1 && parts[0] == "" {
119 + return nil
120 + }
121 + return parts
122 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_prepare_test.go new
+137
@@ -0,0 +1,137 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmp
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/ddprofiledefinition"
12 +)
13 +
14 +func TestLongestCommonPrefix(t *testing.T) {
15 + tests := map[string]struct {
16 + oids []string
17 + expected string
18 + }{
19 + "if_x_table": {
20 + oids: []string{
21 + "1.3.6.1.2.1.31.1.1.1.1",
22 + "1.3.6.1.2.1.31.1.1.1.18",
23 + },
24 + expected: "1.3.6.1.2.1.31.1.1.1",
25 + },
26 + "juniper_table": {
27 + oids: []string{
28 + "1.3.6.1.4.1.2636.5.1.1.2.1.1.1.11",
29 + "1.3.6.1.4.1.2636.5.1.1.2.1.1.1.14",
30 + },
31 + expected: "1.3.6.1.4.1.2636.5.1.1.2.1.1.1",
32 + },
33 + }
34 +
35 + for name, tc := range tests {
36 + t.Run(name, func(t *testing.T) {
37 + assert.Equal(t, tc.expected, longestCommonPrefix(tc.oids))
38 + })
39 + }
40 +}
41 +
42 +func TestHandleCrossTableTagsWithoutMetrics(t *testing.T) {
43 + profile := &Profile{
44 + Definition: &ddprofiledefinition.ProfileDefinition{
45 + Metrics: []ddprofiledefinition.MetricsConfig{
46 + {
47 + Table: ddprofiledefinition.SymbolConfig{
48 + OID: "1.3.6.1.2.1.2.2",
49 + Name: "ifTable",
50 + },
51 + Symbols: []ddprofiledefinition.SymbolConfig{
52 + {OID: "1.3.6.1.2.1.2.2.1.10", Name: "ifInOctets"},
53 + },
54 + MetricTags: ddprofiledefinition.MetricTagConfigList{
55 + {
56 + Tag: "if_name",
57 + Table: "ifXTable",
58 + Symbol: ddprofiledefinition.SymbolConfigCompat{
59 + OID: "1.3.6.1.2.1.31.1.1.1.1",
60 + Name: "ifName",
61 + },
62 + },
63 + },
64 + },
65 + },
66 + Topology: []ddprofiledefinition.TopologyConfig{
67 + {
68 + Kind: ddprofiledefinition.KindFdbEntry,
69 + MetricsConfig: ddprofiledefinition.MetricsConfig{
70 + Table: ddprofiledefinition.SymbolConfig{
71 + OID: "1.3.6.1.2.1.17.4.3",
72 + Name: "dot1dTpFdbTable",
73 + },
74 + Symbols: []ddprofiledefinition.SymbolConfig{
75 + {OID: "1.3.6.1.2.1.17.4.3.1.2", Name: "dot1dTpFdbPort"},
76 + },
77 + MetricTags: ddprofiledefinition.MetricTagConfigList{
78 + {
79 + Tag: "bridge_port_if_index",
80 + Table: "dot1dBasePortTable",
81 + Symbol: ddprofiledefinition.SymbolConfigCompat{
82 + OID: "1.3.6.1.2.1.17.1.4.1.2",
83 + Name: "dot1dBasePortIfIndex",
84 + },
85 + },
86 + },
87 + },
88 + },
89 + },
90 + },
91 + }
92 +
93 + handleCrossTableTagsWithoutMetrics(profile)
94 +
95 + require.Len(t, profile.Definition.Metrics, 2)
96 + assert.Equal(t, "ifXTable", profile.Definition.Metrics[1].Table.Name)
97 + assert.Equal(t, "1.3.6.1.2.1.31.1.1.1.1", profile.Definition.Metrics[1].Table.OID)
98 + require.Len(t, profile.Definition.Topology, 2)
99 + assert.Equal(t, ddprofiledefinition.KindFdbEntry, profile.Definition.Topology[1].Kind)
100 + assert.Equal(t, "dot1dBasePortTable", profile.Definition.Topology[1].Table.Name)
101 + assert.Equal(t, "1.3.6.1.2.1.17.1.4.1.2", profile.Definition.Topology[1].Table.OID)
102 +}
103 +
104 +func TestPrepareLoadedProfile_EnrichesTopologyMappingRefs(t *testing.T) {
105 + profile := &Profile{
106 + Definition: &ddprofiledefinition.ProfileDefinition{
107 + Topology: []ddprofiledefinition.TopologyConfig{
108 + {
109 + Kind: ddprofiledefinition.KindIfName,
110 + MetricsConfig: ddprofiledefinition.MetricsConfig{
111 + Table: ddprofiledefinition.SymbolConfig{
112 + OID: "1.3.6.1.2.1.2.2",
113 + Name: "ifTable",
114 + },
115 + Symbols: []ddprofiledefinition.SymbolConfig{
116 + {OID: "1.3.6.1.2.1.2.2.1.2", Name: "ifDescr"},
117 + },
118 + MetricTags: ddprofiledefinition.MetricTagConfigList{
119 + {
120 + Tag: "if_type",
121 + Index: 1,
122 + MappingRef: "ifType",
123 + },
124 + },
125 + },
126 + },
127 + },
128 + },
129 + }
130 +
131 + require.NoError(t, prepareLoadedProfile(profile))
132 +
133 + require.Len(t, profile.Definition.Topology, 1)
134 + tag := profile.Definition.Topology[0].MetricTags[0]
135 + assert.True(t, tag.Mapping.HasItems())
136 + assert.Equal(t, "ethernetCsmacd", tag.Mapping.Items["6"])
137 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_test.go
+71
@@ -174,6 +174,77 @@ func Test_FindProfiles(t *testing.T) {
174 }
175 }
176
177 +func TestDefaultCatalogResolveProject_LoadedCiscoProfileSeparatesConsumers(t *testing.T) {
178 + tests := map[string]struct {
179 + consumer ProfileConsumer
180 + wantMetrics []string
181 + wantTopologyKinds []ddprofiledefinition.TopologyKind
182 + wantNoMetrics bool
183 + wantNoTopology bool
184 + }{
185 + "metrics_projection": {
186 + consumer: ConsumerMetrics,
187 + wantMetrics: []string{"systemUptime", "tcpCurrEstab", "cpmCPUTotal5minRev"},
188 + wantNoTopology: true,
189 + },
190 + "topology_projection": {
191 + consumer: ConsumerTopology,
192 + wantTopologyKinds: []ddprofiledefinition.TopologyKind{
193 + ddprofiledefinition.KindLldpRem,
194 + ddprofiledefinition.KindCdpCache,
195 + ddprofiledefinition.KindFdbEntry,
196 + ddprofiledefinition.KindQbridgeFdbEntry,
197 + ddprofiledefinition.KindStpPort,
198 + ddprofiledefinition.KindVtpVlan,
199 + },
200 + wantNoMetrics: true,
201 + },
202 + }
203 +
204 + for name, tc := range tests {
205 + t.Run(name, func(t *testing.T) {
206 + profiles := DefaultCatalog().Resolve(ResolveRequest{
207 + SysObjectID: "1.3.6.1.4.1.9.1.1",
208 + ManualPolicy: ManualProfileFallback,
209 + }).Project(tc.consumer).Profiles()
210 + require.NotEmpty(t, profiles)
211 +
212 + metricNames := make(map[string]bool)
213 + topologyKinds := make(map[ddprofiledefinition.TopologyKind]bool)
214 +
215 + for _, prof := range profiles {
216 + require.NotNil(t, prof.Definition)
217 + if tc.wantNoMetrics {
218 + assert.Empty(t, prof.Definition.Metrics, prof.SourceFile)
219 + assert.Empty(t, prof.Definition.VirtualMetrics, prof.SourceFile)
220 + }
221 + if tc.wantNoTopology {
222 + assert.Empty(t, prof.Definition.Topology, prof.SourceFile)
223 + }
224 +
225 + for _, metric := range prof.Definition.Metrics {
226 + if metric.Symbol.Name != "" {
227 + metricNames[metric.Symbol.Name] = true
228 + }
229 + for _, sym := range metric.Symbols {
230 + metricNames[sym.Name] = true
231 + }
232 + }
233 + for _, topo := range prof.Definition.Topology {
234 + topologyKinds[topo.Kind] = true
235 + }
236 + }
237 +
238 + for _, metricName := range tc.wantMetrics {
239 + assert.True(t, metricNames[metricName], "missing metric %q", metricName)
240 + }
241 + for _, kind := range tc.wantTopologyKinds {
242 + assert.True(t, topologyKinds[kind], "missing topology kind %q", kind)
243 + }
244 + })
245 + }
246 +}
247 +
248 func Test_Profile_merge(t *testing.T) {
249 profiles := FindProfiles("1.3.6.1.4.1.9.1.1216", "", nil) // cisco-nexus
250
src/go/plugin/go.d/collector/snmp/ddsnmp/topology_classify.go deleted
-190
@@ -1,190 +0,0 @@
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 deleted
-111
@@ -1,111 +0,0 @@
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_kind.go new
+28
@@ -0,0 +1,28 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmp
4 +
5 +import "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
6 +
7 +type TopologyKind = ddprofiledefinition.TopologyKind
8 +
9 +const (
10 + KindLldpLocPort = ddprofiledefinition.KindLldpLocPort
11 + KindLldpLocManAddr = ddprofiledefinition.KindLldpLocManAddr
12 + KindLldpRem = ddprofiledefinition.KindLldpRem
13 + KindLldpRemManAddr = ddprofiledefinition.KindLldpRemManAddr
14 + KindLldpRemManAddrCompat = ddprofiledefinition.KindLldpRemManAddrCompat
15 + KindCdpCache = ddprofiledefinition.KindCdpCache
16 + KindIfName = ddprofiledefinition.KindIfName
17 + KindIfStatus = ddprofiledefinition.KindIfStatus
18 + KindIfDuplex = ddprofiledefinition.KindIfDuplex
19 + KindIpIfIndex = ddprofiledefinition.KindIpIfIndex
20 + KindBridgePortIfIndex = ddprofiledefinition.KindBridgePortIfIndex
21 + KindFdbEntry = ddprofiledefinition.KindFdbEntry
22 + KindQbridgeFdbEntry = ddprofiledefinition.KindQbridgeFdbEntry
23 + KindQbridgeVlanEntry = ddprofiledefinition.KindQbridgeVlanEntry
24 + KindStpPort = ddprofiledefinition.KindStpPort
25 + KindVtpVlan = ddprofiledefinition.KindVtpVlan
26 + KindArpEntry = ddprofiledefinition.KindArpEntry
27 + KindArpLegacyEntry = ddprofiledefinition.KindArpLegacyEntry
28 +)
src/go/plugin/go.d/collector/snmp/profile-format.md
+84
@@ -15,6 +15,7 @@ It tells the Netdata SNMP collector:
15 - which **OIDs** to query
16 - how to **interpret** the returned values
17 - how to **transform** them into **metrics**, **dimensions**, **tags**, and **metadata**
18 +- which rows are **regular metrics** and which rows are **SNMP topology** observations
19
20 Profiles make it possible to describe _entire device families_ (switches, routers, UPSes, firewalls, printers, etc.) declaratively — so you don’t need to hard-code logic in Go or manually define metrics for each device.
21
@@ -49,6 +50,8 @@ When Netdata connects to an SNMP device, the collector:
50 ├──────────────────────┤
51 │ metrics │ → OIDs to collect
52 ├──────────────────────┤
53 +│ topology │ → OIDs to collect for SNMP topology
54 +├──────────────────────┤
55 │ metric_tags │ → dynamic tags for all metrics
56 ├──────────────────────┤
57 │ static_tags │ → fixed tags for all metrics
@@ -146,6 +149,7 @@ selector: <device matching pattern>
149 extends: <base profiles to include>
150 metadata: <device information>
151 metrics: <what to collect>
152 +topology: <what to collect for topology>
153 metric_tags: <global tags>
154 static_tags: <static tags>
155 virtual_metrics: <calculated metrics>
@@ -157,6 +161,7 @@ virtual_metrics: <calculated metrics>
161 | [**extends**](#2-extends) | Inherits and merges other base profiles. |
162 | [**metadata**](#3-metadata) | Collects device-level information (host labels). |
163 | [**metrics**](#4-metrics) | Defines which OIDs to collect and how to chart them. |
164 +| [**topology**](#41-topology) | Defines SNMP topology rows and their topology kind. |
165 | [**metric_tags**](#5-metric_tags) | Defines global dynamic tags collected once per device and attached to all metrics. |
166 | [**static_tags**](#6-static_tags) | Defines fixed tags applied to all metrics. |
167 | [**virtual_metrics**](#7-virtual_metrics) | Defines calculated or aggregated metrics based on others. |
@@ -270,6 +275,9 @@ metadata:
275 - `model` is collected dynamically. The collector tries the listed OIDs **in order** and uses the **first** one that returns a non-empty value.
276 - These values appear as **device (virtual node) host labels** in the Netdata UI.
277 - They are **not per-metric tags** and are applied to the device itself, not individual charts.
278 +- Metadata fields are available to both regular metrics and topology by default.
279 + Use `consumers: [metrics]` or `consumers: [topology]` only when a field is
280 + intentionally limited to one view.
281
282 :::tip
283
@@ -371,6 +379,76 @@ virtual_metrics:
379 - { metric: _ifHCOutOctets, table: ifXTable, as: out }
380 ```
381
382 +### 4.1 topology
383 +
384 +The `topology` section defines SNMP rows consumed by the SNMP topology collector.
385 +Topology rows are collected through the same scalar and table mechanics as
386 +regular metrics, but they are not exported as charts. Instead, each row is routed
387 +to a topology handler through its closed `kind` value.
388 +
389 +Use top-level `topology:` when the row describes a topology actor, link, VLAN,
390 +bridge, FDB, ARP, LLDP, CDP, STP, VTP, or interface-mapping observation.
391 +
392 +```yaml
393 +topology:
394 + - kind: lldp_rem
395 + MIB: LLDP-MIB
396 + table:
397 + OID: 1.0.8802.1.1.2.1.4.1
398 + name: lldpRemTable
399 + symbols:
400 + - OID: 1.0.8802.1.1.2.1.4.1.1.6
401 + name: lldp_rem
402 + metric_tags:
403 + - tag: lldp_loc_port_num
404 + index: 2
405 + - tag: lldp_rem_index
406 + index: 3
407 + - tag: lldp_rem_sys_name
408 + symbol:
409 + OID: 1.0.8802.1.1.2.1.4.1.1.9
410 + name: lldpRemSysName
411 +```
412 +
413 +**Rules**:
414 +
415 +- `kind` is required and must be one of the closed topology kinds below.
416 +- Topology row symbol names must not start with `_`.
417 +- Topology rows do not use chart/export-only fields such as `chart_meta`,
418 + `metric_type`, `mapping`, `transform`, `scale_factor`, `format`, or
419 + `constant_value_one` on the row value symbol.
420 +- `metric_tags` inside a topology row work like table metric tags and identify
421 + or enrich the topology row.
422 +- `systemUptime` stays under `metrics:` for regular SNMP collection. It is not a
423 + topology kind and should not be declared under `topology:`.
424 +
425 +Valid topology kinds:
426 +
427 +```text
428 +lldp_loc_port
429 +lldp_loc_man_addr
430 +lldp_rem
431 +lldp_rem_man_addr
432 +lldp_rem_man_addr_compat
433 +cdp_cache
434 +if_name
435 +if_status
436 +if_duplex
437 +ip_if_index
438 +bridge_port_if_index
439 +fdb_entry
440 +qbridge_fdb_entry
441 +qbridge_vlan_entry
442 +stp_port
443 +vtp_vlan
444 +arp_entry
445 +arp_legacy_entry
446 +```
447 +
448 +Topology mixins can be inherited through `extends` just like metric mixins. When
449 +two inherited topology rows collide, the identity is `kind + table identity +
450 +symbol name`, matching regular table metric merge behavior.
451 +
452 #### Scalar symbol fallbacks
453
454 You can express “try this OID, otherwise try that OID” by declaring **multiple scalar metrics with the same** `symbol.name`, each pointing to a different OID. At runtime the collector **GETs** all declared scalar OIDs, marks missing ones, and **emits** the metric from whichever OID returns data. Missing OIDs are skipped cleanly.
@@ -427,6 +505,10 @@ metric_tags:
505 - Each tag is collected once per device, not per metric or per table row.
506 - The resulting tag values are attached to **all metrics** collected by the profile.
507 - Tags can be transformed (for example, reformatted or mapped) using the same rules as per-metric tags.
508 +- Top-level `metric_tags` are available to both regular metrics and topology by
509 + default. In topology they become device/profile labels, not per-row dispatch
510 + keys. Use `consumers: [metrics]` or `consumers: [topology]` only when a tag is
511 + intentionally limited to one view.
512
513 :::tip
514
@@ -1854,6 +1936,8 @@ metrics:
1936 - Virtual metrics are **calculated metrics** built from other metrics in your profile (or inherited ones).
1937 - They don’t query SNMP; they **reuse existing metric values** to create totals, fallbacks, or per-row aggregations.
1938 - Once computed, they behave like normal metrics: charted, tagged, and alertable.
1939 +- Virtual metrics are part of the regular metrics view. A virtual metric cannot
1940 + depend on both regular metric rows and topology rows.
1941
1942 Common use cases:
1943
src/go/plugin/go.d/collector/snmp/profile_sets.go
+8 -2
@@ -14,10 +14,16 @@ import (
14 )
15
16 func (c *Collector) setupProfiles(si *snmputils.SysInfo) []*ddsnmp.Profile {
17 - matchedProfiles := ddsnmp.FindProfiles(si.SysObjectID, si.Descr, c.ManualProfiles)
17 + resolved := ddsnmp.DefaultCatalog().Resolve(ddsnmp.ResolveRequest{
18 + SysObjectID: si.SysObjectID,
19 + SysDescr: si.Descr,
20 + ManualProfiles: c.ManualProfiles,
21 + ManualPolicy: ddsnmp.ManualProfileFallback,
22 + })
23 + matchedProfiles := resolved.Profiles()
24 c.logMatchedProfiles(matchedProfiles, si.SysObjectID)
25
20 - return selectCollectionProfiles(matchedProfiles)
26 + return resolved.Project(ddsnmp.ConsumerMetrics).Profiles()
27 }
28
29 func (c *Collector) logMatchedProfiles(profiles []*ddsnmp.Profile, sysObjectID string) {
src/go/plugin/go.d/collector/snmp/profile_sets_test.go deleted
-101
@@ -1,101 +0,0 @@
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_profile_filter.go deleted
-146
@@ -1,146 +0,0 @@
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/collector.go
+14 -9
@@ -167,12 +167,18 @@ func (c *Collector) refreshDeviceTopology(key string, dev ddsnmp.DeviceConnectio
167 return
168 }
169
170 + sysUptime, err := snmputils.GetSysUptime(snmpClient)
171 + if err != nil {
172 + c.Debugf("device '%s': failed to query system uptime: %v", dev.Hostname, err)
173 + }
174 +
175 // Build the next snapshot off-registry. Function readers keep seeing the
176 // previous complete snapshot until this collection is fully ingested.
177 next := c.newDeviceCollectionCache(dev)
178 c.topologyCache = next
179 defer func() { c.topologyCache = nil }()
180
181 + c.updateTopologySysUptime(sysUptime)
182 c.updateTopologyProfileTags(pms)
183 c.ingestTopologyProfileMetrics(pms)
184 c.collectTopologyVTPVLANContexts(dev)
@@ -214,24 +220,23 @@ func (c *Collector) pruneStaleDeviceCaches(seen map[string]bool) {
220 }
221
222 func (c *Collector) findTopologyProfiles(dev ddsnmp.DeviceConnectionInfo) []*ddsnmp.Profile {
217 - return selectTopologyRefreshProfiles(ddsnmp.FindProfiles(dev.SysObjectID, dev.SysDescr, dev.ManualProfiles))
223 + return ddsnmp.DefaultCatalog().Resolve(ddsnmp.ResolveRequest{
224 + SysObjectID: dev.SysObjectID,
225 + SysDescr: dev.SysDescr,
226 + ManualProfiles: dev.ManualProfiles,
227 + ManualPolicy: ddsnmp.ManualProfileAugment,
228 + }).Project(ddsnmp.ConsumerTopology).Profiles()
229 }
230
231 func (c *Collector) ingestTopologyProfileMetrics(pms []*ddsnmp.ProfileMetrics) {
232 for _, pm := range pms {
222 - c.ingestTopologyMetricSet(pm.HiddenMetrics)
223 - c.ingestTopologyMetricSet(pm.Metrics)
233 + c.ingestTopologyMetricSet(pm.TopologyMetrics)
234 }
235 }
236
237 func (c *Collector) ingestTopologyMetricSet(metrics []ddsnmp.Metric) {
238 for _, metric := range metrics {
229 - switch {
230 - case ddsnmp.IsTopologyMetric(metric.Name):
231 - c.updateTopologyCacheEntry(metric)
232 - case ddsnmp.IsTopologySysUptimeMetric(metric.Name):
233 - c.updateTopologyScalarMetric(metric)
234 - }
239 + c.updateTopologyCacheEntry(metric)
240 }
241 }
242
src/go/plugin/go.d/collector/snmp_topology/collector_refresh_test.go
+14 -4
@@ -13,6 +13,7 @@ import (
13
14 "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
15 "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector"
16 + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/snmputils"
17 )
18
19 func TestCollector_RefreshKeepsPublishedSnapshotWhileCollectionRuns(t *testing.T) {
@@ -91,6 +92,15 @@ func expectTopologyRefreshSNMPClient(mockHandler *snmpmock.MockHandler, dev ddsn
92 mockHandler.EXPECT().SetCommunity(dev.Community)
93 mockHandler.EXPECT().SetVersion(gosnmp.Version2c)
94 mockHandler.EXPECT().Connect().Return(nil)
95 + mockHandler.EXPECT().Get(gomock.InAnyOrder([]string{
96 + snmputils.OidSnmpEngineTime,
97 + snmputils.OidHrSystemUptime,
98 + snmputils.OidSysUpTime,
99 + })).Return(&gosnmp.SnmpPacket{
100 + Variables: []gosnmp.SnmpPDU{
101 + {Name: snmputils.OidSnmpEngineTime, Type: gosnmp.Integer, Value: 1234},
102 + },
103 + }, nil)
104 mockHandler.EXPECT().Close().Return(nil)
105 }
106
@@ -122,16 +132,16 @@ func seedPublishedEndpointSnapshot(cache *topologyCache) {
132
133 func replacementEndpointProfileMetrics() []*ddsnmp.ProfileMetrics {
134 return []*ddsnmp.ProfileMetrics{{
125 - HiddenMetrics: []ddsnmp.Metric{
135 + TopologyMetrics: []ddsnmp.Metric{
136 {
127 - Name: metricBridgePortMapEntry,
137 + TopologyKind: ddsnmp.KindBridgePortIfIndex,
138 Tags: map[string]string{
139 tagBridgeBasePort: "5",
140 tagBridgeIfIndex: "5",
141 },
142 },
143 {
134 - Name: metricDot1qFdbEntry,
144 + TopologyKind: ddsnmp.KindQbridgeFdbEntry,
145 Tags: map[string]string{
146 tagDot1qFdbID: "7",
147 tagDot1qFdbMac: "00:50:56:ab:cd:ef",
@@ -139,7 +149,7 @@ func replacementEndpointProfileMetrics() []*ddsnmp.ProfileMetrics {
149 },
150 },
151 {
142 - Name: metricArpEntry,
152 + TopologyKind: ddsnmp.KindArpEntry,
153 Tags: map[string]string{
154 tagArpIfIndex: "5",
155 tagArpIP: "10.0.0.20",
src/go/plugin/go.d/collector/snmp_topology/profile_filter.go deleted
-143
@@ -1,143 +0,0 @@
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
+14 -82
@@ -9,85 +9,8 @@ import (
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"
12 )
13
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 -
14 func TestFindTopologyProfiles_UsesDeclarativeProfileExtensions(t *testing.T) {
15 profiles := (&Collector{}).findTopologyProfiles(ddsnmp.DeviceConnectionInfo{
16 SysObjectID: "1.3.6.1.4.1.9.1.1",
@@ -107,6 +30,15 @@ func TestFindTopologyProfiles_UsesDeclarativeProfileExtensions(t *testing.T) {
30 metricNames[sym.Name] = struct{}{}
31 }
32 }
33 + for _, topo := range prof.Definition.Topology {
34 + metricNames[string(topo.Kind)] = struct{}{}
35 + if topo.Symbol.Name != "" {
36 + metricNames[topo.Symbol.Name] = struct{}{}
37 + }
38 + for _, sym := range topo.Symbols {
39 + metricNames[sym.Name] = struct{}{}
40 + }
41 + }
42 for _, res := range prof.Definition.Metadata {
43 for field := range res.Fields {
44 metadataFields[field] = struct{}{}
@@ -114,11 +46,11 @@ func TestFindTopologyProfiles_UsesDeclarativeProfileExtensions(t *testing.T) {
46 }
47 }
48
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")
49 + assert.Contains(t, metricNames, "lldp_rem")
50 + assert.Contains(t, metricNames, "cdp_cache")
51 + assert.Contains(t, metricNames, "fdb_entry")
52 + assert.Contains(t, metricNames, "stp_port")
53 + assert.Contains(t, metricNames, "vtp_vlan")
54 assert.Contains(t, metadataFields, "lldp_loc_sys_name")
55 assert.Contains(t, metadataFields, "vtp_version")
56 }
src/go/plugin/go.d/collector/snmp_topology/topology_cache_cdp.go
+6
@@ -2,6 +2,12 @@
2
3 package snmptopology
4
5 +import "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
6 +
7 +func init() {
8 + registerTopologyMetricHandler(ddsnmp.KindCdpCache, (*topologyCache).updateCdpRemote)
9 +}
10 +
11 func (c *topologyCache) updateCdpRemote(tags map[string]string) {
12 ifIndex := tags[tagCdpIfIndex]
13 if ifIndex == "" {
src/go/plugin/go.d/collector/snmp_topology/topology_cache_fdb.go
+12 -1
@@ -2,7 +2,18 @@
2
3 package snmptopology
4
5 -import "strings"
5 +import (
6 + "strings"
7 +
8 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
9 +)
10 +
11 +func init() {
12 + registerTopologyMetricHandler(ddsnmp.KindFdbEntry, (*topologyCache).updateFdbEntry)
13 + registerTopologyMetricHandler(ddsnmp.KindQbridgeFdbEntry, (*topologyCache).updateFdbEntry)
14 + registerTopologyMetricHandler(ddsnmp.KindQbridgeVlanEntry, (*topologyCache).updateDot1qVlanMap)
15 + registerTopologyMetricHandler(ddsnmp.KindVtpVlan, (*topologyCache).updateVtpVlanEntry)
16 +}
17
18 func (c *topologyCache) updateFdbEntry(tags map[string]string) {
19 c.updateLocalBridgeIdentityFromTags(tags)
src/go/plugin/go.d/collector/snmp_topology/topology_cache_ingest.go
+20 -11
@@ -18,14 +18,23 @@ func (c *Collector) updateTopologyProfileTags(pms []*ddsnmp.ProfileMetrics) {
18
19 for _, pm := range pms {
20 tags := topologyMetadataValues(pm.DeviceMetadata)
21 - if len(tags) == 0 {
22 - continue
21 + if len(pm.Tags) > 0 {
22 + if tags == nil {
23 + tags = make(map[string]string, len(pm.Tags))
24 + }
25 + for k, v := range pm.Tags {
26 + if v != "" {
27 + tags[k] = v
28 + }
29 + }
30 }
31
25 - c.topologyCache.applyLLDPLocalDeviceProfileTags(tags)
26 - c.topologyCache.updateLocalBridgeIdentityFromTags(tags)
27 - c.topologyCache.applySTPProfileTags(tags)
28 - c.topologyCache.applyVTPProfileTags(tags)
32 + if len(tags) > 0 {
33 + c.topologyCache.applyLLDPLocalDeviceProfileTags(tags)
34 + c.topologyCache.updateLocalBridgeIdentityFromTags(tags)
35 + c.topologyCache.applySTPProfileTags(tags)
36 + c.topologyCache.applyVTPProfileTags(tags)
37 + }
38 }
39 }
40
@@ -37,14 +46,14 @@ func (c *Collector) updateTopologyCacheEntry(m ddsnmp.Metric) {
46 c.topologyCache.mu.Lock()
47 defer c.topologyCache.mu.Unlock()
48
40 - c.topologyCache.ingestMetric(m.Name, m.Tags)
49 + c.topologyCache.ingestMetric(m.TopologyKind, m.Tags)
50 }
51
43 -func (c *Collector) updateTopologyScalarMetric(m ddsnmp.Metric) {
52 +func (c *Collector) updateTopologySysUptime(value int64) {
53 if c == nil || c.topologyCache == nil {
54 return
55 }
47 - if !isTopologySysUptimeMetric(m.Name) || m.Value <= 0 {
56 + if value <= 0 {
57 return
58 }
59
@@ -52,9 +61,9 @@ func (c *Collector) updateTopologyScalarMetric(m ddsnmp.Metric) {
61 defer c.topologyCache.mu.Unlock()
62
63 local := c.topologyCache.localDevice
55 - local.SysUptime = m.Value
64 + local.SysUptime = value
65 local.Labels = ensureLabels(local.Labels)
57 - setTopologyMetadataLabelIfMissing(local.Labels, "sys_uptime", strconv.FormatInt(m.Value, 10))
66 + setTopologyMetadataLabelIfMissing(local.Labels, "sys_uptime", strconv.FormatInt(value, 10))
67 c.topologyCache.localDevice = local
68 }
69
src/go/plugin/go.d/collector/snmp_topology/topology_cache_interfaces.go
+10
@@ -5,8 +5,18 @@ package snmptopology
5 import (
6 "math"
7 "strings"
8 +
9 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
10 )
11
12 +func init() {
13 + registerTopologyMetricHandler(ddsnmp.KindIfName, (*topologyCache).updateIfNameByIndex)
14 + registerTopologyMetricHandler(ddsnmp.KindIfStatus, (*topologyCache).updateIfNameByIndex)
15 + registerTopologyMetricHandler(ddsnmp.KindIfDuplex, (*topologyCache).updateIfNameByIndex)
16 + registerTopologyMetricHandler(ddsnmp.KindIpIfIndex, (*topologyCache).updateIfIndexByIP)
17 + registerTopologyMetricHandler(ddsnmp.KindBridgePortIfIndex, (*topologyCache).updateBridgePortMap)
18 +}
19 +
20 func (c *topologyCache) updateIfNameByIndex(tags map[string]string) {
21 ifIndex := strings.TrimSpace(tags[tagTopoIfIndex])
22 if ifIndex == "" {
src/go/plugin/go.d/collector/snmp_topology/topology_cache_lldp.go
+13 -1
@@ -2,7 +2,19 @@
2
3 package snmptopology
4
5 -import "strings"
5 +import (
6 + "strings"
7 +
8 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
9 +)
10 +
11 +func init() {
12 + registerTopologyMetricHandler(ddsnmp.KindLldpLocPort, (*topologyCache).updateLldpLocPort)
13 + registerTopologyMetricHandler(ddsnmp.KindLldpLocManAddr, (*topologyCache).updateLldpLocManAddr)
14 + registerTopologyMetricHandler(ddsnmp.KindLldpRem, (*topologyCache).updateLldpRemote)
15 + registerTopologyMetricHandler(ddsnmp.KindLldpRemManAddr, (*topologyCache).updateLldpRemManAddr)
16 + registerTopologyMetricHandler(ddsnmp.KindLldpRemManAddrCompat, (*topologyCache).updateLldpRemManAddr)
17 +}
18
19 func (c *topologyCache) updateLldpLocPort(tags map[string]string) {
20 portNum := tags[tagLldpLocPortNum]
src/go/plugin/go.d/collector/snmp_topology/topology_cache_metric_dispatch.go
+17 -44
@@ -2,54 +2,27 @@
2
3 package snmptopology
4
5 -import "strings"
5 +import "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
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 -}
7 +type topologyMetricHandler func(*topologyCache, map[string]string)
8 +
9 +var topologyMetricHandlers = make(map[ddsnmp.TopologyKind]topologyMetricHandler)
10
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
11 +func registerTopologyMetricHandler(kind ddsnmp.TopologyKind, handler topologyMetricHandler) {
12 + if kind == "" {
13 + panic("empty topology metric kind")
14 + }
15 + if handler == nil {
16 + panic("nil topology metric handler")
17 + }
18 + if _, ok := topologyMetricHandlers[kind]; ok {
19 + panic("duplicate topology metric handler for kind " + string(kind))
20 }
21 + topologyMetricHandlers[kind] = handler
22 }
23
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
24 +func (c *topologyCache) ingestMetric(kind ddsnmp.TopologyKind, tags map[string]string) {
25 + if handler := topologyMetricHandlers[kind]; handler != nil {
26 + handler(c, tags)
27 }
28 }
src/go/plugin/go.d/collector/snmp_topology/topology_cache_stp_arp.go
+11 -1
@@ -2,7 +2,17 @@
2
3 package snmptopology
4
5 -import "strings"
5 +import (
6 + "strings"
7 +
8 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
9 +)
10 +
11 +func init() {
12 + registerTopologyMetricHandler(ddsnmp.KindStpPort, (*topologyCache).updateStpPortEntry)
13 + registerTopologyMetricHandler(ddsnmp.KindArpEntry, (*topologyCache).updateArpEntry)
14 + registerTopologyMetricHandler(ddsnmp.KindArpLegacyEntry, (*topologyCache).updateArpEntry)
15 +}
16
17 func (c *topologyCache) updateStpPortEntry(tags map[string]string) {
18 port := strings.TrimSpace(tags[tagStpPort])
src/go/plugin/go.d/collector/snmp_topology/topology_cache_tags.go
-21
@@ -2,27 +2,6 @@
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 -
5 const (
6 tagLldpLocChassisID = "lldp_loc_chassis_id"
7 tagLldpLocChassisIDSubtype = "lldp_loc_chassis_id_subtype"
src/go/plugin/go.d/collector/snmp_topology/topology_cache_test.go
+55 -35
@@ -26,6 +26,37 @@ func newTestCollector(dev ddsnmp.DeviceConnectionInfo) *Collector {
26 }
27 }
28
29 +func TestTopologyMetricHandlersRegisteredForRowKinds(t *testing.T) {
30 + tests := map[string]struct {
31 + kind ddsnmp.TopologyKind
32 + }{
33 + "lldp_loc_port": {kind: ddsnmp.KindLldpLocPort},
34 + "lldp_loc_man_addr": {kind: ddsnmp.KindLldpLocManAddr},
35 + "lldp_rem": {kind: ddsnmp.KindLldpRem},
36 + "lldp_rem_man_addr": {kind: ddsnmp.KindLldpRemManAddr},
37 + "lldp_rem_man_addr_compat": {kind: ddsnmp.KindLldpRemManAddrCompat},
38 + "cdp_cache": {kind: ddsnmp.KindCdpCache},
39 + "if_name": {kind: ddsnmp.KindIfName},
40 + "if_status": {kind: ddsnmp.KindIfStatus},
41 + "if_duplex": {kind: ddsnmp.KindIfDuplex},
42 + "ip_if_index": {kind: ddsnmp.KindIpIfIndex},
43 + "bridge_port_if_index": {kind: ddsnmp.KindBridgePortIfIndex},
44 + "fdb_entry": {kind: ddsnmp.KindFdbEntry},
45 + "qbridge_fdb_entry": {kind: ddsnmp.KindQbridgeFdbEntry},
46 + "qbridge_vlan_entry": {kind: ddsnmp.KindQbridgeVlanEntry},
47 + "stp_port": {kind: ddsnmp.KindStpPort},
48 + "vtp_vlan": {kind: ddsnmp.KindVtpVlan},
49 + "arp_entry": {kind: ddsnmp.KindArpEntry},
50 + "arp_legacy_entry": {kind: ddsnmp.KindArpLegacyEntry},
51 + }
52 +
53 + for name, tc := range tests {
54 + t.Run(name, func(t *testing.T) {
55 + require.NotNil(t, topologyMetricHandlers[tc.kind], "missing topology handler for %s", tc.kind)
56 + })
57 + }
58 +}
59 +
60 func TestTopologyCache_LldpSnapshot(t *testing.T) {
61 coll := newTestCollector(ddsnmp.DeviceConnectionInfo{
62 Hostname: "10.0.0.1", SysObjectID: "1.3.6.1.4.1.9.1.1", SysName: "sw1", SysDescr: "Switch 1", SysLocation: "dc1",
@@ -40,7 +71,7 @@ func TestTopologyCache_LldpSnapshot(t *testing.T) {
71 coll.updateTopologyProfileTags(pms)
72
73 coll.updateTopologyCacheEntry(ddsnmp.Metric{
43 - Name: metricLldpLocPortEntry,
74 + TopologyKind: ddsnmp.KindLldpLocPort,
75 Tags: map[string]string{
76 tagLldpLocPortNum: "1",
77 tagLldpLocPortID: "Gi0/1",
@@ -49,7 +80,7 @@ func TestTopologyCache_LldpSnapshot(t *testing.T) {
80 },
81 })
82 coll.updateTopologyCacheEntry(ddsnmp.Metric{
52 - Name: metricLldpRemEntry,
83 + TopologyKind: ddsnmp.KindLldpRem,
84 Tags: map[string]string{
85 tagLldpLocPortNum: "1",
86 tagLldpRemIndex: "1",
@@ -433,7 +464,7 @@ func TestTopologyCache_LLDPManagementAddressesAndCaps(t *testing.T) {
464 }})
465
466 coll.updateTopologyCacheEntry(ddsnmp.Metric{
436 - Name: metricLldpLocManAddrEntry,
467 + TopologyKind: ddsnmp.KindLldpLocManAddr,
468 Tags: map[string]string{
469 tagLldpLocMgmtAddrSubtype: "2",
470 tagLldpLocMgmtAddr: "0a000001",
@@ -441,7 +472,7 @@ func TestTopologyCache_LLDPManagementAddressesAndCaps(t *testing.T) {
472 },
473 })
474 coll.updateTopologyCacheEntry(ddsnmp.Metric{
444 - Name: metricLldpRemManAddrEntry,
475 + TopologyKind: ddsnmp.KindLldpRemManAddr,
476 Tags: map[string]string{
477 tagLldpLocPortNum: "1",
478 tagLldpRemIndex: "1",
@@ -450,7 +481,7 @@ func TestTopologyCache_LLDPManagementAddressesAndCaps(t *testing.T) {
481 },
482 })
483 coll.updateTopologyCacheEntry(ddsnmp.Metric{
453 - Name: metricLldpRemManAddrEntry,
484 + TopologyKind: ddsnmp.KindLldpRemManAddr,
485 Tags: map[string]string{
486 tagLldpLocPortNum: "1",
487 tagLldpRemIndex: "1",
@@ -459,7 +490,7 @@ func TestTopologyCache_LLDPManagementAddressesAndCaps(t *testing.T) {
490 },
491 })
492 coll.updateTopologyCacheEntry(ddsnmp.Metric{
462 - Name: metricLldpRemManAddrEntry,
493 + TopologyKind: ddsnmp.KindLldpRemManAddr,
494 Tags: map[string]string{
495 tagLldpLocPortNum: "1",
496 tagLldpRemIndex: "1",
@@ -468,7 +499,7 @@ func TestTopologyCache_LLDPManagementAddressesAndCaps(t *testing.T) {
499 },
500 })
501 coll.updateTopologyCacheEntry(ddsnmp.Metric{
471 - Name: metricLldpRemManAddrEntry,
502 + TopologyKind: ddsnmp.KindLldpRemManAddr,
503 Tags: map[string]string{
504 tagLldpLocPortNum: "1",
505 tagLldpRemIndex: "1",
@@ -481,7 +512,7 @@ func TestTopologyCache_LLDPManagementAddressesAndCaps(t *testing.T) {
512 },
513 })
514 coll.updateTopologyCacheEntry(ddsnmp.Metric{
484 - Name: metricLldpRemEntry,
515 + TopologyKind: ddsnmp.KindLldpRem,
516 Tags: map[string]string{
517 tagLldpLocPortNum: "1",
518 tagLldpRemIndex: "1",
@@ -1305,35 +1336,29 @@ func TestBuildLocalTopologyDevice_IncludesSysContactVendorAndModel(t *testing.T)
1336 require.Equal(t, topologyProfileChartContextPrefix, device.ChartContextPrefix)
1337 }
1338
1308 -func TestCollector_UpdateTopologyScalarMetric_StoresSysUptime(t *testing.T) {
1309 - for _, metricName := range []string{"sysUpTime", "systemUptime"} {
1310 - t.Run(metricName, func(t *testing.T) {
1311 - coll := &Collector{
1312 - topologyCache: newTopologyCache(),
1313 - }
1314 - coll.topologyCache.localDevice = topologyDevice{}
1339 +func TestCollector_UpdateTopologySysUptime_StoresSysUptime(t *testing.T) {
1340 + coll := &Collector{
1341 + topologyCache: newTopologyCache(),
1342 + }
1343 + coll.topologyCache.localDevice = topologyDevice{}
1344
1316 - coll.updateTopologyScalarMetric(ddsnmp.Metric{
1317 - Name: metricName,
1318 - Value: 4321,
1319 - })
1345 + coll.updateTopologySysUptime(4321)
1346
1321 - require.EqualValues(t, 4321, coll.topologyCache.localDevice.SysUptime)
1322 - require.Equal(t, "4321", coll.topologyCache.localDevice.Labels["sys_uptime"])
1323 - })
1324 - }
1347 + require.EqualValues(t, 4321, coll.topologyCache.localDevice.SysUptime)
1348 + require.Equal(t, "4321", coll.topologyCache.localDevice.Labels["sys_uptime"])
1349 }
1350
1327 -func TestCollector_IngestTopologyProfileMetrics_IncludesHiddenMetrics(t *testing.T) {
1351 +func TestCollector_IngestTopologyProfileMetrics_IncludesTopologyMetrics(t *testing.T) {
1352 coll := &Collector{
1353 topologyCache: newTopologyCache(),
1354 }
1355
1356 coll.ingestTopologyProfileMetrics([]*ddsnmp.ProfileMetrics{
1357 {
1334 - HiddenMetrics: []ddsnmp.Metric{
1358 + TopologyMetrics: []ddsnmp.Metric{
1359 {
1336 - Name: metricLldpLocPortEntry,
1360 + Name: "lldp_loc_port",
1361 + TopologyKind: ddsnmp.KindLldpLocPort,
1362 Tags: map[string]string{
1363 tagLldpLocPortNum: "7",
1364 tagLldpLocPortID: "Gi1/0/7",
@@ -1342,7 +1367,8 @@ func TestCollector_IngestTopologyProfileMetrics_IncludesHiddenMetrics(t *testing
1367 },
1368 },
1369 {
1345 - Name: metricLldpRemEntry,
1370 + Name: "lldp_rem",
1371 + TopologyKind: ddsnmp.KindLldpRem,
1372 Tags: map[string]string{
1373 tagLldpLocPortNum: "7",
1374 tagLldpRemIndex: "1",
@@ -1355,19 +1381,13 @@ func TestCollector_IngestTopologyProfileMetrics_IncludesHiddenMetrics(t *testing
1381 },
1382 },
1383 },
1358 - Metrics: []ddsnmp.Metric{
1359 - {
1360 - Name: "systemUptime",
1361 - Value: 1234,
1362 - },
1363 - },
1384 },
1385 })
1386
1387 require.Contains(t, coll.topologyCache.lldpLocPorts, "7")
1388 require.Contains(t, coll.topologyCache.lldpRemotes, "7:1")
1369 - require.EqualValues(t, 1234, coll.topologyCache.localDevice.SysUptime)
1370 - require.Equal(t, "1234", coll.topologyCache.localDevice.Labels["sys_uptime"])
1389 + require.Zero(t, coll.topologyCache.localDevice.SysUptime)
1390 + require.Empty(t, coll.topologyCache.localDevice.Labels["sys_uptime"])
1391 }
1392
1393 func TestBuildLocalTopologyDevice_MapsVersionToSoftwareOnly(t *testing.T) {
src/go/plugin/go.d/collector/snmp_topology/topology_profiles.go deleted
-15
@@ -1,15 +0,0 @@
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 deleted
-89
@@ -1,89 +0,0 @@
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_snmprec_forwarding_test.go
+10 -10
@@ -112,34 +112,34 @@ func replaySnmprecForwardingFixture(t *testing.T, fixture string, data snmprecFo
112 coll.updateTopologyProfileTags([]*ddsnmp.ProfileMetrics{{DeviceMetadata: data.bridgeMetadata}})
113 }
114 for _, tags := range data.ifNameEntries {
115 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricTopologyIfNameEntry, Tags: tags})
115 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindIfName, Tags: tags})
116 }
117 for _, tags := range data.ifStatusEntries {
118 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricTopologyIfStatusEntry, Tags: tags})
118 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindIfStatus, Tags: tags})
119 }
120 for _, tags := range data.ipIfEntries {
121 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricTopologyIPIfEntry, Tags: tags})
121 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindIpIfIndex, Tags: tags})
122 }
123 for _, tags := range data.bridgePorts {
124 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricBridgePortMapEntry, Tags: tags})
124 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindBridgePortIfIndex, Tags: tags})
125 }
126 for _, tags := range data.qBridgeVLANs {
127 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricDot1qVlanEntry, Tags: tags})
127 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindQbridgeVlanEntry, Tags: tags})
128 }
129 for _, tags := range data.vtpVLANs {
130 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricVtpVlanEntry, Tags: tags})
130 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindVtpVlan, Tags: tags})
131 }
132 for _, tags := range data.fdbEntries {
133 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricFdbEntry, Tags: tags})
133 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindFdbEntry, Tags: tags})
134 }
135 for _, tags := range data.qBridgeFdb {
136 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricDot1qFdbEntry, Tags: tags})
136 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindQbridgeFdbEntry, Tags: tags})
137 }
138 for _, tags := range data.stpPorts {
139 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricStpPortEntry, Tags: tags})
139 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindStpPort, Tags: tags})
140 }
141 for _, tags := range data.arpEntries {
142 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricArpEntry, Tags: tags})
142 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindArpEntry, Tags: tags})
143 }
144
145 return coll
src/go/plugin/go.d/collector/snmp_topology/topology_snmprec_test.go
+5 -5
@@ -51,19 +51,19 @@ func TestTopologyCache_RealSnmprecFixtures(t *testing.T) {
51 coll.updateTopologyProfileTags([]*ddsnmp.ProfileMetrics{{DeviceMetadata: data.lldpLocalMeta}})
52 }
53 for _, tags := range data.lldpLocPorts {
54 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricLldpLocPortEntry, Tags: tags})
54 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindLldpLocPort, Tags: tags})
55 }
56 for _, tags := range data.lldpLocManAddrs {
57 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricLldpLocManAddrEntry, Tags: tags})
57 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindLldpLocManAddr, Tags: tags})
58 }
59 for _, tags := range data.lldpRemotes {
60 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricLldpRemEntry, Tags: tags})
60 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindLldpRem, Tags: tags})
61 }
62 for _, tags := range data.lldpRemManAddrs {
63 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricLldpRemManAddrEntry, Tags: tags})
63 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindLldpRemManAddr, Tags: tags})
64 }
65 for _, tags := range data.cdpRemotes {
66 - coll.updateTopologyCacheEntry(ddsnmp.Metric{Name: metricCdpCacheEntry, Tags: tags})
66 + coll.updateTopologyCacheEntry(ddsnmp.Metric{TopologyKind: ddsnmp.KindCdpCache, Tags: tags})
67 }
68 coll.finalizeTopologyCache()
69
src/go/plugin/go.d/collector/snmp_topology/topology_vlan_context.go
+1 -1
@@ -16,7 +16,7 @@ func (c *Collector) collectTopologyVTPVLANContexts(dev ddsnmp.DeviceConnectionIn
16 return
17 }
18
19 - profiles, err := loadTopologyVLANContextProfiles()
19 + profiles, err := loadTopologyVLANContextProfiles(dev)
20 if err != nil {
21 c.Warningf("device '%s': topology vlan-context polling disabled: failed to load profiles: %v", dev.Hostname, err)
22 return
src/go/plugin/go.d/collector/snmp_topology/topology_vlan_context_collect.go
+7 -12
@@ -13,18 +13,13 @@ import (
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
16 +func loadTopologyVLANContextProfiles(dev ddsnmp.DeviceConnectionInfo) ([]*ddsnmp.Profile, error) {
17 + return ddsnmp.DefaultCatalog().Resolve(ddsnmp.ResolveRequest{
18 + SysObjectID: dev.SysObjectID,
19 + SysDescr: dev.SysDescr,
20 + ManualProfiles: dev.ManualProfiles,
21 + ManualPolicy: ddsnmp.ManualProfileAugment,
22 + }).Project(ddsnmp.ConsumerTopology).FilterByKind(vlanScopableKinds).Profiles(), nil
23 }
24
25 func collectTopologyVLANContext(c *Collector, dev ddsnmp.DeviceConnectionInfo, vlanID string, profiles []*ddsnmp.Profile) ([]*ddsnmp.ProfileMetrics, error) {
src/go/plugin/go.d/collector/snmp_topology/topology_vlan_context_ingest.go
+14 -11
@@ -13,27 +13,30 @@ func (c *Collector) ingestTopologyVLANContextMetrics(vlanID, vlanName string, pm
13 c.updateTopologyProfileTags(pms)
14
15 for _, pm := range pms {
16 - for _, metric := range pm.Metrics {
17 - if !isTopologyVLANContextMetric(metric.Name) {
16 + for _, metric := range pm.TopologyMetrics {
17 + if !isTopologyVLANContextMetric(metric.TopologyKind) {
18 continue
19 }
20
21 tags := withTopologyVLANContextTags(metric.Tags, vlanID, vlanName)
22 c.updateTopologyCacheEntry(ddsnmp.Metric{
23 - Name: metric.Name,
24 - Tags: tags,
23 + Name: metric.Name,
24 + TopologyKind: metric.TopologyKind,
25 + Tags: tags,
26 })
27 }
28 }
29 }
30
30 -func isTopologyVLANContextMetric(name string) bool {
31 - switch name {
32 - case metricTopologyIfNameEntry, metricBridgePortMapEntry, metricFdbEntry, metricStpPortEntry:
33 - return true
34 - default:
35 - return false
36 - }
31 +func isTopologyVLANContextMetric(kind ddsnmp.TopologyKind) bool {
32 + return vlanScopableKinds[kind]
33 +}
34 +
35 +var vlanScopableKinds = map[ddsnmp.TopologyKind]bool{
36 + ddsnmp.KindIfName: true,
37 + ddsnmp.KindBridgePortIfIndex: true,
38 + ddsnmp.KindFdbEntry: true,
39 + ddsnmp.KindStpPort: true,
40 }
41
42 func withTopologyVLANContextTags(tags map[string]string, vlanID, vlanName string) map[string]string {
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_cisco-base.yaml
+1
@@ -4,6 +4,7 @@ extends:
4 - _std-if-mib.yaml
5 - _std-lldp-mib.yaml
6 - _std-cdp-mib.yaml
7 + - _std-topology-cdp-mib.yaml
8 - _std-topology-fdb-arp-mib.yaml
9 - _std-topology-q-bridge-mib.yaml
10 - _std-topology-stp-mib.yaml
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-cdp-mib.yaml
-105
@@ -48,108 +48,3 @@ metrics:
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-topology-cdp-mib.yaml new
+109
@@ -0,0 +1,109 @@
1 +# CISCO-CDP-MIB topology profile.
2 +# MIB: CISCO-CDP-MIB
3 +
4 +topology:
5 + - kind: cdp_cache
6 + MIB: CISCO-CDP-MIB
7 + table:
8 + OID: 1.3.6.1.4.1.9.9.23.1.2.1
9 + name: cdpCacheTable
10 + symbols:
11 + # Use a required numeric column to ensure rows are emitted by ddsnmp.
12 + - OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.3
13 + name: cdp_cache
14 + metric_tags:
15 + - tag: cdp_if_index
16 + index: 1
17 + - tag: cdp_device_index
18 + index: 2
19 + - tag: cdp_device_id
20 + symbol:
21 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.6
22 + name: cdpCacheDeviceId
23 + - tag: cdp_address_type
24 + symbol:
25 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.3
26 + name: cdpCacheAddressType
27 + - tag: cdp_device_port
28 + symbol:
29 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.7
30 + name: cdpCacheDevicePort
31 + - tag: cdp_version
32 + symbol:
33 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.5
34 + name: cdpCacheVersion
35 + - tag: cdp_platform
36 + symbol:
37 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.8
38 + name: cdpCachePlatform
39 + - tag: cdp_capabilities
40 + symbol:
41 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.9
42 + name: cdpCacheCapabilities
43 + - tag: cdp_address
44 + symbol:
45 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.4
46 + name: cdpCacheAddress
47 + format: hex
48 + - tag: cdp_vtp_mgmt_domain
49 + symbol:
50 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.10
51 + name: cdpCacheVTPMgmtDomain
52 + - tag: cdp_native_vlan
53 + symbol:
54 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.11
55 + name: cdpCacheNativeVLAN
56 + - tag: cdp_duplex
57 + symbol:
58 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.12
59 + name: cdpCacheDuplex
60 + - tag: cdp_power_consumption
61 + symbol:
62 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.15
63 + name: cdpCachePowerConsumption
64 + - tag: cdp_mtu
65 + symbol:
66 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.16
67 + name: cdpCacheMTU
68 + - tag: cdp_sys_name
69 + symbol:
70 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.17
71 + name: cdpCacheSysName
72 + - tag: cdp_sys_object_id
73 + symbol:
74 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.18
75 + name: cdpCacheSysObjectID
76 + - tag: cdp_primary_mgmt_addr_type
77 + symbol:
78 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.19
79 + name: cdpCachePrimaryMgmtAddrType
80 + - tag: cdp_primary_mgmt_addr
81 + symbol:
82 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.20
83 + name: cdpCachePrimaryMgmtAddr
84 + format: hex
85 + - tag: cdp_secondary_mgmt_addr_type
86 + symbol:
87 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.21
88 + name: cdpCacheSecondaryMgmtAddrType
89 + - tag: cdp_secondary_mgmt_addr
90 + symbol:
91 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.22
92 + name: cdpCacheSecondaryMgmtAddr
93 + format: hex
94 + - tag: cdp_physical_location
95 + symbol:
96 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.23
97 + name: cdpCachePhysLocation
98 + - tag: cdp_last_change
99 + symbol:
100 + OID: 1.3.6.1.4.1.9.9.23.1.2.1.1.24
101 + name: cdpCacheLastChange
102 + - tag: cdp_if_name
103 + table: ifXTable
104 + symbol:
105 + OID: 1.3.6.1.2.1.31.1.1.1.1
106 + name: ifName
107 + index_transform:
108 + - start: 0
109 + end: 0
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-topology-cisco-vtp-mib.yaml
+4 -3
@@ -9,15 +9,16 @@ metadata:
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
12 +topology:
13 + - kind: vtp_vlan
14 + MIB: CISCO-VTP-MIB
15 table:
16 OID: 1.3.6.1.4.1.9.9.46.1.3.1.1
17 name: vtpVlanTable
18 symbols:
19 # Numeric anchor to force row emission.
20 - OID: 1.3.6.1.4.1.9.9.46.1.3.1.1.2
20 - name: _topology_vtp_vlan_entry
21 + name: vtp_vlan
22 metric_tags:
23 - tag: vtp_vlan_index
24 index: 1
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-topology-fdb-arp-mib.yaml
+25 -17
@@ -1,16 +1,17 @@
1 # Supplemental topology discovery profile for endpoint correlation.
2 # Sources: BRIDGE-MIB FDB and IP-MIB neighbor tables.
3
4 -metrics:
4 +topology:
5 # Interface index -> interface name map used to annotate topology links.
6 - - MIB: IF-MIB
6 + - kind: if_name
7 + MIB: IF-MIB
8 table:
9 OID: 1.3.6.1.2.1.31.1.1
10 name: ifXTable
11 symbols:
12 # Use a numeric column to ensure row emission.
13 - OID: 1.3.6.1.2.1.31.1.1.1.15
13 - name: _topology_if_name_entry
14 + name: if_name
15 metric_tags:
16 - tag: topo_if_index
17 index: 1
@@ -29,14 +30,15 @@ metrics:
30
31 # IF-MIB ifTable: explicit interface status collection for topology port states.
32 # Some vendors do not support stable cross-table joins from ifXTable to ifTable.
32 - - MIB: IF-MIB
33 + - kind: if_status
34 + MIB: IF-MIB
35 table:
36 OID: 1.3.6.1.2.1.2.2
37 name: ifTable
38 symbols:
39 # Use oper status symbol to guarantee row emission for interfaces.
40 - OID: 1.3.6.1.2.1.2.2.1.8
39 - name: _topology_if_status_entry
41 + name: if_status
42 metric_tags:
43 - tag: topo_if_index
44 index: 1
@@ -83,13 +85,14 @@ metrics:
85 name: ifLastChange
86
87 # EtherLike-MIB: interface duplex mode.
86 - - MIB: EtherLike-MIB
88 + - kind: if_duplex
89 + MIB: EtherLike-MIB
90 table:
91 OID: 1.3.6.1.2.1.10.7.2
92 name: dot3StatsTable
93 symbols:
94 - OID: 1.3.6.1.2.1.10.7.2.1.19
92 - name: _topology_if_duplex_entry
95 + name: if_duplex
96 metric_tags:
97 - tag: topo_if_index
98 index: 1
@@ -103,13 +106,14 @@ metrics:
106 3: full
107
108 # IP-MIB: interface address map used for management IP -> ifIndex correlation.
106 - - MIB: IP-MIB
109 + - kind: ip_if_index
110 + MIB: IP-MIB
111 table:
112 OID: 1.3.6.1.2.1.4.20
113 name: ipAddrTable
114 symbols:
115 - OID: 1.3.6.1.2.1.4.20.1.2
112 - name: _topology_ip_if_index_entry
116 + name: ip_if_index
117 metric_tags:
118 - tag: topo_ip_addr
119 symbol:
@@ -125,13 +129,14 @@ metrics:
129 name: ipAdEntNetMask
130
131 # BRIDGE-MIB: maps bridge port number to ifIndex.
128 - - MIB: BRIDGE-MIB
132 + - kind: bridge_port_if_index
133 + MIB: BRIDGE-MIB
134 table:
135 OID: 1.3.6.1.2.1.17.1.4
136 name: dot1dBasePortTable
137 symbols:
138 - OID: 1.3.6.1.2.1.17.1.4.1.2
134 - name: _topology_bridge_port_if_index_entry
139 + name: bridge_port_if_index
140 metric_tags:
141 - tag: bridge_base_address
142 symbol:
@@ -146,14 +151,15 @@ metrics:
151 name: dot1dBasePortIfIndex
152
153 # BRIDGE-MIB: forwarding database (MAC -> bridge port).
149 - - MIB: BRIDGE-MIB
154 + - kind: fdb_entry
155 + MIB: BRIDGE-MIB
156 table:
157 OID: 1.3.6.1.2.1.17.4.3
158 name: dot1dTpFdbTable
159 symbols:
160 # Use the port column so rows are emitted only for learned entries.
161 - OID: 1.3.6.1.2.1.17.4.3.1.2
156 - name: _topology_fdb_entry
162 + name: fdb_entry
163 metric_tags:
164 - tag: bridge_base_address
165 symbol:
@@ -181,14 +187,15 @@ metrics:
187 5: mgmt
188
189 # IP-MIB: modern ARP/ND cache (IPv4 + IPv6).
184 - - MIB: IP-MIB
190 + - kind: arp_entry
191 + MIB: IP-MIB
192 table:
193 OID: 1.3.6.1.2.1.4.35.1
194 name: ipNetToPhysicalTable
195 symbols:
196 # Use state column to emit rows only when neighbors exist.
197 - OID: 1.3.6.1.2.1.4.35.1.6
191 - name: _topology_arp_entry
198 + name: arp_entry
199 metric_tags:
200 - tag: arp_if_index
201 index: 1
@@ -234,13 +241,14 @@ metrics:
241 7: incomplete
242
243 # IP-MIB: legacy IPv4 ARP cache fallback.
237 - - MIB: IP-MIB
244 + - kind: arp_legacy_entry
245 + MIB: IP-MIB
246 table:
247 OID: 1.3.6.1.2.1.4.22
248 name: ipNetToMediaTable
249 symbols:
250 - OID: 1.3.6.1.2.1.4.22.1.4
243 - name: _topology_arp_legacy_entry
251 + name: arp_legacy_entry
252 metric_tags:
253 - tag: arp_if_index
254 symbol:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-topology-lldp-mib.yaml
+16 -11
@@ -50,14 +50,15 @@ metadata:
50 name: lldpLocSysCapEnabled
51 format: hex
52
53 -metrics:
54 - - MIB: LLDP-MIB
53 +topology:
54 + - kind: lldp_loc_port
55 + MIB: LLDP-MIB
56 table:
57 OID: 1.0.8802.1.1.2.1.3.7
58 name: lldpLocPortTable
59 symbols:
60 - OID: 1.0.8802.1.1.2.1.3.7.1.2
60 - name: _topology_lldp_loc_port_entry
61 + name: lldp_loc_port
62 metric_tags:
63 - tag: lldp_loc_port_num
64 index: 1
@@ -82,13 +83,14 @@ metrics:
83 OID: 1.0.8802.1.1.2.1.3.7.1.4
84 name: lldpLocPortDesc
85
85 - - MIB: LLDP-MIB
86 + - kind: lldp_loc_man_addr
87 + MIB: LLDP-MIB
88 table:
89 OID: 1.0.8802.1.1.2.1.3.8
90 name: lldpLocManAddrTable
91 symbols:
92 - OID: 1.0.8802.1.1.2.1.3.8.1.3
91 - name: _topology_lldp_loc_man_addr_entry
93 + name: lldp_loc_man_addr
94 metric_tags:
95 - tag: lldp_loc_mgmt_addr_subtype
96 index: 1
@@ -111,14 +113,15 @@ metrics:
113 OID: 1.0.8802.1.1.2.1.3.8.1.6
114 name: lldpLocManAddrOID
115
114 - - MIB: LLDP-MIB
116 + - kind: lldp_rem
117 + MIB: LLDP-MIB
118 table:
119 OID: 1.0.8802.1.1.2.1.4.1
120 name: lldpRemTable
121 symbols:
122 # Use a required numeric column to ensure rows are emitted by ddsnmp.
123 - OID: 1.0.8802.1.1.2.1.4.1.1.6
121 - name: _topology_lldp_rem_entry
124 + name: lldp_rem
125 metric_tags:
126 - tag: lldp_loc_port_num
127 index: 2
@@ -188,7 +191,8 @@ metrics:
191 # same way as lldpLocManAddrTable - anchor on a readable column (.1.3
192 # lldpRemManAddrIfSubtype) and derive subtype/address from the row index
193 # via index_transform with format: hex.
191 - - MIB: LLDP-MIB
194 + - kind: lldp_rem_man_addr
195 + MIB: LLDP-MIB
196 table:
197 OID: 1.0.8802.1.1.2.1.4.2
198 name: lldpRemManAddrTable
@@ -196,7 +200,7 @@ metrics:
200 # Primary anchor for implementations that expose columns .1/.2
201 # (e.g. MikroTik).
202 - OID: 1.0.8802.1.1.2.1.4.2.1.1
199 - name: _topology_lldp_rem_man_addr_entry
203 + name: lldp_rem_man_addr
204 metric_tags:
205 - tag: lldp_loc_port_num
206 index: 2
@@ -258,7 +262,8 @@ metrics:
262 OID: 1.0.8802.1.1.2.1.4.2.1.5
263 name: lldpRemManAddrOID
264
261 - - MIB: LLDP-MIB
265 + - kind: lldp_rem_man_addr_compat
266 + MIB: LLDP-MIB
267 table:
268 OID: 1.0.8802.1.1.2.1.4.2
269 name: lldpRemManAddrTable
@@ -266,7 +271,7 @@ metrics:
271 # Compatibility anchor for implementations that expose .3/.4/.5 but
272 # not .1/.2 (e.g. XS1930). Address bytes are reconstructed from index.
273 - OID: 1.0.8802.1.1.2.1.4.2.1.3
269 - name: _topology_lldp_rem_man_addr_compat_entry
274 + name: lldp_rem_man_addr_compat
275 metric_tags:
276 - tag: lldp_loc_port_num
277 index: 2
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-topology-q-bridge-mib.yaml
+7 -5
@@ -5,16 +5,17 @@
5 # - dot1qTpFdbTable provides per-FDB-domain MAC learning entries.
6 # - dot1qVlanCurrentTable maps FDB domain IDs to VLAN IDs where available.
7
8 -metrics:
8 +topology:
9 # Q-BRIDGE-MIB: VLAN/FDB-domain forwarding table (MAC -> bridge port).
10 - - MIB: Q-BRIDGE-MIB
10 + - kind: qbridge_fdb_entry
11 + MIB: Q-BRIDGE-MIB
12 table:
13 OID: 1.3.6.1.2.1.17.7.1.2.2.1
14 name: dot1qTpFdbTable
15 symbols:
16 # Use port column as numeric row anchor.
17 - OID: 1.3.6.1.2.1.17.7.1.2.2.1.2
17 - name: _topology_qbridge_fdb_entry
18 + name: qbridge_fdb_entry
19 metric_tags:
20 - tag: dot1q_fdb_id
21 index: 1
@@ -40,13 +41,14 @@ metrics:
41 5: mgmt
42
43 # Q-BRIDGE-MIB: maps FDB domain ID to VLAN ID.
43 - - MIB: Q-BRIDGE-MIB
44 + - kind: qbridge_vlan_entry
45 + MIB: Q-BRIDGE-MIB
46 table:
47 OID: 1.3.6.1.2.1.17.7.1.4.2.1
48 name: dot1qVlanCurrentTable
49 symbols:
50 - OID: 1.3.6.1.2.1.17.7.1.4.2.1.3
49 - name: _topology_qbridge_vlan_entry
51 + name: qbridge_vlan_entry
52 metric_tags:
53 # Some devices expose timemark+vlan index, others only vlan.
54 # Keep both and resolve in code.
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_std-topology-stp-mib.yaml
+4 -3
@@ -15,15 +15,16 @@ metadata:
15 name: dot1dStpDesignatedRoot
16 format: hex
17
18 -metrics:
19 - - MIB: BRIDGE-MIB
18 +topology:
19 + - kind: stp_port
20 + MIB: BRIDGE-MIB
21 table:
22 OID: 1.3.6.1.2.1.17.2.15.1
23 name: dot1dStpPortTable
24 symbols:
25 # Numeric anchor to force row emission.
26 - OID: 1.3.6.1.2.1.17.2.15.1.3
26 - name: _topology_stp_port_entry
27 + name: stp_port
28 metric_tags:
29 - tag: stp_port
30 index: 1
src/go/plugin/go.d/config/go.d/snmp.profiles/default/cisco-sb.yaml
+1
@@ -3,6 +3,7 @@ extends:
3 - _std-if-mib.yaml
4 - _std-lldp-mib.yaml
5 - _std-cdp-mib.yaml
6 + - _std-topology-cdp-mib.yaml
7 - _std-topology-fdb-arp-mib.yaml
8 - _std-topology-q-bridge-mib.yaml
9 - _std-topology-stp-mib.yaml
src/go/plugin/go.d/pkg/snmputils/sysuptime.go new
+106
@@ -0,0 +1,106 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmputils
4 +
5 +import (
6 + "fmt"
7 + "strings"
8 +
9 + "github.com/gosnmp/gosnmp"
10 +)
11 +
12 +const (
13 + OidSnmpEngineTime = "1.3.6.1.6.3.10.2.1.3.0"
14 + OidHrSystemUptime = "1.3.6.1.2.1.25.1.1.0"
15 + OidSysUpTime = "1.3.6.1.2.1.1.3.0"
16 + sysUptimeTimeTicks = 0.01
17 +)
18 +
19 +type sysUptimeSource struct {
20 + oid string
21 + scale float64
22 +}
23 +
24 +var sysUptimeSources = []sysUptimeSource{
25 + {oid: OidSnmpEngineTime},
26 + {oid: OidHrSystemUptime, scale: sysUptimeTimeTicks},
27 + {oid: OidSysUpTime, scale: sysUptimeTimeTicks},
28 +}
29 +
30 +func GetSysUptime(client gosnmp.Handler) (int64, error) {
31 + packet, err := client.Get(sysUptimeOIDs())
32 + if err != nil {
33 + return 0, err
34 + }
35 + if packet == nil || len(packet.Variables) == 0 {
36 + return 0, nil
37 + }
38 +
39 + pdusByOID := make(map[string]gosnmp.SnmpPDU, len(packet.Variables))
40 + for _, pdu := range packet.Variables {
41 + pdusByOID[strings.TrimPrefix(pdu.Name, ".")] = pdu
42 + }
43 +
44 + var lastErr error
45 + for _, source := range sysUptimeSources {
46 + pdu, ok := pdusByOID[source.oid]
47 + if !ok || !isSysUptimePduWithData(pdu) {
48 + continue
49 + }
50 +
51 + value, err := sysUptimePduValue(pdu)
52 + if err != nil {
53 + lastErr = fmt.Errorf("OID '%s': %w", source.oid, err)
54 + continue
55 + }
56 + if source.scale != 0 {
57 + value = int64(float64(value) * source.scale)
58 + }
59 + if value > 0 {
60 + return value, nil
61 + }
62 + }
63 +
64 + return 0, lastErr
65 +}
66 +
67 +func sysUptimeOIDs() []string {
68 + oids := make([]string, 0, len(sysUptimeSources))
69 + for _, source := range sysUptimeSources {
70 + oids = append(oids, source.oid)
71 + }
72 + return oids
73 +}
74 +
75 +func sysUptimePduValue(pdu gosnmp.SnmpPDU) (int64, error) {
76 + if !isSysUptimeNumericPdu(pdu) {
77 + return 0, fmt.Errorf("cannot convert %T to numeric uptime", pdu.Value)
78 + }
79 + return gosnmp.ToBigInt(pdu.Value).Int64(), nil
80 +}
81 +
82 +func isSysUptimePduWithData(pdu gosnmp.SnmpPDU) bool {
83 + switch pdu.Type {
84 + case gosnmp.NoSuchObject,
85 + gosnmp.NoSuchInstance,
86 + gosnmp.Null,
87 + gosnmp.EndOfMibView:
88 + return false
89 + default:
90 + return true
91 + }
92 +}
93 +
94 +func isSysUptimeNumericPdu(pdu gosnmp.SnmpPDU) bool {
95 + switch pdu.Type {
96 + case gosnmp.Counter32,
97 + gosnmp.Counter64,
98 + gosnmp.Integer,
99 + gosnmp.Gauge32,
100 + gosnmp.Uinteger32,
101 + gosnmp.TimeTicks:
102 + return true
103 + default:
104 + return false
105 + }
106 +}
src/go/plugin/go.d/pkg/snmputils/sysuptime_test.go new
+86
@@ -0,0 +1,86 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmputils
4 +
5 +import (
6 + "errors"
7 + "testing"
8 +
9 + "github.com/golang/mock/gomock"
10 + "github.com/gosnmp/gosnmp"
11 + snmpmock "github.com/gosnmp/gosnmp/mocks"
12 + "github.com/stretchr/testify/assert"
13 + "github.com/stretchr/testify/require"
14 +)
15 +
16 +func TestGetSysUptime(t *testing.T) {
17 + tests := map[string]struct {
18 + pdus []gosnmp.SnmpPDU
19 + getErr error
20 + expected int64
21 + wantErr bool
22 + }{
23 + "prefers_snmp_engine_time_seconds": {
24 + pdus: []gosnmp.SnmpPDU{
25 + {Name: OidSnmpEngineTime, Type: gosnmp.Integer, Value: 1234},
26 + {Name: OidHrSystemUptime, Type: gosnmp.TimeTicks, Value: uint32(999900)},
27 + {Name: OidSysUpTime, Type: gosnmp.TimeTicks, Value: uint32(888800)},
28 + },
29 + expected: 1234,
30 + },
31 + "falls_back_to_host_resources_timeticks": {
32 + pdus: []gosnmp.SnmpPDU{
33 + {Name: OidSnmpEngineTime, Type: gosnmp.NoSuchObject, Value: nil},
34 + {Name: "." + OidHrSystemUptime, Type: gosnmp.TimeTicks, Value: uint32(123456)},
35 + {Name: OidSysUpTime, Type: gosnmp.TimeTicks, Value: uint32(888800)},
36 + },
37 + expected: 1234,
38 + },
39 + "falls_back_to_mib2_sysuptime_timeticks": {
40 + pdus: []gosnmp.SnmpPDU{
41 + {Name: OidSnmpEngineTime, Type: gosnmp.NoSuchInstance, Value: nil},
42 + {Name: OidHrSystemUptime, Type: gosnmp.NoSuchObject, Value: nil},
43 + {Name: OidSysUpTime, Type: gosnmp.TimeTicks, Value: uint32(987654)},
44 + },
45 + expected: 9876,
46 + },
47 + "returns_zero_when_no_source_has_data": {
48 + pdus: []gosnmp.SnmpPDU{
49 + {Name: OidSnmpEngineTime, Type: gosnmp.NoSuchObject, Value: nil},
50 + {Name: OidHrSystemUptime, Type: gosnmp.NoSuchObject, Value: nil},
51 + {Name: OidSysUpTime, Type: gosnmp.NoSuchObject, Value: nil},
52 + },
53 + expected: 0,
54 + },
55 + "returns_get_error": {
56 + getErr: errors.New("timeout"),
57 + wantErr: true,
58 + },
59 + "returns_conversion_error_when_only_available_source_is_invalid": {
60 + pdus: []gosnmp.SnmpPDU{
61 + {Name: OidSnmpEngineTime, Type: gosnmp.OctetString, Value: []byte("bad")},
62 + {Name: OidHrSystemUptime, Type: gosnmp.NoSuchObject, Value: nil},
63 + {Name: OidSysUpTime, Type: gosnmp.NoSuchObject, Value: nil},
64 + },
65 + wantErr: true,
66 + },
67 + }
68 +
69 + for name, tc := range tests {
70 + t.Run(name, func(t *testing.T) {
71 + ctrl := gomock.NewController(t)
72 + defer ctrl.Finish()
73 +
74 + client := snmpmock.NewMockHandler(ctrl)
75 + client.EXPECT().Get(gomock.InAnyOrder(sysUptimeOIDs())).Return(&gosnmp.SnmpPacket{Variables: tc.pdus}, tc.getErr)
76 +
77 + actual, err := GetSysUptime(client)
78 + if tc.wantErr {
79 + require.Error(t, err)
80 + } else {
81 + require.NoError(t, err)
82 + }
83 + assert.Equal(t, tc.expected, actual)
84 + })
85 + }
86 +}